OpenStack Keystone in Rust
The legacy Keystone identity service (written in Python and maintained upstream by OpenStack Foundation) has served the OpenStack ecosystem reliably for years. It handles authentication, authorization, token issuance, service catalog, project/tenant management, and federation services across thousands of deployments. However, as we embarked on adding next-generation identity features—such as native WebAuthn (“passkeys”), modern federation flows, direct OIDC support, JWT login, workload authorization, restricted tokens and service-accounts—it became clear that certain design and performance limitations of the Python codebase would hamper efficient implementation of these new features.
Consequently, we initiated a project termed “Keystone-NG”: a Rust-based component that augments rather than fully replaces the existing Keystone service. The original plan was to implement only the new feature-set in Rust and route those new API paths to the Rust component, while keeping the core Python Keystone service in place for existing users and workflows.
As development progressed, however, the breadth of new functionality (and the opportunity to revisit some of the existing limitations) led to a partial re-implementation of certain core identity flows in Rust. This allows us to benefit from Rust’s memory safety, concurrency model, performance, and modern tooling, while still preserving the upstream Keystone Python service as the canonical “master” identity service, routing only the new endpoints and capabilities through the Rust component.
In practice, this architecture means:
-
The upstream Python Keystone remains the main identity interface, preserving backward compatibility, integration with other OpenStack services, existing user workflows, catalogs, policies and plugins.
-
The Rust “Keystone-NG” component handles new functionality, specifically:
-
Native WebAuthN (passkeys) support for passwordless / phishing-resistant MFA
-
A reworked federation service, enabling modern identity brokering and advanced federation semantics OIDC (OpenID Connect) Direct in Keystone, enabling Keystone to act as an OIDC Provider or integrate with external OIDC identity providers natively JWT login flows, enabling stateless, compact tokens suitable for new micro-services, CLI, SDK, and workload-to-workload scenarios
-
Workload Authorization, designed for service-to-service authorization in cloud native contexts (not just human users)
-
Restricted Tokens and Service Accounts, which allow fine-grained, limited‐scope credentials for automation, agents, and service accounts, with explicit constraints and expiry
-
By routing only the new flows through the Rust component we preserve the stability and ecosystem compatibility of Keystone, while enabling a forward-looking identity architecture. Over time, additional identity flows may be migrated or refactored into the Rust component as needed, but our current objective is to retain the existing Keystone Python implementation as the trusted, mature baseline and incrementally build the “Keystone-NG” Rust service as the complement.
We believe this approach allows the best of both worlds: the trusted maturity of Keystone’s Python code-base, combined with the modern, high-safety, high-performance capabilities of Rust where they matter most.
Compatibility
Highest priority is to ensure that this implementation is compatible with the original python Keystone: authentication issued by Rust implementation is accepted by the Python Keystone and vice versa. At the same time it is expected, that the new implementation may implement new features not supported by the Python implementation. In this case, it is still expected that such features do not break authentication flows. It must be possible to deploy Python and Rust implementation in parallel and do request routing on the web server level.
Database
Adding new features most certainly require having database changes. It is not expected that such changes interfere with the Python implementation to ensure it is working correctly.
API
Also here it is expected that new API resources are going to be added. As above it is not expected that such changes interfere with the Python implementation to ensure it is still working correctly and existing clients will not break.
Installation
The easiest way to get started with the keystone-ng is using the container image. It is also possible to use the compiled binary. It can be either compiled locally or downloaded from the project artifacts.
Using pre-compiled binaries
As of the moment of writing there were no releases. Due to that there are no pre-compiled binaries available yet. Every release of the project would include the pre-compiled binaries for a variety of platforms.
Compiling
In order to compile the keystone-ng it is necessary to have the rust compiler
available. It may be installed from the system packages or using the rustup.rs
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Afterwards in the root of the project source tree following command may be
executed to invoke the cargo
cargo build --release
It produces 2 binaries:
-
target/release/keystone (the api server)
-
target/release/keystone-db (the database management tool)
Currently keystone depends on the openssl (as a transitive dependency). Depending on the environment it may be a statically linked or dynamically. There are signals that that may be not necessary anymore once all dependencies transition to the use of rustls.
Using containers
It is possible to run Keystone-ng inside containers. A sample Dockerfile is
present in the project source tree to build container image with the Keystone
and the keystone-db utility. When no ready image is available it can be build
like that:
docker build . -t keystone:rust
Since keystone itself communicates with the database and OpenPolicyAgent those
must be provided separately. docker-compose.yaml demonstrates how this can be
done.
docker run -v /etc/keystone/:/etc/keystone -p 8080:8080 ghcr.io/openstack-experimental/keystone:main -v /etc/keystone/keystone.conf
Database migrations
Rust Keystone is using different ORM and implements migration that co-exist together with alembic migrations of the python Keystone. It also ONLY manages the database schema additions and does NOT include the original database schema. Therefore it is necessary to apply both migrations.
keystone-db --config /etc/keystone/keystone.conf
It is important to also understand that the DB_URL may differ between python and rust due to the optional presence of the preferred database driver in the url. keystone-ng will ignore the the driver in the application itself, but the migration may require user to manually remove it since it is being processed by the ORM itself and not by the keystone-ng code.
OpenPolicyAgent
keystone-ng relies on the OPA for policy enforcement. Default policies are provided with the project and can be passed directly to the OPA process or compiled into the bundle.
opa run -s policies
NOTE: by default OPA process listens on the localhost only what lead to
unavailability to expose it between containers. Please use -a 0.0.0.0:8181 to
start listening on all interfaces.
Parallel installation with the python Keystone
Since Keystone-NG is only an addition and is not a drop-in replacement for the python Keystone it is necessary to deploy both versions together.
With the python Keystone no changes on the deployment strategy should be necessary. Whichever WSGI method is used to run the Keystone it stays this way and continues listening on the expected port.
The rust Keystone is deployed in parallel to it (usually on the same hardware) and by default it listens on the port 8080.
Next required step is to alter the http proxy server configuration. In the devstack this is usually the Apache webserver. Some operators may run nginx or haproxy in front of the default webserver with the Keystone. Depending on the preferred functionality (i.e. whether the token validation should be performed by the python or the rust implementation) redirects can be implemented. This way it is possible to decide for every single API call individually whether it should be served by python or rust implementation.
Nginx sample configuration
server {
listen 443 ssl;
server_name devstack.v6.rocks;
...
# by default in devstack services are exposed with the url path style.
location /identity/v4 {
server http://localhost:8080;
}
proxy_pass http://<192.168.1.1>;
}
Apache sample configuration
<VirtualHost *:443>
ServerName devstack..v6.rocks
...
# Rust /v4 API
ProxyPass "/identity/v4" http://localhost:8080/v4 retry=0
# Python /v3 APIs are served by the uwsgi app
ProxyPass "/identity/v3" "unix:/var/run/uwsgi/keystone-api.socket|uwsgi://uwsgi-uds-keystone-api/v3" retry=0
# We want discovery URL to be served by Rust. The same way any /v3/ API can
# be forwarded to rust version (where supported).
ProxyPass "/identity" http://localhost:8080 retry=0
</VirtualHost>
Architecture
Keystone requires 2 additional components to run:
-
database (the same as the py-keystone uses)
-
OpenPolicyAgent, that implements API policy enforcement
architecture-beta
service db(database)[Database]
service keystone(server)[Keystone]
service opa(server)[OpenPolicyAgent]
db:L -- R:keystone
opa:L -- T:keystone
Database
Python keystone uses the sqlalchemy as ORM and the migration tool. It cannot be
used from Rust efficiently, therefore keystone-ng uses the sea-orm which
provides async support natively and also allows database type abstraction.
Current development focuses on the PostgreSQL database. The MySQL should be
supported, but is not currently tested against.
New API and resources are being added. This requires database changes. sea-orm
also comed with the migration tools. However there is a slight difference
between sqlalchemy and sea-orm. The later suggests doing database schema first.
In the next step object types are created out of the database. That means that
the database migration must be written first and cannot be automatically
generated from the code (easily, but there is a way). Current migrations do not
create database schema that is managed by the py-keystone. Therefore in order to
get a fully populated database schema it is necessary to apply
keystone-manage db_sync and keystone-db up independently.
Target of the keystone-ng is to be deployed in pair with the python keystone of “any” version. Due to that it is not possible to assume the state of the database, nor to apply any changes to the schema manaaged by the py-keystone. A federation rework assumes model change. To keep it working with the python-keystone artificial table entries may be created (in the example when a new identity provider is being created automatically sanitized entries are being added for the legacy identity provider and necessary protocols) A federation rework assumes model change. To keep it working with the python-keystone artificial table entries may be created (in the example when a new identity provider is being created automatically sanitized entries are being added for the legacy identity provider together with necessary idp protocols).
Fernet
keystone-ng uses the same mechanism for tokens to provide compatibility. The fernet-keys repository must be provided in the runtime (i.e. by mounting them as a volume into the container). There is no tooling to create or rotate keys as the py-keystone does.
Architecture Decision Records
Keystone project uses Architecture Decision Records to describe why the software is built how it is built.
1. Record architecture decisions
Date: 2025-11-03
Status
Accepted
Context
We need to record the architectural decisions made on this project.
Decision
We will use Architecture Decision Records, as described by Michael Nygard.
Consequences
See Michael Nygard’s article, linked above. For a lightweight ADR toolset, see Nat Pryce’s adr-tools.
2. Open Policy Agent
Date: 2025-11-03
Status
Accepted
Context
Use of oslo.policy is not easily possible from Rust. In addition to that during the OpenStack Summit 2025 it was shown how Open Policy Agent can be used to further improve the policy control in OpenStack. As such the Keystone implement the policy enforcement using the OPA with the following rules:
-
Listoperation MUST receive the all query parameters of the operation in the target. -
For
Showoperation the policy MUST receive the current record as the target (fetch the record and pass it into the policy engine). -
Updateoperation MUST receive current and new state of the resource (first the current resource is fetched and passed together with the new state [current, target] to the policy engine). -
Createoperation works similarly as current oslo.policy with the desired state passed to the policy engine. -
Deleteoperation MUST pass the current resource state of the resource into the policy engine.
Decision
The only policy enforcement engine supported in the Keystone is Open Policy Engine.
Consequences
-
Policy evaluation requires external service (OPA) to be running.
-
When covering existing functionality of the python Keystone policies SHOULD be converted as is and do not introduce a changed flow.
Standardized Policy Input Structure
The PolicyEnforcer interface is standardized with the following signature:
#![allow(unused)]
fn main() {
async fn enforce(
&self,
policy_name: &'static str,
credentials: &ValidatedSecurityContext,
target: Value,
existing: Option<Value>,
) -> Result<PolicyEvaluationResult, PolicyError>;
}
The OPA input document structure is:
{
"credentials": { "user_id": "...", "roles": [...], "domain_id": "...", ... },
"target": {
"<resource>": <object or null>
},
"existing": {
"<resource>": <object or null>
}
}
The <resource> key matches the REST resource type: user, group, role,
project, instance, idp, mapping, restriction, assignment, etc.
This prevents field name collisions between policies and ensures each resource’s
data is properly isolated.
Field Semantics Per Operation
The <resource> key matches the REST resource type:
user,group,role,project,instance,idp,mapping,restriction,assignment, etc.- This isolates data and prevents field name collisions between different resource schemas.
Policies read fields as input.target.user.domain_id,
input.existing.restriction.user_id, input.target.instance.name, etc.
Examples:
- Create user:
{"target": {"user": request_payload}} - Update restriction:
{"target": {"restriction": patch}, "existing": {"restriction": stored}} - Show group:
{"target": {"group": stored_object}} - List instances:
{"target": {"instance": query_params}}
Implementation Details
The handler-side contract for enforce():
- Create: Pass
serde_json::to_value(request_object)?as target,Noneas existing - Update: Pass
serde_json::to_value(patch)?as target,Option::from(stored_object).map(serde_json::to_value)as existing - Show: Pass
serde_json::to_value(stored_object)?as target,Noneas existing - Delete: Pass
serde_json::to_value(stored_object)?as target,Noneas existing - List: Pass
serde_json::to_value(query_params)?as target,Noneas existing
Policy Evaluation Guidelines
Ownership predicates that need to work across create/show/delete/update should
resolve the domain_id from either target or existing:
# Resolve domain_id from target or existing depending on operation
resource_domain_id := input.target.domain_id if {
input.target.domain_id
}
resource_domain_id := input.existing.domain_id if {
input.existing.domain_id
}
own_resource if {
resource_domain_id != null
resource_domain_id == input.credentials.domain_id
}
Validation rules (checking user-provided data for referential integrity, e.g.,
that domain/project/role IDs exist) should read from input.target for both
create and update operations, as target carries the user’s request in both
cases.
Notes
-
The
input.existingfield isValue::Nullwhen passed asNonefrom the handler, not an absent key. Policies can checkinput.existing == null. -
The
input.targetfield is nevernullexcept deliberately (e.g., when no target object is relevant). For operations where the object is the existing stored resource,targetcarries that object. -
Policy tests (
*_test.rego) should use the same input structure as handlers:- Create tests:
"target": { "binding": { ... } } - Update tests:
"target": { "binding": { ... } }, "existing": { "binding": { ... } } - Show/Delete tests:
"target": { "binding": { ... } }
- Create tests:
3. Sea ORM
Date: 2025-11-03
Status
Accepted
Context
Sea ORM provides a nice full async support while at the same time enables simultaneous support for multiple database backends (PostgreSQL, MySQL, …).
Decision
Sea ORM is used as the ORM library for the database communication.
Consequences
No known
4. v4 API
Date: 2025-11-03
Status
Accepted
Context
New authentication workflows cannot be covered with the existing Keystone v3 API. As such it is required to add new API methods and eventually change the existing. As such not to break compatibility and provide a relatively easy way to route the traffic allowing both project (python and rust) to co-exist a new v4 API version should be introduced.
Decision
-
All new auth methods MUST be implemented in v4.
-
Known issues with the v3 API SHOULD be addressed in the v3.
Consequences
-
New extended functionality will not be available in the v3.
-
Certain necessary changes may be ported to the v3 (including python) to implement backwards compatibility. Example: acknowledge new token payload issued with v4 with the python Keystone.
5. Passkey Auth
Date: 2025-11-03
Status
Accepted
Context
Nowadays password-less authentication becomes standard. In OpenStack it is at the moment not implemented whether on the API level (for the CLI) nor on the UI.
Webauthn is a well accepted standard for implementing password-less authentication with the help of hardware or software authenticators. Keystone should implement support for new authentication methods relying on the webauthn.
Decision
Introduce webauthn support in Keystone. This requires adding new database tables and introduction of the additional flows to allow user registering authenticators.
-
webauthn_credentialtable describes the authenticators of the user (user_id as the primary key). -
webauthn_statetable stores authentication and registration states according to the standard. -
User should be able to request the desired scope in the authentication initialization request. In this case a scoped token is returned when user has the required access.
-
To prevent attacks authentication requests for not existing users or users without registered authenticators MUST return fake (but valid) authentication state.
Consequences
New authentication method allows users to get valid token without requiring user to pass any secrets on the wire. Overall security of the system is increased.
6. Federation IDP
Date: 2025-11-03
Status
Accepted
Context
OIDC requires the server side to know the Identity provider details. Python Keystone relies on the external software to implement the OIDC flow only receiving the final data once the flow completes. Certain flows are triggered by the Service side (i.e. back-channel-logout). In addition to that relying on the external software does not allow any seld-service for the customer.
v3 currently provides limited OIDC support, but it is not possible to extend it in a backward compatible way.
As such OIDC support must be implemented natively in Keystone.
Decision
Keystone implement OIDC support natively without relying on the 3rd-party software. New APIs must provide self-service capabilities. Identity providers may be global (i.e. a social login) or dedicated (i.e. private Okta tenant).
A new set of APIs and database tables is added to Keystone for implementing new functionality. Existing DB constraints MUST not be deleted and only additive changes can be implemented to allow parallel deployment of python and rust Keystones for the smooth transition. “Virtual” database entries MUST be inserted for the old-style identity provider to guarantee the co-existence.
Global/private identity providers are implemented using the optional domain_id
attribute. When empty the identity provider is treated as global (shared) and is
correspondingly visible to every user of the cloud. Private IdPs SHOULD be only
visible to the users of the domain. Corresponding rules MUST be implemented on
the policy level to allow customization by the CSP.
The IdP specifies client_id and client_secret (when necessary). client_secret
MUST not be retrievable. It can only be set during create or update operations.
It MUST be also possible to specify JWKS urls when the identity provider does
not implement metadata discovery.
Consequences
New APIs must be implemented in the CLI.
7. Federation Mapping
Date: 2025-11-03
Status
Accepted
Context
OIDC protocol describes how the user data is being passed to the service provider. It is necessary to translate this information to the Keystone data model. In v3 “mapping” is being used to describe this translation. The data model of the v3 mapping is, however, unnecessary complex (due to historical reasons). HashiCorp vault describes “mapping“s as “roles” for such translations. Since it is not possible to use the term “role” the mapping should continue to be used instead. The model of the Vault role provides a nice and easy reference for Keystone.
Decision
“Mapping” (attribute mapping) MUST describe how the information from OIDC claims need to be translated into the Keystone data model. It MUST also describe user defined bounds to allow use restriction.
When domain_id is not being set on the IdP level it MUST be defined either on
the mapping entry, or the mapping MUST define domain_id_claim to extract the
information about domain relation of the user. domain_id MUST be immutable
property of the mapping to prevent moving it to the foreign domain.
Mapping MUST have the name attribute that is unique within the domain.
default_mapping_name property SHOULD be specified on the IdP level to provide
a default for when the user does not explicitly specify which mapping should be
used.
Consequences
- Mappings MUST be configured carefully to prevent login of users across the
domain borders.
bound_xxxshould be used extensively to guard this.
8. Workload Federation
Date: 2025-11-03
Status
Accepted
Context
It is often desired to access the OpenStack cloud from workloads (i.e. GitHub workflow, Zuul job, etc). Usually such services provide a JWT issued by the platform which the service provider can trust. This is very similar (and technically relates) to the OIDC standard.
In the JWT flow the “user” is exchanging a JWT token issued by the trusted IdP for a Keystone token. This authentication response includes a token and a service catalog to provide a known OpenStack usage scenario.
Decision
OIDC mappings MUST specify a type which is oidc or jwt to specify the flow
they define. A jwt type mapping can be only used in the JWT flow.
The new authentication API includes the IdP ID. The authentication request does
not support the Json request body and uses a generic
authorization: bearer <jwt> header and
openstack-mapping-name: <mapping_name> to request the information. Depending
on the mapping configuration the desired authorization scope is returned. The
flow does not support explicitly requesting the scope beyond what is described
by the mapping.
Consequences
-
A new API to exchange JWT token for the Keystone token is added.
-
JWT auth must provide the mapping name.
-
The mapping SHOULD point to some for of the technical user.
9. Auth token revocation
Date: 2025-11-18
Status
Accepted
Context
Issued tokens are having certain configurable validity. In cases when a user need to be disabled, the project deactivated, or simply to prevent the token use after the work has been completed it is necessary to provide the possibility to invalidate the tokens. Python Keystone provides this possibility and so it is necessary to implement it in the same way.
Since original functionality is not explicitly documented this ADR will become the base of such information.
Decision
Fernet token revocation is implemented based on the revocation_event database
table.
The table has following fields:
pub id: i32,
pub domain_id: Option<String>,
pub project_id: Option<String>,
pub user_id: Option<String>,
pub role_id: Option<String>,
pub trust_id: Option<String>,
pub consumer_id: Option<String>,
pub access_token_id: Option<String>,
pub issued_before: DateTime,
pub expires_at: Option<DateTime>,
pub revoked_at: DateTime,
pub audit_id: Option<String>,
pub audit_chain_id: Option<String>,
Token revocation
When a revocation of a valid token is being requested the record with the following information is being inserted into the database:
audit_idis populated with the first entry of the tokenaudit_idslist. When this list is empty an error is being returned.issued_beforeis set to the current time with the UTC timezone.revoked_atis set to the current time with the UTC timezone.- other fields are left empty.
Revocation check
A token validation for being revoked is performed based on the presence of the
revocation events in the revocation_event table matching the expanded token
properties. This means that before the token revocation is being checked
additional database queries for expanding the scope information including the
roles the token is granting are performed.
Following conditions are combined with the AND condition:
- First element of the token’s
audit_idsproperty is compared against the database record. When this list is empty an error is being returned. token.project_idis compared against the database record when present.token.user_idis compared against the database record when present.token.trustor_idis compared against the database recorduser_idwhen present.token.trustee_idis compared against the database recorduser_idwhen present.token.trust_idis compared against the database recordtrust_idwhen present.token.issued_atis compared against the database record withrevocation_event.issued_before >= token.issued_at.
Python version of the Keystone applies additional match verification for the selected data on the server side and not in the database query.
- When
revocation_event.domain_idis set it is compared againsttoken.domain_idandtoken.identity_domain_id. - When
revocation_event.role_idis present it is compared against every of thetoken.roles.
After the first non matching result further evaluation is being stopped. Logically there does not seem to be a reason for such handling and it looks to be an evolutionary design decision. Following checks can be added into the single database query with a different logic only comparing the corresponding fields when the column is not empty.
While following checks allow much higher details of the revocation events in the
context of the usual fernet token revocation it is only going to match on the
audit_id and issued_before.
Revocation table purge
In the python Keystone there is no automatic cleanup handling. Due to that expired records are removed during the revocation check. Records to be expired are selected using the following logic.
expire_delta = CONF.token.expiration + CONF.token.expiration_bufferoldest = utc.now() - expire_deltaDELETE from revocation_event WHERE revoked_at < oldest
When both python and rust Keystone versions are deployed in parallel and both
try to delete expired records errors can occur. However, if only rust version is
validating the tokens python version will not perform any backups. Additionally
no errors were reported yet in installations with multiple Keystone instances.
Therefore it is necessary for the rust implementation to do periodic cleanup. It
should be exexcuted with the following query filter:
revoked_at < (now - (expiration + expiration_buffer)). Such implementation
must be made optional with possibility to disable this behavior using the config
file.
Consequences
-
Database table with the revocation events must be periodically cleaned up.
-
Token validation processing time is increased with the database lookup.
-
Expired revocation records are optionally periodically cleaned by the rust implementation.
10. PCI-DSS requirement: Invalid authentication attempts are limited
Date: 2025-11-27
Status
Accepted
Context
PCI-DSS contains the following requirement to the IAM system:
Invalid authentication attempts are limited by:
- Locking out the user ID after not more than 10 attempts.
- Setting the lockout duration to a minimum of 30 minutes or until the user’s identity is confirmed.
Python Keystone implements this requirement with the help of the
conf.security_compliance.lockout_duration during the login attempt to identify
whether the user is currently temporarily disabled:
def _is_account_locked(self, user_id, user_ref):
"""Check if the user account is locked.
Checks if the user account is locked based on the number of failed
authentication attempts.
:param user_id: The user ID
:param user_ref: Reference to the user object
:returns Boolean: True if the account is locked; False otherwise
"""
ignore_option = user_ref.get_resource_option(
options.IGNORE_LOCKOUT_ATTEMPT_OPT.option_id
)
if ignore_option and ignore_option.option_value is True:
return False
attempts = user_ref.local_user.failed_auth_count or 0
max_attempts = CONF.security_compliance.lockout_failure_attempts
lockout_duration = CONF.security_compliance.lockout_duration
if max_attempts and (attempts >= max_attempts):
if not lockout_duration:
return True
else:
delta = datetime.timedelta(seconds=lockout_duration)
last_failure = user_ref.local_user.failed_auth_at
if (last_failure + delta) > timeutils.utcnow():
return True
else:
self._reset_failed_auth(user_id)
return False
Decision
For compatibility reasons rust implementation must adhere to the requirement.
During password authentication before validating the password following check must be applied part of the locked account verification:
-
When
conf.security_compliance.lockout_durationandconf.security_compliance.lockout_failure_attemptsare not set the account is NOT locked. -
When
user_options.IGNORE_LOCKOUT_ATTEMPTis set user account is NOT locked -
When
user.failed_auth_count >= conf.security_compliance.lockout_failure_attemptsthe account is locked. -
When
user.failed_auth_at + conf.security_compliance.lockout_duration > now()account is locked. When the time is< now()- reset the counters in the database. -
Otherwise the account is NOT locked.
After the authentication is success the user.failed_auth_at and
user.failed_auth_count are being reset. In the case of failed authentication
such attempt sets the mentioned properties correspondingly.
Consequences
-
Authentication with methods other than username password are not protected.
-
Reactivating the temporarily locked account can be performed by the admin or domain admin via resetting the
user.failed_auth_countattribute.
11. PCI-DSS requirement: Inactive user accounts are removed/disabled
Date: 2025-11-27
Status
Accepted
Context
PCI-DSS contains the following requirement to the IAM system:
Inactive user accounts are removed or disabled within 90 days of inactivity.
Python Keystone implements this requirement with the help of the
conf.security_compliance.disable_user_account_days_inactive during the login
attempt to identify whether the user is currently active or deactivated:
def enabled(self):
"""Return whether user is enabled or not."""
if self._enabled:
max_days = (
CONF.security_compliance.disable_user_account_days_inactive
)
inactivity_exempt = getattr(
self.get_resource_option(
iro.IGNORE_USER_INACTIVITY_OPT.option_id
),
'option_value',
False,
)
last_active = self.last_active_at
if not last_active and self.created_at:
last_active = self.created_at.date()
if max_days and last_active:
now = timeutils.utcnow().date()
days_inactive = (now - last_active).days
if days_inactive >= max_days and not inactivity_exempt:
self._enabled = False
return self._enabled
In python Keystone there is no periodic process that deactivates inactive accounts. Instead it is calculated on demand during the login process and listint/showing user details. With the new application architecture in Rust it is possible to implement background processes that disable inactive users. This allows doing less calculations during user authentication and fetching since it is possible to rely that the background process deactivates accounts when necessary.
Decision
For compatibility reasons rust implementation must adhere to the requirement.
After successful authentication when user.enabled attribute is not true the
authentication request must be rejected with http.Unauthorized.
Additional background process must be implemented to deactivate inactive
accounts. For this when
conf.security_compliance.disable_user_account_days_inactive is set a process
should loop over all user accounts. When the
user.last_active_at + disable_user_account_days_inactive < now() presence of
the user.options.IGNORE_USER_INACTIVITY_OPT should be checked. When absent the
account must be updated setting user.enabled to false.
Since it is technically possible that the background process is not running for any reason the same logic should be applied also when converting the identity backend data to the internal account representation and applied when the user data is reported by the backend as active. On the other hand having a separate background process helps updating account data in the backend and produce audit records on time without waiting for the on-demand logic to apply. It also allows disabling accounts in the remote identity backends that are connected with read/write mode (i.e. SCIM push).
After the successful authentication of the user with password or the federated
workflow the user.last_active_at should be set to the current date time.
Consequences
-
Authentication with methods other than username password are not updating the
lst_active_at. Due to that the account that used i.e. application credentials for the activation for more than X days would become disabled. This requires account to perform periodic login using the password. -
It should be considered to update application credentials workflow to update the
user.last_active_atattribute after successful authentication. -
It could happen that the periodic account deactivation process does not work for certain amount of time (i.e due to bugs in the code or the chosen frequency) allowing the user to login when it should have been disabled. This can be only prevented by applying the same logic during the conversion of the database entry to the internal
Userstructure the same way like python keystone is doing. -
Administrator account can be deactivated. Separate tooling or documentation how to unlock the account must be present.
12. PCI-DSS requirement: Inactive user accounts are removed/disabled
Date: 2025-11-27
Status
Accepted
Context
PCI-DSS contains the following requirement to the IAM system:
If passwords/passphrases are used as the only authentication factor for user access (i.e., in any single-factor authentication implementation) then either:
-
Passwords/passphrases are changed at least once every 90 days, OR
-
The security posture of accounts is dynamically analyzed, and real-time access to resources is automatically determined accordingly.
Python Keystone implements this requirement with the help of the
conf.security_compliance.password_expires_days and password.expires_at
during the login attempt to identify whether the specified used password is
expired. user.options.IGNORE_PASSWORD_EXPIRY_OPT option allows bypassing the
expiration check.
Decision
For compatibility reasons rust implementation must adhere to the requirement.
Password expiration is performed after verification that the password is valid.
-
password.expires_at_int(as epoch seconds) or thepassword.expires_at(as date time specifies the password expiration. When none is set password is considered as valid. Otherwise it is compared against the current time. -
During account password update operation when user is not having the
user.options.IGNORE_PASSWORD_EXPIRY_OPToption enabled the current date time plus theconf.security_compliance.password_expires_daystime is persisted as thepassword.expires_at_intproperty. -
Password expiration MUST NOT be enforced in the password change flow to prevent a permanent lock out.
Consequences
- Administrator account can be deactivated. Separate tooling or documentation how to unlock the account must be present.
13. OpenIDConnect federation: Expiring group membership
Date: 2025-12-09
Status
Accepted
Context
Python Keystone uses expiring group membership for the federated users
https://specs.openstack.org/openstack/keystone-specs/specs/keystone/ussuri/expiring-group-memberships.html.
Every time the user authenticates using the federated login it’s group
membership are persisted in the expiring_user_group_membership table instead
of the user_group_membership. The table has a non nullable column
last_verified which is set to the time of the last user login. The user is
considered to be included as a member of the group for the period of time
specified in the conf.federation.default_authorization_ttl. Once the
user.last_verified + ttl < current_timestamp() the user is not considered the
member of the group anymore. The intention of this mechanism is to prevent stale
group memberships granting the user privileges.
Decision
For compatibility reasons rust implementation must implement the same functionality.
Every time the user authenticates the user group memberships are persisted in
the expiring_user_group_membership table.
-
Current group membership is being read from the database (ignoring the time limitation).
-
The group membership that the user should not be having anymore are deleted.
-
For the new group memberships corresponding entries are added with the current timestamp.
-
For all other groups that the user is still member of corresponding records are updated to set
last_verifiedto the current timestamp. -
Effective role assignments of the user are taking into the consideration expiring group memberships through the
list_user_groupsrespecting the expiring membershipts (independent of theidp_id) asexpiring_user_group_membership.last_verified > current_timestamp - conf.federation.default_authorization_ttl.
Consequences
-
The user must login periodically to keep application credentials working when corresponding roles are granted through the expiring group membership.
-
With the SCIM support the expiring membership should not be necessary.
14. Application Credentials
Date: 2025-12-12
Status
Accepted
Context
Application Credentials will have the following characteristics:
-
Immutable.
-
Allow for optionally setting limits, e.g. 5 Application Credentials per User or Project, to prevent abuse of the resource.
-
Assigned the set of current roles the creating User has on the Project at creation time, or optionally a list of roles that is a subset of the creating User’s roles on the Project.
-
Secret exposed only once at creation time in the create API response.
-
Limited ability to manipulate identity objects (see Limitations Imposed)
-
Support expiration.
-
Are deleted when the associated User is deleted.
Application Credentials will be treated as credentials and not authorization tokens, as this fits within the keystone model and is consistent with others APIs providing application authentication. It also avoids the security and performance implications of creating a new token type that would potentially never expire and have custom validation.
Decision
For compatibility reasons rust implementation must implement the same functionality.
Application Credential Management
Users can create, list, and delete Application Credentials for themselves. For example, adding an Application Credential:
POST /v3/users/{user_id}/application_credentials
{
"application_credential": {
"name": "backup",
"description": "Backup job...",
"expires_at": "2017-11-06T15:32:17.000000",
"roles": [
{"name": "Member"}
]
}
}
name must be unique among a User’s application credentials, but name is only guaranteed to be unique under that User. name may be useful for Consumers who want human readable config files.
description is a long description for storing information about the purpose of the Application Credential. It is mostly useful in reports or listings of Application Credential.
expires_at is when the Application Credential expires. null means that the
Application Credential does not automatically expire. expires_at is in ISO
Date Time Format and is assumed to be in UTC if an explicit timezone offset is
not included.
roles is an optional list of role names or ids that is a subset of the roles the Creating User has on the Project to which they are scoped at creation time. Roles that the Creating User does not have on the Project are an error.
In the initial implementation, the Application Credential will assume the roles of the Creating User or the given subset and we will not implement fine-grained access controls beyond that.
Response example:
{
"application_credential": {
"id": "aa4541d9-0bc0-44f5-b02d-a9d922df7cbd",
"secret": "a49670c3c18b9e079b9cfaf51634f563dc8ae3070db2...",
"name:" "backup",
"description": "Backup job...",
"expires_at": "2017-11-06T15:32:17.000000",
"project_id": "1a6f968a-cebe-4265-9b36-f3ca2801296c",
"roles": [
{
"id": "d49d6689-b0fc-494a-abc6-e2e094131861",
"name": "Member"
}
]
}
}
The id in the response is the Application Credential identifier and would be returned in get or list API calls. An id is globally unique to the cloud.
secret is a random string and only returned via the create API call. Keystone
will only store a hash of the secret and not the secret itself, so a lost secret
is unrecoverable. Subsequent queries of an Application Credential will not
return the secret field.
roles is a list of role names and ids. It is informational and can be used by
the Consumer to verify that the Application Credential inherited the roles from
the User that the Consumer expected. This is not a policy enforcement, it is
simply for human validation.
If the Consumer prefers to generate their own secret, they can do so and provide it in the create call. Keystone will store a hash of the given secret. Keystone will return the secret once upon creation in the same way it would if it was generated, but will not store the secret itself nor return it after the initial creation.
A Consumer can list their existing Application Credentials:
GET /v3/users/{user_id}/application_credentials
{
"application_credentials": [
{
"id": "aa4541d9-0bc0-44f5-b02d-a9d922df7cbd",
"name:" "backup",
"description": "Backup job...",
"expires_at": "2017-11-06T15:32:17.000000",
"project_id": "1a6f968a-cebe-4265-9b36-f3ca2801296c",
"roles": [
{
"id": "d49d6689-b0fc-494a-abc6-e2e094131861",
"name": "Member"
}
]
}
]
}
A Consumer can get information about a specific existing Application Credential:
GET /v3/users/{user_id}/application_credentials/{application_credential_id}
{
"application_credentials": [
{
"id": "aa4541d9-0bc0-44f5-b02d-a9d922df7cbd",
"name:" "backup",
"description": "Backup job...",
"expires_at": "2017-11-06T15:32:17.000000",
"project_id": "1a6f968a-cebe-4265-9b36-f3ca2801296c",
"roles": [
{
"id": "d49d6689-b0fc-494a-abc6-e2e094131861",
"name": "Member"
}
]
}
]
}
A Consumer can delete one of their own existing Application Credential to invalidate it:
DELETE /v3/users/{user_id}/application_credentials/{application_credential_id}
Note
Application Credentials that expire will be deleted. The alternative would be to allow them to accumulate for forever in the hopes that keeping them around will make investigation as to why an Application is not working easier, but the only real benefit to this is providing a different error message. More thought and feedback on this are needed, but are not essential for the first round of work.
When the Creating User for an Application Credential is deleted, or if their roles on the Project to which the Application Credential is scoped are unassigned, that Application Credential is also deleted.
Aside from deletion, Application Credentials are immutable and may not be modified. Using an Application Credential to Obtain a Token
An Application Credential can be used for authentication to request a scoped token following Keystone’s normal authorization flow. For example:
POST /v3/auth/tokens
{
"auth": {
"identity": {
"methods": [
"application_credential"
],
"application_credential": {
"id": "aa4541d9-0bc0-44f5-b02d-a9d922df7cbd",
"secret": "a49670c3c18b9e079b9cfaf51634f563dc8ae3070db2..."
}
}
}
}
Keystone will validate the Application Credential by matching a hash of the key secret associated with the id similar to how Keystone does Password authentication currently.
If the Application Credential is referred to by name, it will be necessary to
provide either user_id or the combination of user_name and
user_domain_name so that Keystone can look up the Application Credential for
the User.
POST /v3/auth/tokens
{
"auth": {
"identity": {
"methods": [
"application_credential"
],
"application_credential": {
"name": "backup",
"user": {
"id": "1a6f968a-cebe-4265-9b36-f3ca2801296c"
},
"secret": "a49670c3c18b9e079b9cfaf51634f563dc8ae3070db2..."
}
}
}
}
As an alternative to the current use of Service Users, a Deployer could create a single Service User and an Application Credential for each service. Or even create a Nova user and then give each nova instance it’s own Application Credential. Although at this point the Application Credential does not have the ability to further limit API use, the ability to start assigning Application Credentials per-service and performing expiration and rotation may be a desirable step forward that can be further enhanced with the addition of restricting an Application Credential’s API Access.
Consequences
This would have a positive security impact:
-
Instead of having a Service User for each service, all services can use a single Service User and multiple Application Credentials. This decreases the attack vector of gaining access to privileged operations by reducing the number of accounts to attack.
-
Usernames and passwords are kept out of configuration files. While Application Credentials are still extremely sensitive, if compromised they do not allow attackers to glean service user password conventions from configuration.
-
Application Credentials will grow the ability to have limited access, so a move to them is a step towards limited access credentials.
-
Application Credentials can be gracefully rotated out of use and deleted periodically, allowing Consumers and Deployers a mechanism to prevent compromised Users without requiring swapping credentials in short amounts of time that might cause service interruption or downtime.
-
Although we had long considered allowing application credentials to live beyond the lifetime of its creating user in order to allow seamless application uptime when the user leaves the team, it unfortunately poses too high a risk for abuse. Ensuring the application credential is deleted when the user is deleted or removed from the project will prevent malicious or lazy users from giving themselves access to a project when they should no longer have it.
There is an inherent risk with adding a new credential type and changing authentication details. One such risk would be the allowing of many credentials for the same User account.
End user impact
- Consumers who have Applications that monitor or interact with OpenStack Services should be able to leverage this feature to improve the overall security and manageability of their Applications.
- Consumers can gracefully rotate Application Credentials for an Application with no downtime by creating a new Application Credential, updating config files to use the new Application Credential, and finally deleting the old Application Credential.
- Consumers who do not start using Application Credentials should experience no impact.
Deployers impact
- Deployers only need to enforce security on a single Service User instead of multiple.
- Password rotation policies for Service Users no longer require immediately redeploying service configuration files. A User password change does not affect the existing Application Credential in the various service configuration files.
- Deployers can gracefully rotate Application Credentials through a deployment with no downtime.
15. Kubernetes Authentication Mechanism for Keystone
Date: 2026-02-17
Status
Accepted
Context
Currently, Keystone supports various authentication mechanisms (Password, Token, TOTP, External, etc.). As OpenStack increasingly runs alongside or underneath Kubernetes workloads, there is a need for “machine authentication” where a Kubernetes Pod can exchange its Service Account Token (JWT) for a Keystone token without managing long-lived secrets like passwords or API keys.
This implementation follows the logic used by OpenBao:
- Trust Establishment: Keystone is configured to trust a Kubernetes API server’s JWT issuer.
- Role Mapping: A Kubernetes Service Account (and namespace) is mapped to a specific Keystone Project/Role.
- Validation: Keystone validates the incoming JWT against the Kubernetes TokenReview API.
Decision
We will implement a new kubernetes auth method in Keystone. This requires
persistent storage to manage multiple Kubernetes clusters (backends) and the
mapping of Kubernetes identities to OpenStack identities.
1. Data Model Changes
Two new tables will be introduced to the Keystone schema.
Table: kubernetes_auth_instance
This table stores the configuration for connecting to and validating tokens from external Kubernetes clusters.
| Column | Type | Description |
|---|---|---|
id | String(64) | Primary Key (UUID). |
domain_id | String(64) | Domain ID (UUID). |
enabled | Boolean | Enabled flag. |
name | String(255) | Unique name for this K8s backend configuration. |
host | String(255) | The URL of the Kubernetes API server (e.g., https://10.0.0.1:6443). |
token_reviewer_jwt | Text | A long-lived JWT used by Keystone to access the K8s TokenReview API. |
ca_cert | Text | PEM encoded CA cert for the K8s API (optional for self-signed). |
disable_local_ca_jwt | Boolean | Enable/disable use of the local CA and/or token as own TokenReview auth. |
Table: kubernetes_auth_role
This table maps Kubernetes-specific attributes (Namespace/ServiceAccount) to Keystone-specific token restriction (User/Project/Roles).
| Column | Type | Description |
|---|---|---|
id | String(64) | Primary Key (UUID). |
auth_instance_id | String(64) | Foreign Key to kubernetes_auth_instance.id. |
enabled | Boolean | Enabled flag. |
token_restriction_id | String(64) | Foreign Key to token_restriction.id. |
bound_service_account_names | Text | List of allowed SAs (comma-separated or JSON). |
bound_service_account_namespaces | Text | List of allowed Namespaces (comma-separated or JSON). |
bound_audience | String(128) | Optional Audience claim to verify in the JWT. |
Token Restrictions represent here a finite mapping of the user_id (which
should point to the service account user and MUST be set), the target project
(based on the project_id) and the corresponding roles granted on this scope.
As such it is not required to grant the user roles on the project directly and
instead only specify them in the token restriction mapping.
2. Required API
Administrative API (CRUD for Configuration)
Endpoints to manage the trust relationship.
- POST
/v4/k8s_auth/: Register a new Kubernetes cluster. - GET/PATCH/DELETE
/v4/k8s_auth/instances/{cluster_id}: Manage cluster config. - POST
/v4/k8s_auth/instances/{cluster_id}/roles: Create a mapping between a K8s SA/Namespace and a Keystone Project. - GET/PATCH/DELETE
/v4/k8s_auth/instances/{cluster_id}/roles/{role_name}: Manage role mappings. - POST
/v4/k8s_auth/instances/{cluster_id}/auth: Exchange K8s SA token for Keystone token.
Authentication API (The “Login” Flow)
The new authentication endpoint is exposed under
/v4/k8s_auth/instances/{cluster_id}/auth and expects a json payload with a
POST method.
Request Payload:
{
"k8s_role": "web-servers-role",
"jwt": "<jwt_from_k8s_service_account_token_volume_projection>"
}
3. Authentication Workflow
- Lookup: Keystone receives the request, identifies the
role. It fetches the associatedkubernetes_auth_instanceconfig. - Verification: Keystone calls the Kubernetes API (
host) at the/apis/authentication.k8s.io/v1/tokenreviewsendpoint using thetoken_reviewer_jwtor the user specifiedjwtwhentoken_reviewer_jwtis unset. In the later case it is required that the service account has thesystem:auth-delegatorClusterRole. It can be granted with
kubectl create clusterrolebinding client-auth-delegator \
--clusterrole=system:auth-delegator \
--group=group1 \
--serviceaccount=default:svcaccount1 ...
- Validation:
- K8s returns the status of the JWT.
- Keystone verifies that the
kubernetes.io/serviceaccount/service-account.nameandnamespaceclaims match thebound_service_account_namesandnamespacesin thekubernetes_auth_roletable.
- Token Issuance: If valid, Keystone issues a scoped token for the
token_restriction_iddefined in the role mapping.
Consequences
-
Pros:
- Enables seamless “Secretless” authentication for workloads running on Kubernetes.
- Matches industry standards set by OpenBao/Vault.
- Supports multi-tenancy by allowing multiple Kubernetes clusters to connect to one Keystone.
-
Cons:
- Keystone must have network line-of-sight to the Kubernetes API server.
- Adds complexity to the identity backend.
16. Distributed Encrypted Storage via Raft and Fjall
Date: 2026-04-12
Status
Accepted
Context
The current implementation of keystone requires a storage back-end that provides high availability, strong consistency for identity assignments, and industry-leading security for PII and secrets. Traditional SQL databases often introduce complexity in secret management and lack native “At-Rest” encryption tied to the application’s lifecycle.
We need a solution that:
-
Guarantees Consistency: Identity changes must be linearizable.
-
Embedded Performance: An embedded LSM-tree to avoid external database network overhead.
-
Cryptographic Sovereignty: Data must be encrypted before it hits the log or the disk, ensuring a “Zero-Knowledge” storage layer.
Decision
We will implement a distributed storage engine using OpenRaft for consensus and Fjall as the local State Machine and Log Store. The architecture will follow the “Vault-style” encryption model.
- The Storage Stack
-
Consensus: openraft (Rust) for managing cluster membership and log replication.
-
LSM-Tree: fjall for high-performance, disk-backed storage of the state machine.
-
Serialization: rmp-serde (MessagePack) for compact binary representation of log entries.
- The Cryptographic Barrier
To ensure data is never stored in plain-text on disk:
-
AEAD Encryption: Use AES-256-GCM for all payloads.
-
Log Binding: The Raft Index will be used as Associated Data (AD) for log entries to prevent replay attacks.
-
Storage Binding: The Primary Key (e.g., UserID) will be used as AD for FjallDB entries to prevent key-substitution attacks.
-
Key Hierarchy: A Master Key (KEK) provided via Environment/HSM will wrap a volatile Data Encryption Key (DEK) kept in memory.
- Data Flow
-
Write Path:
API receives a request → Serialize to MsgPack → Encrypt → Propose to OpenRaft.
Apply step: Decrypt using Raft Index → Re-encrypt for storage → Write to Fjall.
-
Read Path:
Linearizable Read: Follower queries Leader for ReadIndex → Follower waits for local apply → Decrypt from Fjall → Return over mTLS.
Technical Specifications
gRPC Definitions
The internal Raft communication will use an opaque binary payload to keep the consensus layer decoupled from the IAM logic.
Protocol Buffers
#![allow(unused)]
fn main() {
message RaftEntry {
uint64 term = 1;
uint64 index = 2;
// Optional Membership config.
Membership membership = 3;
// Optional Store request.
// [12b Nonce][Ciphertext][16b Tag] }
optional bytes app_data = 4;
}
}
Type Configurations (openraft)
#![allow(unused)]
fn main() {
openraft::declare_raft_types!(
pub KeystoneConfig:
D = EncryptedBlob, // Vec<u8> wrapper
R = Response, // Ephemeral, plain-text over mTLS
NodeId = u64,
Node = BasicNode,
);
}
Consequences
Positive
-
Security: Compromising the disk or the Raft log does not leak user secrets.
-
Performance: Fjall provides SSD-optimized writes and efficient prefix-seeking for IAM queries.
-
Simplicity: No external dependency on Postgres/MySQL; the binary is self-contained. Operator is able to select the traditional SQL backend drivers though.
Negative / Risks
-
CPU Overhead: Every write/read involves AES-GCM operations.
-
Operational Complexity: Cluster forming, backup/restore operations are now part of the Keystone operations.
-
Stale Reads: If not configured correctly, followers might serve stale identity data unless the ReadIndex protocol is strictly followed.
Compliance
All secret handling must implement the Zeroize trait to ensure plain-text data is wiped from RAM immediately after gRPC transmission.
ADR 0016-v2: Distributed Encrypted Storage via Raft and Fjall
Date: 2026-06-13 Last-revised: 2026-07-02 (PKCS#11/TPM KEK provisioning addendum)
Status
Proposed
Supersedes: ADR-0016 (2026-04-12)
Security review findings applied (2026-06-24):
- F1 HIGH: Raft log nonce NodeId widened from 4 to 8 bytes; counter shrunk to 4 bytes (§2.2)
- F2 MEDIUM: Audit HMAC key derivation made per-node via
node_idin HKDF info (§3.1) - F3 MEDIUM: Backup manifest AD now includes
dek_version_u32to prevent same-second swap (§7) - F4 MEDIUM: SPIFFE mode expanded with TTL ceiling, fail-closed behaviour, SPIFFE ID pattern (§4.1)
- F5 MEDIUM: Quarantine state specified as Raft-committed and restart-persistent (§10 invariant 5)
- F6 LOW: Emergency rotation confirmation timeout now has an explicit abort + audit path (§6.2)
- F7 LOW: NodeId uniqueness check specified as fail-closed when leader is unreachable (§4.3)
- F8 LOW: Sub-key derivation notation changed from
HKDF-SHA256toHKDF-Expand(§2.1)
Addendum applied (2026-07-02): §2.5 added, specifying the concrete PKCS#11
and TPM 2.0 KEK provider mechanisms that §2.1 previously deferred (“HSM /
PKCS#11 / Cloud KMS”), the kek_provider configuration schema, and new
invariants 13–15 (§10).
Context
Keystone-NG requires a storage backend providing high availability, strong linearizable consistency for identity assignments, and absolute cryptographic sovereignty over PII and secrets. Traditional SQL databases lack native application-lifecycle encryption and introduce external network dependencies.
We need a solution that:
- Guarantees Consistency: Identity changes must be linearizable; a revoked user or disabled account must never be observable as active by any node.
- Embedded Performance: An embedded LSM-tree avoids external database network overhead.
- Cryptographic Sovereignty: Data must be encrypted before it touches the Raft log or disk. A full disk or log compromise must not leak plaintext payloads or user identifiers.
- Zero-Trust Transport: Intra-cluster communication must be mutually authenticated with short-lived, automatically rotated credentials.
Decision
We will implement a distributed storage engine using OpenRaft for consensus
and Fjall as the local state machine and log store, following a
“Vault-style” encryption model. Intra-cluster mTLS supports two modes (spiffe
and tls), and local follower reads are permitted strictly for non-sensitive
data via a cryptographically bound tiering system.
1. The Storage Stack
| Layer | Component | Role |
|---|---|---|
| Consensus | openraft (Rust) | Log replication, cluster membership, linearizability |
| LSM-Tree | fjall | State machine and log store (SSD-optimized) |
| Serialization | rmp-serde (MessagePack) | Compact binary log entries |
| Transport | gRPC over mTLS | Intra-cluster Raft RPC (SPIFFE or Custom PKI) |
| Management | keystone-manage CLI | Cluster ops: init, join, quarantine, DEK rotation |
Management Interface: Admin operations are performed via the
keystone-manage storage CLI, which communicates with the cluster over gRPC
with mTLS enforcement (SPIFFE SVID or operator-managed TLS certificates). This
gRPC interface is not exposed to the public network — it is accessible only on
the internal management network to operators.
Authorization is enforced at the gRPC interceptor level using the mTLS client
identity. Each management RPC has an explicit allow-list mapping SPIFFE SVID
identities (or TLS SAN URIs) to permitted operations. For example, only SVIDs
with a storage-operator role tag may invoke RotateDek or ClearQuarantine.
Network isolation serves as a compensating control, not the sole enforcement
boundary, in accordance with the zero-trust principle stated in §Context.
Rate Limiting: Management RPCs enforce per-source-IP and per-identity rate
limits. RotateDekRequest is limited to 2 invocations per hour per operator;
ClearQuarantineRequest is limited to 10 per hour per operator.
RotateDekRequest{emergency: true} additionally requires dual-control approval:
a second operator with the storage-operator role must confirm within 5 minutes
via a separate ConfirmRotateDekRequest RPC. Dual-control events are recorded
in the audit log with both operator identities.
Supply Chain: Core dependencies (openraft, fjall) are pinned to exact
versions in Cargo.lock. New releases must pass a manual security review before
upgrading. A contingency plan (fork, vendor, or replace) is maintained for each
dependency. cargo-vet or equivalent is used in CI for these two crates
specifically. cargo deny rules reject any transitive dependency that stores
key material without implementing ZeroizeOnDrop. cargo-vet coverage is
extended to all crates that directly handle key material or ciphertext: the
AES-GCM provider, the mlock wrapper, the HKDF implementation, and — per the
§2.5 PKCS#11/TPM addendum — the two production KEK-provider crates’ HSM/TPM
client libraries: cryptoki (pinned 0.12.0, storage-crypto-pkcs11) and
tss-esapi (pinned 7.7.0, storage-crypto-tpm). Both hold the unwrapped DEK
transiently during wrap_dek/unwrap_dek and sit directly in the KEK call
path, so they meet criteria (a) and (b) below. Any new
dependency falls under extended cargo-vet coverage if it: (a) receives or stores
a Zeroizing<T> value; (b) is in the AES-HKDF-KMS call path; (c) provides
mlock or VirtualLock functionality; or (d) processes raw ciphertext before
decryption. Pull request reviewers must verify new Cargo.toml entries against
this checklist before approval.
2. The Cryptographic Barrier
2.1 Key Hierarchy
HSM / PKCS#11 / Cloud KMS
│
▼
Master Key (KEK) ← never touches RAM as plaintext
│
│ AES-256-GCM unwrap
▼
Data Encryption Key (DEK) ← 256-bit random key, generated at bootstrap
│
├── Log DEK (LD) ← HKDF-Expand(DEK, info="keystone-raft-log-v1", L=32)
├── State DEK (SD) ← HKDF-Expand(DEK, info="keystone-fjall-state-v1", L=32)
└── Backup DEK (BDEK) ← HKDF-Expand(DEK, info="keystone-backup-v1" ++ dek_version_u32_be, L=32)
DEK Bootstrap: DEK generation MUST target an already-mlock’d allocation; it MUST NOT be generated into an unlocked buffer and subsequently copied. A transient unlocked copy would allow the DEK to be written to swap, bypassing the memory protection described in §9.
- KEK Provisioning (Production): KEK resides in an HSM or Cloud KMS. The KEK never enters process memory.
- KEK Provisioning (Development): An environment variable
KEYSTONE_DEV_KEKmay supply a hex-encoded KEK. The process refuses to start unless--dev-modeandKEYSTONE_ALLOW_ENV_KEK=1are explicitly set. After readingKEYSTONE_DEV_KEK, the process must immediately unset it viastd::env::remove_varand zero the original string. The variable must not persist in the process environment for the lifetime of the process, preventing exposure via/proc/<pid>/environ. - DEK Derivation: The DEK is derived into isolated sub-keys via
HKDF-Expand (Expand-only; the DEK is already uniformly random so HKDF-Extract
is not needed) to ensure log, state, and backup ciphertexts are never
encrypted under the same key context. The backup DEK (BDEK) incorporates the active
dek_version_u32into its derivation input, binding it to the current DEK epoch. This ensures backups created under different DEK rotations do not share the same BDEK, limiting the blast radius of a BDEK compromise.
2.2 Nonce Management & GCM Tags
AES-256-GCM requires strict nonce uniqueness. Truncated tags are prohibited; all tags must be 16 bytes.
- Raft Log (Log DEK): Nonce is
[8-byte NodeId BE] ++ [4-byte monotonic counter BE]. NodeId occupies the full 8 bytes (matching theu64type in §8) to prevent nonce-space collision between nodes that share the same lower 32 bits. The counter is stored durably and increments with a reservation block of 1024 to absorb crashes. On startup, the persisted counter is validated against a separately-stored high-water mark (kept in a dedicated Fjall metadata key_meta:nonce_hwm:<node_id>); if the recovered counter is less than or equal to the high-water mark, the node refuses to start and requires operator intervention, preventing nonce reuse from counter corruption. After each reservation block write, the node verifies the write by reading back and comparing; if the read-back does not match, the node treats it as a fatal storage error and halts, preventing silent nonce reuse during a live session. A warning is emitted when remaining counter space drops below 10% of the2^31rotation threshold. - Fjall State Machine (State DEK): Nonce is derived via
HKDF-Expand(StateDek, info=PrimaryKey || version_u32, L=12). Theversionfield starts at0for new records and increments on each update. This guarantees that even if the same primary key is re-written, the nonce is unique under the current DEK.u32is used (allowing ~4.3 billion updates per record per DEK epoch); with 90-day rotation and realistic IAM workloads, overflow is not expected. The version is stored as a 4-byte big-endian suffix alongside the ciphertext in Fjall, laid out as:[nonce_12b][ciphertext][tag_16b][version_u32_BE]. On read, the version is extracted from this suffix to determine the next increment value.
2.3 Associated Data (AD) Bindings
To prevent ciphertext substitution and data tampering, we tightly bind the context of the data to the AES-GCM envelope.
| Context | Associated Data Binding | Attack Prevented |
|---|---|---|
| Raft log entry | term ++ index (16 bytes, big-endian) | Index-substitution (replay attacks) |
| Fjall state entry | 1b_tier_marker ++ domain_id ++ primary_key | Key-substitution and Read-Tier tampering |
| Metadata entry | b"keystone-meta-v1" ++ meta_key_bytes | Metadata confused with app data |
| Backup envelope | b"keystone-backup-v1" ++ snapshot_utc_epoch_be_u64 ++ dek_version_u32 | Time-travel / Backup replay |
2.4 Known Limitation: Primary Key Confidentiality
Fjall stores primary keys (UserIDs, domain identifiers, etc.) as plaintext index entries. An attacker with disk access can enumerate all stored identifiers without decrypting values. All primary keys are UUIDv4 — cryptographically random identifiers with no encoded semantic content. An attacker can learn account cardinality (total count) and existence, but cannot determine personal identities, names, emails, or other PII without decrypting the associated values under the DEK. This reduces the exposure from PII disclosure to merely revealing the existence and cardinality of accounts.
This is an accepted limitation: encrypting index keys would require deterministic encryption (leaking frequency patterns) or an oblivious data structure (impractical for an LSM-tree). The AES-256-GCM AD binding ensures that even with full index enumeration, the attacker cannot move values between keys or decrypt payloads without the DEK.
Residual Correlation: LSM-tree key ordering reveals creation order, and
cross-reference fields (e.g., domain_id stored as a value in user records)
link identifiers — but only when the values are decrypted. An attacker with
plaintext keys alone cannot perform cross-account correlation.
Access Pattern Leakage: Access pattern analysis can reveal which keys are read or written and when, correlating active accounts and revocation events. This is evaluated against the in-scope attacker (physical disk access, backup exfiltration) — the attacker cannot observe real-time access patterns on a cold disk or static backup. For deployments where an attacker can monitor hardware- level I/O patterns, this constitutes an accepted limitation.
Threat Model: This limitation is evaluated against the following attacker capabilities:
- In scope: Physical disk access, backup exfiltration. The attacker can enumerate identifiers but cannot read values, modify data, impersonate nodes, or determine account ownership without the DEK and valid mTLS credentials.
- Out of scope: Attacker with KMS access, rogue operator with root
privileges, or supply-chain compromise of
fjall. The legal/compliance team has reviewed this limitation against applicable regulations (GDPR pseudonymisation obligations) and confirmed it meets the organization’s privacy requirements.
2.5 PKCS#11 and TPM KEK Provisioning
§2.1 named “HSM / PKCS#11 / Cloud KMS” as the production KEK source but did
not specify a mechanism. This section specifies the PKCS#11 and TPM 2.0
KekProvider implementations. Both satisfy invariant 2 (§10): the KEK itself
never enters process memory in plaintext, only wrapped DEK bytes cross the
provider boundary.
Provider selection: [distributed_storage] kek_provider selects between
env (dev-mode only, §2.1), pkcs11, and tpm. Exactly one provider is
active per node. Production deployments (dev_mode = false) MUST set this to
pkcs11 or tpm; kek_provider = "env" outside dev_mode is rejected at
config-validation time, before any KEK material is touched (invariant 6).
2.5.1 PKCS#11
The KEK is an AES-256 key object resident on a PKCS#11 token (a hardware HSM
or, for development/CI, SoftHSM2), created with CKA_EXTRACTABLE = false and
CKA_SENSITIVE = true. wrap_dek/unwrap_dek invoke CKM_AES_GCM directly
against that key object (C_EncryptInit/C_Encrypt and
C_DecryptInit/C_Decrypt with a CK_GCM_PARAMS structure carrying a
freshly generated 12-byte nonce, DEK_WRAP_AD as additional authenticated
data, and a 128-bit tag). The resulting wire format is byte-identical to
EnvKek’s: [12-byte nonce][ciphertext][16-byte tag] — no downstream code
(DEK bootstrap, Fjall metadata storage) needs to distinguish which
KekProvider produced a wrapped blob.
- Key provisioning: creating the AES-256 key object on the token is an
out-of-band operator step (e.g.
pkcs11-tool --keygenorsofthsm2-util --init-tokenfor development), documented in the operator guide. The provider MAY auto-generate the key viaCKM_AES_KEY_GENon first startup ifpkcs11_key_labelis not found on the configured slot — this is an ergonomic convenience for fresh clusters, not a substitute for operator-controlled key ceremony in regulated deployments. - PIN handling: the token PIN is read once at startup from
pkcs11_pin_fileinto aSecretSlice(zeroized onDrop), used forC_Login. It is held for the duration of theinit_storagecall — not scrubbed the instantC_Loginreturns, since the same in-memory config clone is threaded through the rest of node startup — and zeroized once that call completes and the clone is dropped. The PIN is never accepted via environment variable or inline config value — only a file path, consistent with the existingtls_key_file/tls_cert_fileconvention (§4.2). - Failure handling: a login failure, missing key object, or GCM tag mismatch on unwrap is fatal to node startup (or, post-startup, treated the same as any other GCM tag failure under invariant 5’s quarantine logic).
2.5.2 TPM 2.0
The KEK is a non-duplicable, TPM-resident symmetric-cipher key
(fixedTPM | fixedParent | sensitiveDataOrigin, no duplication attribute).
As with PKCS#11, the raw key material never leaves the TPM and never enters
process memory — only the wrapped DEK crosses the boundary.
TPM 2.0 has no native AES-GCM command. TPM2_EncryptDecrypt2 supports
only CFB/CBC/CTR/OFB/ECB symmetric modes; there is no AEAD primitive. The TPM
provider therefore uses Encrypt-then-MAC instead of AES-GCM:
wrap_dek(dek):
iv = random 16 bytes
ciphertext = TPM2_EncryptDecrypt2(key = tpm_kek, mode = AES-256-CFB, iv, data = dek)
tag = TPM2_HMAC(key = tpm_hmac, data = iv ++ ciphertext ++ DEK_WRAP_AD)
wrapped = iv ++ ciphertext ++ tag // [16b][32b][32b] = 80 bytes
unwrap_dek(wrapped):
split wrapped into iv, ciphertext, tag
expected_tag = TPM2_HMAC(key = tpm_hmac, data = iv ++ ciphertext ++ DEK_WRAP_AD)
reject unless expected_tag == tag (constant-time compare) — never attempt
TPM2_EncryptDecrypt2 on an unauthenticated ciphertext
dek = TPM2_EncryptDecrypt2(key = tpm_kek, mode = AES-256-CFB, iv, data = ciphertext, decrypt = true)
This is a deliberate deviation from “AES-256-GCM for all payloads” (§2.2)
scoped strictly to the TPM KEK-wrap boundary: it does not touch the Log DEK,
State DEK, or Backup DEK, which remain AES-256-GCM as specified elsewhere in
this ADR. tpm_kek and tpm_hmac MAY be the same TPM key object used in two
different USAGE modes if the provisioning tooling supports it, or two
separate persistent handles; either is acceptable provided both satisfy the
non-duplicable, non-extractable attributes above.
- Key provisioning: a persistent handle (
tpm_key_handle) or a saved key context (tpm_key_context_file) identifying the pre-provisioned TPM key(s). Provisioning itself (viatpm2_create/tpm2_evictcontrolor equivalent) is an out-of-band operator step, documented alongside the PKCS#11 key ceremony. - Auth handling: if the key was provisioned with
userWithAuth, the auth value is read once fromtpm_auth_fileinto aSecretSlice(zeroized onDrop) and used to authorize the TPM session. As with the PKCS#11 PIN (§2.5.1), it is held for the duration of theinit_storagecall rather than scrubbed immediately after use, and zeroized once that call completes. Only a file path is accepted, never an environment variable or inline value. A key relying purely on PCR/policy session authorization may omittpm_auth_file. - Sample scope: the TPM provider ships with a runnable example targeting
a software TPM (
swtpm) for local exploration, and is not part of the required CI gate — real and virtual TPM availability in CI runners is not reliable enough to gate merges on. The PKCS#11 path (§2.5.1), backed by SoftHSM2, is the CI-gated path (§1).
3. Read Consistency and Data Tiers
To optimize read-heavy IAM workloads without sacrificing security, data is categorized into sensitivity tiers. The tier marker is prefixed to the plaintext and bound into the AES-GCM AD; altering the tier invalidates the ciphertext.
| Tier | Label | Allowed Read Modes | Examples |
|---|---|---|---|
| 0 | PUBLIC | Local Read, Linearizable | Feature flags, role display names |
| 1 | INTERNAL | Local Read, Linearizable | Display attributes, internal configuration markers |
| 2 | SENSITIVE | Linearizable Only (ReadIndex) | Group memberships, active session tokens, API keys |
| 3 | SECRET | Linearizable Only (ReadIndex) | Credential plaintext, TOTP seeds |
Audit data is out of scope for this Raft storage engine. Audit logging is handled by a separate external pipeline (e.g., SIEM, centralized logging) and is not subject to the tiering or read consistency model described here.
Group membership is elevated to Tier 2 (linearizable only) because it is a direct input to authorization decisions. A stale read of group membership can cause a recently removed member to still be considered part of a privileged group, producing an incorrect access grant. Callers must ensure that any Tier 1 data used in live authorization decisions is read via a linearizable path.
Configuration: Operators enable local reads via
local_reads_mode = "local_for_public". Tier 2 and 3 data always require the
ReadIndex protocol, ensuring revoked credentials are never exposed via a stale
follower.
3.1 Audit Log Architecture
Audit events referenced in this ADR (DEK rotation, skipped re-encryption keys, quarantine recovery, emergency rotation, and operator actions) are forwarded to an external SIEM or centralized logging pipeline. The audit log is not stored within the Raft storage engine, satisfying GDPR Article 30 requirements independently of the identity data store.
Integrity: Each audit record is signed with a per-node HMAC-SHA256 key
derived from the KEK via
HKDF-Expand(KEK, info="keystone-audit-hmac-v1" ++ node_id_u64_be, L=32). The
node_id is included in the derivation so each node holds a distinct signing
key; a compromised node cannot forge audit records attributed to other nodes.
The signing key is rotated on every DEK rotation, binding the HMAC key lifetime
to the DEK epoch. The signature is computed over the canonical JSON
representation of the audit record (including timestamp, event type, actor, and
node_id), and transmitted alongside the record. An epoch tag
(dek_version_u32) and the originating node_id are included in each audit
record to identify which HMAC key signed it. Because the KEK never enters
process memory in production (§2.1), this derivation is performed inside the
HSM or Cloud KMS using a context-keyed derivation operation.
Transport: Audit records are forwarded over an authenticated channel (TCP/TLS) to the SIEM. The keystone node cannot unilaterally modify records already received by the SIEM, which enforces append-only semantics downstream.
HMAC Key Lifecycle: The epoch-tagged HMAC signing key is transmitted to the
SIEM over the same authenticated channel as the audit records, bound to the
dek_version_u32 epoch. This ensures the HMAC key is protected by the same
transport mechanisms as the audit records themselves. The SIEM retains each
epoch’s key for the duration of the audit retention period, enabling re-
verification of historic records across epoch boundaries. The keystone node does
not need to retain epoch keys beyond the current DEK epoch — responsibility for
key lifecycle lies with the SIEM.
Availability: If the SIEM endpoint is unreachable, audit records are
buffered locally (encrypted at rest with the Log DEK) and replayed on
connectivity restoration. Buffer capacity is bounded to prevent disk exhaustion;
if the buffer reaches 90% capacity, the node emits a CRITICAL alert. Audit
buffer exhaustion is an operational concern for the audit pipeline, and does NOT
affect the Raft storage engine’s availability — writes to the identity data
store proceed normally regardless of SIEM connectivity or audit buffer state.
4. Intra-Cluster Transport (mTLS)
Operators select the transport mode via [storage] transport_mode.
Protocol Requirements: All mTLS connections (both SPIFFE and TLS fallback)
MUST use TLS 1.3 or later. TLS 1.2 and earlier are prohibited. Permitted cipher
suites are restricted to AEAD-only: TLS_AES_256_GCM_SHA384 and
TLS_CHACHA20_POLY1305_SHA256. The TLS stack must enforce these settings at
configuration time and refuse to start if unsupported ciphers are negotiated.
4.1 SPIFFE Mode (Default)
Managed automatically by SPIRE.
- Identity: Short-lived X.509 SVIDs rotated automatically. SVID TTL MUST NOT exceed 1 hour. Node processes enforce this by refusing to use an SVID with a remaining validity of less than 5 minutes (force-renewal window).
- SPIFFE ID Pattern: All Keystone storage node SVIDs MUST match
spiffe://<trust-domain>/keystone/storage/<role>. The gRPC interceptor validates this pattern before any RPC is dispatched; connections from SVIDs that do not match are rejected withPERMISSION_DENIED. - SPIRE Unavailability (Fail-Closed): If the SPIRE agent cannot renew an
expiring SVID before it enters the force-renewal window, the node emits a
CRITICALlog entry. If the SVID expires before renewal succeeds, the node refuses to accept new inbound connections and drains in-flight Raft proposals before halting. It does NOT fall back to an expired SVID. - Trust Bundle Refresh: The SPIRE agent manages trust bundle rotation automatically. No manual intervention is required or permitted for trust bundle updates.
4.2 TLS Mode (Fallback)
Operator-managed PKI for environments without SPIRE.
- PKI Rules: Must use a dedicated Keystone Intermediate CA. Leaf certificates MUST NOT exceed 30 days validity (enforced at startup).
- Certificate Expiry Watchdog: A runtime watchdog checks remaining certificate validity at a regular interval (every hour). It logs warnings at 7 days remaining, errors at 2 days remaining, and triggers a configurable action (warn-only or shutdown) at expiry. This prevents the enforcement gap where a node starts with a valid certificate but continues operating after expiry.
- Certificate Revocation: CRL and OCSP are not implemented for the TLS fallback path. The compensating controls are: (1) the 30-day maximum leaf certificate validity limits exposure from a stolen or miss-issued certificate; (2) prompt certificate replacement is required on compromise, enforced by the operator’s PKI management procedures. This is acceptable for an internal cluster where the operator manages the Intermediate CA and has direct control over certificate issuance and replacement.
Planned Improvement: CRL and OCSP support for the TLS fallback path is planned as a future enhancement to the custom mTLS infrastructure. When implemented, it will replace the reliance on short certificate validity as the sole revocation control and provide real-time certificate status verification at connection time.
4.3 NodeId Assignment
Decision: NodeId is a manually configured u64 that must be unique within
the cluster.
Rationale: NodeId is an opaque cluster-local identifier, not a secret or authentication material. Cryptographic derivation (e.g., BLAKE3) adds complexity without security benefit: mTLS already provides mutual authentication, and the collision check enforces uniqueness. A deterministic hash of cluster_id and URI provides no additional assurance over manual assignment — the operator controls the certificate and the configuration, and under zero-trust both are within the adversary boundary.
Uniqueness Enforcement: At startup, the node queries the Raft membership
config and compares its (node_id, rpc_addr) against all existing members. If
any existing member shares the same node_id but has a different rpc_addr, a
collision is detected. The node emits a fatal log entry and refuses to start.
The leader enforces the same check when processing add_learner requests. This
catches both operator misconfiguration and deliberate duplication.
Fail-closed: If the node cannot contact any cluster member to retrieve the
membership config at startup (e.g., network partition, no quorum), it MUST
refuse to start rather than skip the uniqueness check. An inability to verify
uniqueness is treated the same as a detected collision. This prevents a
misconfigured node from joining an isolated network segment undetected.
5. Data Flow Architectures
5.1 Write Path
The payload is subject to double-encryption to separate log concerns from state concerns. This adds approximately 2× symmetric crypto overhead per write (Log DEK decrypt + State DEK encrypt). With AES-NI, measured at ~0.2ms per operation at typical IAM payload sizes.
Client API → Serialize (MsgPack)
│
▼
Encrypt with Log DEK (AD = term ++ index)
│
▼
Propose to OpenRaft Leader (Replicated over mTLS)
│
▼
Apply on Node:
1. Decrypt log entry (Log DEK)
1.5. Fetch current version for PK from Fjall (default 0 if new); increment
2. Re-encrypt for state (State DEK, nonce = HKDF-Expand(SD, info=PK ||
version, L=12), AD = tier ++ domain ++ pk)
3. Write ciphertext + version suffix to Fjall DB
The state machine is idempotent under Raft replay: a crash between the version fetch (§5.1 step 1.5) and the write (§5.1 step 3) results in no persistent state change, and the replay reads the same on-disk version, deriving the same nonce and producing the same ciphertext. This invariant holds because Fjall’s batch commit is atomic and Raft entries are deterministic.
6. DEK Rotation Lifecycle
To prevent AES-256-GCM nonce exhaustion, Data Encryption Keys must be
periodically rotated. Rotation is triggered either by time (configurable via
[storage] dek_rotation_days, default 90 days) or by volume (when AES-GCM
encryptions reach 2^31 under any sub-key).
DEK Version Tracking: Each DEK epoch is assigned a monotonically increasing
dek_version_u32. The version is stored alongside the wrapped DEK in Fjall
metadata (e.g., _meta:dek:current:version). The version counter used in
state machine nonces (see §2.2) is also tracked per DEK epoch.
Live Background Rotation Flow:
- Generate a fresh DEK in memory with
dek_version = current + 1. - Wrap under the KEK and write to
_meta:dek:pendingvia a Raft proposal, recording the committed Raft index asrotation_indexin the same proposal. This guarantees the pending DEK and its version exist before any write can reference them. rotation_indexserves as an unambiguous boundary: log entries at index< rotation_indexuse the retired DEK; entries at index≥ rotation_indexuse the pending DEK.- All new Raft writes at or after
rotation_indexuse the pending DEK. - A background task re-encrypts all existing Fjall records under the new DEK
atomically in key-sorted order. The re-encryption uses optimistic concurrency
control (CAS on version): it reads the on-disk version, encrypts under the
new DEK with
version + 1, and writes only if the version on disk is unchanged. If a concurrent Raft write incremented the version, the key is either already encrypted under the new DEK (the Raft write used the pending DEK) or is retried. After 3 failed CAS attempts on the same key, the background task skips it — the key is assumed to have been updated under the new DEK by a Raft write. This CAS-on-version mechanism ensures no re-encryption can clobber a concurrent Raft mutation during rotation. Skipped keys are flagged in the post-rotation verification report (see step 8), are automatically retried on the next scheduled rotation cycle, and are emitted to an audit log entry. If a skipped key remains unverified for more than 24 hours, a CRITICAL alert is emitted and operator intervention is required. - During rotation, both DEKs are active. Reads use the per-record
dek_versionto select the correct key deterministically — they never fall back via tag verification failure. If thedek_versionis missing or ambiguous, the record is treated as corrupt and quarantined rather than probing both keys. Recovery path: Records with missing or ambiguousdek_versioncan be recovered from the Raft log by replaying the entry with the knownrotation_indexboundary to determine the correct DEK epoch. If the Raft log entry is unavailable (e.g., truncated by snapshots), the record is restored from backup. The blast radius is limited to the partition containing the affected key. Operators runkeystone-manage storage recover --recordto trigger recovery, which emits an audit log entry. For state entries, the per-recordversionfield is incremented during re-encryption to produce a fresh nonce. - Upon completion, the old DEK is atomically promoted to
_meta:dek:retired:<timestamp>via a second Raft proposal, and an audit log event is recorded. - Post-rotation verification: A verification pass reads each record’s stored
dek_versionand confirms it is consistent with the new DEK epoch. Any record that cannot be verified is flagged in an operator-facing report rather than silently accepted, ensuring no records remain orphaned under the retired DEK after rotation is declared complete. Unverified records trigger an automated retry on the next rotation cycle. If verification fails for more than 24 hours consecutively, a CRITICAL alert is emitted. The DEK retirement is blocked until the operator resolves the flagged records or explicitly approves proceeding with a signed audit entry acknowledging the unverified records.
Partial Rotation Recovery: If a node restarts mid-rotation, it detects
_meta:dek:pending. The leader resumes the idempotent re-encryption from the
last committed progress marker stored in _meta:dek:rotation_progress before
normal operations complete. The rotation_index boundary remains authoritative
for determining which DEK each log entry was encrypted under.
6.2 Emergency Rotation and DEK Compromise
When a DEK is suspected or confirmed compromised, the operator triggers an emergency rotation that follows the same flow as normal rotation but with additional containment steps:
- Trigger: Emergency rotation is initiated via
keystone-manage storage rotate-dek --emergency, which connects to the cluster over gRPC with mTLS enforcement (see §1). Access requires RBAC authorization (storage-operatorrole) and dual-control confirmation viaConfirmRotateDekfrom a second operator within 5 minutes. Confirmation timeout: If the 5-minute window expires without confirmation, the pending emergency rotation is automatically aborted: the node commits a Raft proposal removing_meta:dek:pending. The abort is recorded in the audit log with the initiating operator identity, the expiry timestamp, and the fact that no confirmation was received. The partial-rotation recovery path (end of §6) checks for arotation_idtimestamp and ignores_meta:dek:pendingentries older than 5 minutes that were never confirmed, preventing an aborted emergency rotation from being resumed on restart as a normal rotation. - Immediate containment: A fresh DEK is generated and committed via Raft
following the standard flow (§6 step 1-2). The compromised DEK is marked
revoked(notretired) in_meta:dek:revoked:<timestamp>, preventing its reuse for any decryption operation. - Impact assessment: The operator queries the per-record
dek_versionto identify all records encrypted under the compromised DEK epoch. This determines the scope of potentially exposed data. - Re-encryption: The background task re-encrypts all affected records under the new DEK following the standard CAS-on-version flow (§6 step 5).
- Discard: The revoked DEK is discarded immediately — it is NOT stored in the retired DEK chain and is NOT available to any KMS role. If offline decryption of backups from the compromised epoch is required, the operator must use the backup manifest (§7) and the BDEK, not the compromised DEK.
- Incident logging: The emergency rotation, affected record count, and
operator identity are recorded in the audit log with a distinct event type
(
DEK_EMERGENCY_ROTATION).
Normal rotation cadence resumes after the emergency rotation completes; the
dek_rotation_days timer is reset to account for the forced rotation age.
7. Backup and Restore
Backups in Keystone-NG are fundamentally Fjall snapshots. Because all values in Fjall are encrypted via the State DEK, disk snapshots contain exclusively AES-256-GCM ciphertext.
Backup Encryption Envelope
Each backup is wrapped in a backup-specific envelope to bind it to a point in time and a specific DEK epoch, preventing rollback or replay attacks:
AES-256-GCM(
plaintext = snapshot_bytes,
key = Backup DEK (BDEK),
AD = b"keystone-backup-v1" ++ snapshot_utc_epoch_be_u64 ++ dek_version_u32
)
The dek_version_u32 identifies which DEK epoch the snapshot was taken under
(at time of snapshot, current DEK version). The backup bundle includes a DEK
manifest listing all retired DEKs that may be required for offline decryption,
handling cases where the snapshot spans a rotation boundary. The DEK manifest is
a separate structure included in the backup bundle alongside the encrypted
snapshot. It is encrypted with the BDEK using AES-256-GCM with AD bound to
b"keystone-backup-manifest-v1" ++ snapshot_utc_epoch_be_u64 ++ dek_version_u32.
Including dek_version_u32 in the manifest AD prevents swapping the manifest
between two backups taken within the same UTC second under different DEK epochs.
The manifest itself is not covered by the outer backup envelope but is
independently encrypted and integrity-protected. BDEK incorporates
dek_version_u32 in its derivation, binding each backup to its DEK epoch, and
the AD provides independent replay protection.
Note: The timestamp is explicitly an 8-byte big-endian UTC epoch seconds value. String timestamps are prohibited as Associated Data due to ambiguity and fragility.
Restore Process
Restoring a snapshot to a new cluster strictly requires:
- Access to the Backup DEK (
backup_dekrole) in the KMS. - Valid node identity credentials (SPIFFE SVIDs or Intermediate CA certs) for the new nodes before they join the cluster.
- Unwrapping the backup envelope, loading it into Fjall, and immediately re-wrapping the restored DEK under the new cluster’s runtime KEK.
Retired DEKs must be retained in the KMS for the organization’s audit retention period (minimum 365 days) to allow offline decryption of archived backups. When a GDPR data erasure request arrives, the ability to decrypt that subject’s data from archived backups depends on the retained DEKs. The operator must re-encrypt the affected archived backups under a fresh DEK epoch, or shard the key material to reduce the DEK retention window. For GDPR Article 17 compliance, the worst- case re-encryption time for a full backup archive (measured at the organization’s maximum anticipated volume) must be documented and evaluated against the 30-day erasure timeframe. If re-encryption exceeds 30 days, the organization must deploy per-subject wrapping keys: each subject’s data envelope is encrypted under a subject-specific envelope key, which is in turn encrypted under the BDEK. Erasure of a subject’s data then requires only destroying the subject’s envelope key, not re-encrypting the entire archive. If re-encryption remains not feasible, the operator must document the inability to achieve full erasure and the residual risk, per GDPR Article 17.
Retired DEK Access Control: Retired DEKs are accessed through a distinct KMS
role (backup_dek_offline) that is separate from the runtime backup_dek role.
This limits the blast radius of a KMS breach or insider threat: compromising the
runtime role does not grant access to retired keys and vice versa. Access to any
retired DEK requires dual-control or break-glass approval. Operators should
evaluate whether the 365-day retention mandate can be satisfied with a separate
escrow mechanism instead of keeping keys live in the operational KMS.
8. gRPC Protocol & Code Definitions
Protocol Buffers:
message RaftEntry {
uint64 term = 1;
uint64 index = 2;
optional Membership membership = 3;
// Encrypted app payload: [12b counter-nonce][ciphertext][16b GCM tag]
// AD = big-endian(term) ++ big-endian(index)
optional bytes app_data = 4;
}
message RaftResponse {
// SENSITIVE: Ephemeral plaintext response, transmitted only over mTLS.
// Zeroized by the sender immediately after gRPC send.
// Rust type: ZeroizingResponse — must be zeroized after use.
bytes payload = 1;
}
message ClearQuarantineRequest {}
message RotateDekRequest {
bool emergency = 1;
}
message ConfirmRotateDekRequest {
// The rotation_id of the pending emergency rotation that requires confirmation.
string rotation_id = 1;
}
Implementation Note (Residual Risk): gRPC implementations (including tonic/prost) may internally buffer, clone, or copy message bytes before or during transmission (e.g., into the HTTP/2 frame buffer). Zeroizing the application-level struct does not guarantee the copies within the gRPC stack are also zeroized. A stream-based response that processes the minimum plaintext footprint should be preferred where feasible.
Compensating Controls: These are validated at startup pre-flight (§9):
- Core dumps must be disabled (
RLIMIT_CORE = 0). The startup pre-flight verifies this and refuses to start if the limit is nonzero. - Set
PR_SET_DUMPABLE = 0(Linux) to prevent ptrace and/proc/<pid>/memaccess from co-located processes. The startup pre-flight verifies this and refuses to start if it fails. - If Tier 3 data (credential plaintext, TOTP seeds) ever flows through a
RaftResponse, streaming must be evaluated to minimize the peak plaintext footprint within gRPC stack buffers.
OpenRaft Types (Rust):
#![allow(unused)]
fn main() {
openraft::declare_raft_types!(
pub KeystoneConfig:
D = EncryptedBlob, // [u8] wrapper enforcing 16-byte tag check
R = ZeroizingResponse,
NodeId = u64, // Manually configured; collision detection via
// (node_id, rpc_addr) comparison against existing
// membership at startup and on learner-add
Node = SpiffeNode,
);
}
9. Zeroize & Memory Protection
Standard software zeroization (ZeroizeOnDrop) is insufficient on its own
because operating system page swapping can silently write key material to disk
(swap pages), bypassing in-process zeroization.
Memory Locking (mlock)
To guarantee key material is physically pinned to RAM and never written to swap
storage, all keys (Dek, LogDek, StateDek) and their immediate working
buffers must be allocated in memory-locked pages using the OS-level mlock(2)
(Linux) or VirtualLock (Windows) APIs.
This is enforced via a memory-locking wrapper (e.g., memsec or secrecy with
OS-level locking):
#![allow(unused)]
fn main() {
// Internally wrapped in a secrets-manager to guarantee locked heap allocations:
let buf: memsec::Malloc<[u8; 32]> = memsec::malloc().expect("mlock allocation");
}
Resource Limits: The Keystone process must request an RLIMIT_MEMLOCK
sufficient for the key material pool at startup. If mlock allocation fails due
to insufficient OS limits, the process logs a CRITICAL warning and refuses to
start in production mode.
Startup Pre-Flight: At process startup (before KEK/DEK are loaded), the node
verifies RLIMIT_CORE == 0 and PR_SET_DUMPABLE == 0. If either check fails,
the node emits a CRITICAL log entry and refuses to start in production mode
(same pattern used for RLIMIT_MEMLOCK). This ensures the compensating controls
against gRPC stack plaintext exposure are actually active.
Enforcement Pipeline:
- A
#[deny(clippy::mem_forget)]lint prevents accidental bypass of drop-based zeroization project-wide. cargo denyrules reject any storage dependency that transitively stores key material without implementingZeroizeOnDrop.- Key material types MUST NOT derive
DebugorDisplayto prevent accidental formatting in logs, panics, or debugger output. - Core dump configuration must exclude memory-locked pages from capture.
- Heap profiling tools are prohibited in production environments containing
active key material. Enforcement: release builds strip profiling symbols
(
strip --strip-debug). A seccomp profile deniesptracesyscalls, and thePR_SET_DUMPABLE = 0setting from the startup pre-flight prevents attach. Enforcement: (1) release builds strip profiling symbols (strip --strip-debug); (2) a seccomp profile deniesptracesyscalls (PR_SET_NO_NEW_PRIVS = 1+SECCOMP_MODE_FILTER); (3) AppArmor deny rules block/proc/<pid>/memaccess.
10. Security Invariants
Any code change violating the following is rejected at review:
- No plaintext on disk: Every byte is encrypted with AES-256-GCM before the write call returns.
- No DEK in plaintext outside mlock’d RAM: The DEK lives only in
KMS-wrapped form on disk, and inside
mlock’dZeroizingbuffers in memory. - Strict mTLS: Auto-join is permanently disabled. SVID or SAN URI patterns are mandatory.
- No stale reads for sensitive data: Tier 2 and Tier 3 data must exclusively utilize the ReadIndex protocol.
- No unauthenticated operations: GCM tag mismatch is fatal for the affected
key; 3 distinct key failures within 60 seconds in the same Fjall partition
trigger read-only quarantine (in-flight Raft proposals are drained, not
interrupted). Quarantine state is durable: it is committed via a Raft
proposal to
_meta:quarantine:<partition>:<node_id>(partition first, so an operator’sClearQuarantinecan prefix-scan and remove every reporting node’s entry for a partition in one pass) so it persists across node restarts and is visible to all cluster members. GCM failures reflect node-local storage corruption, not a cluster-wide data problem, so blocking is node-scoped: only the reporting node re-enters quarantine (and refuses local reads) for that partition on restart; other nodes persist the record for audit visibility only and continue serving reads. Recovery requires the operator to runkeystone-manage storage clear-quarantineon the affected node, which requiresstorage-operatorRBAC authorization, is committed via Raft (so the flag is cleared cluster-wide), and is audit-logged with caller identity and timestamp. A single GCM tag failure emits aWARNlog and increments a metric; two failures within 60 seconds emit anERRORlog and trigger an alert. Per-source-IP and per-identity rate limits apply (see §1):RotateDeklimited to 2/hour,ClearQuarantineto 10/hour. Emergency rotation (RotateDekRequest{emergency: true}) requires dual-control approval from a secondstorage-operatorviaConfirmRotateDekwithin 5 minutes. - No environment-variable KEK in production: The
--dev-modeflag andKEYSTONE_ALLOW_ENV_KEK=1are explicitly required to start with an environment-provided KEK. - NodeId collision detection: A node that detects a
(node_id, rpc_addr)collision with an existing cluster member at startup must emit a fatal log entry and refuse to start. The leader additionally enforces this check when processingadd_learnerrequests. - DEK bootstrap ordering: DEK generation MUST target an already-mlock’d allocation; it MUST NOT be generated into an unlocked buffer and subsequently copied.
- Per-record write rate guard: When the per-record
versioncounter exceeds a configurable threshold (default2^30updates per DEK epoch), the node emits a CRITICAL alert, blocks further updates to that key, and requires operator intervention. This prevents adversarial write flooding from exhausting theu32nonce space within a single DEK epoch. - Nonce source exclusivity: Nonce sources for all AES-256-GCM contexts are defined in §2.2. Random nonces are prohibited. Any new encrypted context must define a deterministic, collision-resistant nonce strategy reviewed by the security team.
- Deployment validation: A pre-flight CI/CD check or Kubernetes admission
webhook must reject any production service definition containing
--dev-modeorKEYSTONE_ALLOW_ENV_KEK. This prevents silent flag smuggling via systemd, container images, or manifests. - Startup pre-flight: At process startup (before KEK/DEK are loaded), the
node must verify
RLIMIT_CORE == 0andPR_SET_DUMPABLE == 0. If either check fails, the node must refuse to start in production mode. - Non-extractable KEK key material (§2.5): PKCS#11 KEK key objects MUST
be created with
CKA_EXTRACTABLE = false/CKA_SENSITIVE = true; TPM KEK key objects MUST be created withfixedTPM | fixedParentand no duplication attribute. AKekProviderimplementation that can export raw key bytes from the token/TPM is non-compliant regardless of how it is otherwise used. - No PKCS#11/TPM credential via environment variable: The PKCS#11 PIN
and TPM auth value are supplied only via
pkcs11_pin_file/tpm_auth_file(a file path in config). Neither may be supplied via an environment variable or inline config value — that channel is reserved exclusively for the dev-modeKEYSTONE_DEV_KEKpath (invariant 6). - Authenticate-before-decrypt for Encrypt-then-MAC contexts: Any
KekProviderthat does not use an AEAD primitive natively (the TPM provider, §2.5.2) MUST verify the MAC over the full ciphertext before performing any decryption operation, and MUST use a constant-time comparison. Decrypting unauthenticated ciphertext, even transiently, is prohibited.
17. SecurityContext Design and Security Principles
Date: 2025-11-15 Updated: 2026-07-03
Status
Accepted; all production APIs documented with parameter descriptions, return values, and validation architecture. Clippy clean.
Validation Architecture
All auth types use a single validate() method returning AuthenticationError
for both structural checks (field presence, length) and business rules (user
enabled, domain enabled, trust chain). The validator crate is not used in
auth.rs — all validation is manual, ensuring every check produces a dedicated,
typed error (UserDisabled(id), DomainDisabled(id), AuthzPrincipalMismatch,
etc.) rather than an opaque ValidationErrors bag.
Validation flow:
SecurityContext::validate()validates the principal (identity, domain, user data), then checks authentication-context-specific constraints (Trust/AppCred user_id match).PrincipalInfo::validate()checksdomain_idlength, then delegates toIdentityInfo::validate().IdentityInfo::validate()dispatches:UserIdentityInfo::validate()— checks user_id length, user presence/match, user enabled, domain enabled.PrincipalIdentityInfo::validate()— checks id and issuer non-empty; IdentityInfo then checks domain enabled.
ScopeInfo::validate()— checks domain/project/trust project enabled status.SecurityContext::fully_resolved()— callsvalidate()+ checks that scoped authorization carries non-empty roles.
The validator crate remains in use by other core-types modules (identity,
assignment, token), but is not used by auth.rs.
Context
Keystone endpoints require a verified, untamperable security context to make authorization decisions. Previous Python implementations passed mutable context object that flattened the authentication and authorization information into a set of optional fields through the request lifecycle, creating a class of vulnerabilities where downstream code does not have unambiguous information.
The Rust Keystone must enforce:
- A security context cannot be used for policy enforcement before it is fully resolved
- Unscoped authentication is valid and must not be mistaken for unresolved context
- Authorization flows from authenticated parent tokens are propagated into the security context
- Test paths cannot bypass validation gates in production
Key types involved:
SecurityContext(core-types) — holds principal, authentication context, authentication methods, authorization, audit IDs, token, token restriction. All fields arepub(crate)with explicit getter/setter accessors to prevent external mutation.ValidatedSecurityContext(core) — wrapper that gates the raw context behind a validation barrier. Internal field is private; onlyDerefis implemented (noDerefMut), so the wrapped context is externally immutable.AuthenticationResult— produced by a single auth method, may carry authorization from parent tokenAuthzInfo— scope + roles extracted at authentication time.scopeispub,rolesispub(crate)with setters (set_roles,roles,try_set_roles)ScopeInfo— enum capturing the authorization scope:Domain,Project,System,TrustProject,Unscoped. TheTrustProjectvariant boxes its payload (TrustProjectInfo) to avoid inflating the enum size for the smaller variants (Domain, System, Unscoped).Credentials(policy) — subset of context projected for OPA policy evaluation. Uses read-only getters:principal(),authorization(),effective_roles()SecurityContextTestingBuilder(core-types,#[cfg]-guarded) — builder for constructingSecurityContextin test fixtures. Replaces positionalfor_testing()with named settersIntoAuthContext(core) — conversion trait that forces the caller to provide context information when converting a provider error intoAuthenticationError::Provider
Decision
Two-Phase Validation: Construction Then Validation
SecurityContext is the raw, possibly incomplete structure.
ValidatedSecurityContext wraps it and represents the validated, resolvable
security context. The two-phase design ensures that no endpoint handler can
observe a partially-authenticated context:
SecurityContext::try_from(AuthenticationResult)constructs the raw context from authentication resultsValidatedSecurityContext::new_for_scope(ctx, scope, state)is the only production path to obtain a validated context. The scope is passed as an explicit parameter (not derived from the context) so callers have unambiguous control over the target scope. It:- If
ctx.authorization()already set and differs fromscope, callsctx.validate_scope_boundaries(&scope)to guard scope override - If
ctx.authorization()isNone, callsctx.set_authorization_scope(scope) - Populates
user_domainforIdentityInfo::Userby querying the resource provider — required beforexvalidate() - Calls
ctx.validate()to check principal integrity (user enabled, appcred/ trust user_id match) - Checks token expiration: if
ctx.expires_at() < Utc::now(), returnsAuthTokenExpired - Runs auth-context-specific validation:
ApplicationCredential: verifies user_id match and AC expirationTrust: validates trust delegation chain, trustor enabled, trustor domain enabled, trustor domain compatibility
- Calls
calculate_effective_roles(state, ctx, scope)(private, read-only) to resolve role assignments from the database - Calls
ctx.set_effective_roles(roles)via setter - Returns
ValidatedSecurityContext(ctx)on success
- If
Production code can only obtain ValidatedSecurityContext through
new_for_scope(). The ValidatedSecurityContext struct wraps the context in a
private inner field. The Deref implementation provides read-only access
(&SecurityContext); there is no DerefMut, into_inner(), or inner_mut().
All 8 fields on SecurityContext are pub(crate), so external crates cannot
obtain &mut access to any field. Setter methods (set_token,
set_authorization, set_effective_roles, set_token_restriction,
expires_at_mut) are pub but require &mut self, which is unreachable once
the context is wrapped in ValidatedSecurityContext.
#[cfg]-Guarded Test Constructors and Builder
During testing, two mechanisms are available under
#[cfg(any(test, feature = "mock"))]:
-
ValidatedSecurityContext::test_new(ctx)— constructs a validated context without going through the validation pipeline. This allows unit tests and integration test mocks to inject a pre-built context. -
SecurityContextTestingBuilder(accessed viaSecurityContext::test_build()) — a named-setter builder that replaces the positionalfor_testing()approach. Required fields areauthentication_contextandprincipal; optional fields aretoken,authorization,expires_at, andtoken_restriction. The builder derivesauth_methodsfromauthentication_context.methods(). Both compile-time tests (#[cfg(test)]) and the optionalmockfeature gate these constructors, so production builds can never call them.
API Extractor Enforcement
The Auth extractor (core/src/api/auth.rs) is the Axum extractor that
validates and resolves the context for every authenticated request. Two paths
exist:
-
Extension injection (tests only): When
ValidatedSecurityContextis present in request extensions, the extractor callsvsc.fully_resolved()?to verify the context is complete, then returnsAuth(vsc). This path is#[cfg(any(test, feature = "mock"))]-guarded. -
Token header flow (production): The extractor reads
X-Auth-Token, callsstate.provider.get_token_provider().authorize_by_token(), which builds the context, resolves roles viaValidatedSecurityContext::new_for_scope(), and returns the validated context. The extractor then callsvsc.fully_resolved()?, and returnsAuth(vsc).
Both paths call fully_resolved() before returning, ensuring the validation
gate cannot be bypassed.
fully_resolved() Semantics
The SecurityContext::fully_resolved() gate at core-types/src/auth.rs:581
enforces:
authorizationisNone— returnsErr(SecurityContextNotResolved): authorization has not been bound from the parent token or request scopeauthorizationisSome(AuthzInfo { scope: Unscoped, roles: None })— passes: unscoped is valid with no rolesauthorizationisSome(AuthzInfo { scope: Scoped, roles: None })— returnsErr(SecurityContextNotResolved): scoped authorization must have rolesauthorizationisSome(AuthzInfo { scope: _, roles: Some(_) })— passes: roles are populated
The critical distinction is that unscoped authorization with roles: None is
valid, while scoped authorization with roles: None indicates an incomplete
resolution.
Authorization Propagation from Authentication
Authorization lives at the AuthenticationResult level, not nested inside
TokenContext. This design allows any authentication method (token, SPIFFE,
K8s, OIDC, etc.) to produce authorization context at authentication time rather
than deferring all role computation to a later phase.
For token authentication, FernetToken::from_security_context(ctx, expires_at)
constructs the appropriate token variant from the validated context, using the
scope and role data from ctx.authorization(). The token provider’s
build_authz_info_from_fernet_token() method maps each FernetToken variant to
AuthzInfo { scope, roles } by fetching scope objects (project, domain,
project_domain) from the database.
The TryFrom<AuthenticationResult> and TryFrom<Vec<AuthenticationResult>>
both propagate the authorization into the resulting context:
- Single authentication result: the result’s authorization is set on the context
- MFA: the first result’s authorization is preferred; subsequent results fill in if the first is missing
- All MFA results must share the same principal or
AuthnPrincipalMismatchis returned
Effective Role Calculation
calculate_effective_roles() in core/src/auth.rs is a private, read-only
function that queries the assignment provider for effective role assignments
based on the scope type. It takes &SecurityContext (immutable reference) and
returns Vec<RoleRef>. The caller then sets roles via
SecurityContext::set_effective_roles(), which uses the setter path through
AuthzInfo::set_roles() (no &mut borrows escape the constructor).
Based on the scope type:
- Project scope: queries effective user+group role assignments on the project; for application credentials, takes the intersection of frozen AC roles with currently assigned user roles
- Domain scope: queries effective user+group role assignments on the domain
- System scope: queries effective role assignments on the system
- Trust scope: resolves trustor roles on the trust’s project scope; if the trust declares explicit roles, verifies the trustor still has those roles and applies implied role expansion
- Unscoped: no role query is performed; roles remain
None
A trust (or restricted application credential) can also arrive on a plain
Project scope rather than its native TrustProject/app-cred scope — most
notably an EC2 credential minted under a trust and redeemed at /v3/ec2tokens,
which reconstructs the delegated AuthenticationContext but presents a bare
project scope. In that case role resolution must still be bounded by the
delegation, not by the trustee’s own assignments on the project:
calculate_effective_roles() detects AuthenticationContext::Trust under a
ScopeInfo::Project and routes it through resolve_trust_roles() (the same
path as TrustProject), and the application-credential path takes the
intersection with the AC’s frozen roles. Without this bound a restricted
delegation redeemed via EC2 could escape its role restriction (OSSA-2026-005 /
CVE-2026-33551).
After assignment queries, if roles is empty and the scope is not unscoped,
ActorHasNoRolesOnTarget is returned. Token restrictions are applied last to
potentially narrow the role set.
Scope Boundary Validation
SecurityContext::validate_scope_boundaries(scope) at core-types/src/auth.rs
validates whether the authentication context permits a requested scope type. It
does NOT verify role ownership — only whether the auth method and any token
restrictions allow the target scope. Returns ScopeNotAllowed on violation.
Key constraints:
- Application credentials cannot be scoped beyond their bound project
- Token restrictions block domain, system, trust, and unscoped scopes; project scope must match the restriction’s project ID
- Trust authentication cannot be re-scoped to a different trust
- K8s authentication is limited by its token restriction
This method is the mechanism that keeps a delegated token’s scope pinned to its delegation project, and it is what the policy-layer scope-drift tripwire (see Delegation Facts Sourced from the Authentication Chain) relies on. Note the pinning is enforced on scope change; the initial scope set at issuance is not re-checked here, so the tripwire in the policy layer remains the backstop.
Policy Credentials Projection
Credentials is the subset of ValidatedSecurityContext projected for OPA
policy evaluation. The TryFrom<&ValidatedSecurityContext> implementation at
core/src/policy.rs extracts user_id via sc.principal().get_user_id(),
role_ids via sc.authorization().effective_roles(), and scope-specific
identifiers (project_id, domain_id, system). The implementation uses only
read-only getters — no &mut borrows are involved.
Unscoped tokens produce role_ids: [] with no scope ID set. The OPA policy must
handle this unscoped case correctly. For scoped contexts, the implementation
returns SecurityContextNotResolved if authorization.roles is unexpectedly
None — this is a defense-in-depth check that catches the case where
fully_resolved() was not properly gated.
Delegation Facts Sourced from the Authentication Chain
Delegation-related facts on Credentials — auth_type, is_delegated,
unrestricted, trust, and delegated_project_id — are read from
sc.authentication_context() (the immutable authentication chain), never
inferred from the authorization scope. The token scope (project_id) is
attacker-influenceable across a rescope, while the delegation object’s own
project binding is fixed at delegation-creation time. Sourcing these facts from
the chain is what lets a policy pin a delegated caller to the delegation’s own
project rather than to whatever project the current token happens to be scoped
to (OSSA-2026-015).
delegated_project_id: Option<String> carries this immutable binding:
AuthenticationContext::ApplicationCredential→ the application credential’sproject_idAuthenticationContext::Trust→ the trust’sproject_id- all non-delegated contexts →
None
Policies anchor the delegation boundary on delegated_project_id and
additionally assert credentials.project_id == credentials.delegated_project_id
as a scope-drift tripwire: it fails closed if a delegated token’s scope is
ever observed diverging from its delegation project.
validate_scope_boundaries() keeps the two equal today, so the tripwire is
defense-in-depth against a future regression in scope pinning, not the primary
control.
Consequences
Security Improvements
- No mutable context exposure:
ValidatedSecurityContext::inner()returns&SecurityContext. OnlyDerefis implemented (noDerefMut), so there is no path to obtain&mut SecurityContextfromValidatedSecurityContext.Cloneproduces an independent copy that does not share state. - Private fields on
SecurityContext: All 8 fields arepub(crate)incore-types. TheValidatedSecurityContexttype incoreis a different crate, so it also cannot mutate the fields directly — only through the getter/setter API. After wrapping, no&mutreference is reachable. - Explicit getter/setter API: All field access goes through getters
(returning
&TorOption<&T>) and setters (taking&mut self). No interior mutability (RefCell,Cell,UnsafeCell, atomics) is used anywhere in the auth context types. - Validation is mandatory:
fully_resolved()is called both in the production Auth extractor path and the test extension-injection path, ensuring no endpoint receives an unresolved context. - Scope boundary enforcement:
validate_scope_boundaries()prevents auth methods with narrower scope permissions from being broadened by request-scoped scope specifications. The scope override guard innew_for_scopecallsvalidate_scope_boundaries()when the requested scope differs from the context’s existing scope. - Test/production separation:
test_new()andSecurityContextTestingBuilderare compile-time excluded from production builds, preventing accidental bypass.
Performance Considerations
- Role computation at validation time: The
new_for_scope()path performs 1-8 database queries depending on scope type and number of effective assignments. This cost is paid once per API request. - Authorization propagation from token auth: The token provider builds
AuthzInfofromFernetTokenby fetching scope objects from the database (project, domain, project_domain). The role set is then re-queried bynew_for_scope()for effective assignments (which may differ from token-frozen roles due to interim role removal). - Revoked token expansion: The
authorize_by_tokenpath expands token role data from database before checking revocation. This is necessary since the token may be considered expired byproject_id,role_id,user_id, or any combination of those. It is therefore necessary to have a fully expanded token before checking for the revocation. - Trust validation overhead: The
new_for_scope()path for trust contexts performs additional queries: trust delegation chain validation, trustor user lookup, and trustor domain enabled check. These are necessary for security but add 2-3 queries per trust-scoped authentication.
Maintenance Surface
ValidatedSecurityContextadds one indirection layer to all handlers usingAuth. TheDerefimplementation minimizes friction, but direct field access is not possible. Instead, use the getter API:ctx.principal(),ctx.authorization(),ctx.token(),ctx.token_restriction(), etc.- Adding a new authentication method requires:
- Implementing the provider’s authentication logic
- Producing an
AuthenticationResult(withauthorizationif applicable) - Ensuring
AuthenticationContext::methods()returns the correct method names - Verifying
validate_scope_boundaries()handles the new context variant - Adding a match arm in
new_for_scope()for auth-context-specific validation (even if empty, to trigger a compile error for future context additions)
- Adding a new
ScopeInfovariant requires updatingvalidate_scope_boundaries(),fully_resolved(),calculate_effective_roles(),ScopeInfo::validate(),FernetToken::from_security_context(),build_authz_info_from_fernet_token(),Credentials::try_from, and all downstream match arms that consume scope information. - The
ScopeInfo::TrustProjectvariant boxes its payload asBox<TrustProjectInfo>to keep the enum size reasonable. Adding fields toTrustProjectInfoonly affects the trust variant, not the smaller variants (Domain,System,Unscoped).
18. Plugin Linking — Anchor Convention and build.rs
Date: 2026-05-28
Status
Accepted
Context
Keystone uses inventory for plugin
registration. Driver crates (SQL, Raft, etc.) call inventory::submit! to
register their SqlDriverRegistration items into a global table discovered at
runtime by inventory::iter.
The problem is that the Rust compiler and linker treat inventory::submit!
items as dead code when no strong symbol from the contributing crate is
referenced by the binary. The linker skips the .rlib archive members entirely,
causing inventory::iter to silently yield no results — drivers are present in
the dependency tree but invisible at runtime.
Decision
Crate Naming Convention
All driver crates follow the naming scheme:
openstack-keystone-<PROVIDER>-driver-<DRIVER_TYPE>
Where <PROVIDER> identifies the subsystem (e.g. identity, role,
federation) and <DRIVER_TYPE> identifies the backend technology (e.g. sql,
raft).
Examples:
| Crate | Purpose |
|---|---|
openstack-keystone-identity-driver-sql | SQL backend for identity |
openstack-keystone-k8s-auth-driver-sql | SQL backend for Kubernetes auth |
openstack-keystone-k8s-auth-driver-raft | Raft backed backend for Kubernetes auth |
openstack-keystone-spiffe-driver-raft | Raft backed backend for SPIFFE |
Infrastructure crates (api-types, config, core, core-types,
distributed-storage, token-fernet) do not use this naming convention —
they are not driver crates.
pub fn anchor() Convention
Every openstack-keystone-* crate exports a no-op function:
#![allow(unused)]
fn main() {
/// Linkage anchor — see ADR-0018. Referenced by `keystone` build.rs so the
/// linker extracts this crate's `.rlib` members, keeping `inventory::submit!`
/// sections visible at runtime.
#[allow(dead_code)]
pub fn anchor() {}
}
This is a compile-time convention enforced by the build script. Any crate in the
dependency tree that does not provide pub fn anchor() will cause a compile
error in the binary crate.
build.rs Auto-Discovery
crates/keystone/build.rs parses Cargo.toml at build time, discovers all
dependencies whose name starts with openstack-keystone-, and generates a
sidecar file (OUT_DIR/inventory_anchors.rs) containing a #[used] static that
references each crate’s anchor() function:
#![allow(unused)]
fn main() {
#[used]
pub static _ANCHORS: &[fn()] = &[
openstack_keystone_appcred_driver_sql::anchor,
openstack_keystone_assignment_driver_sql::anchor,
// ... (auto-discovered, sorted)
];
}
Only deps with driver in the name are collected (the build.rs filter),
ensuring only actual driver crates are anchored.
The generated file is included into lib.rs via include!(), creating a strong
reference chain:
keystone-manage (binary crate)
└─ openstack_keystone (keystone crate)
├─ #[used] static _ANCHORS (generated by build.rs)
│ ├─ appcred_driver_sql::anchor ── forces .rlib extraction
│ ├─ identity_driver_sql::anchor ── forces .rlib extraction
│ └─ ... (all *_driver_* crates)
└─ (transitive deps carry inventory::submit! items)
Binary Crate Linkage
Binary crates (e.g. cli-manage) reference the generated static from the
keystone crate to create the linkage chain:
#![allow(unused)]
fn main() {
#[used]
static _INVENTORY_LINK: &[fn()] = openstack_keystone::_ANCHORS;
}
This prevents the linker from stripping the keystone crate members, which in
turn prevents stripping of all driver crate members.
Consequences
Positive
- Zero manual maintenance: Adding a new driver crate only requires adding it
as a dependency of
crates/keystone.build.rsdiscovers it automatically. - Compile-time enforcement: The build fails if any
openstack-keystone-*dependency is missingpub fn anchor(), preventing accidental breakage. - Naming conveys intent: The
openstack-keystone-<PROVIDER>-driver-<DRIVER_TYPE>suffix makes it immediately clear what crate provides. - No special tooling: The solution uses standard Rust features (
#[used]static,build.rscode generation,include!()).
Negative
- Every
openstack-keystone-*crate has a one-line no-op function. This is negligible overhead. build.rsadds a trivial compilation step (~0.01s in practice, parses a single small text file).
Migration Notes
Existing driver crates were renamed in a single commit. All references
(Cargo.toml workspace members, Cargo.toml dependencies, plugin_manager.rs,
test code, Cargo.toml of test crates) were updated via the subagent.
See Also
crates/keystone/build.rs— auto-discovery and anchor generationcrates/keystone/src/lib.rs—include!()of generated anchorscrates/cli-manage/src/db.rs— binary-side_INVENTORY_LINKreference
19. Credentials Provider Implementation
Date: 2026-06-09
Status
Proposed
Context
Keystone requires a secure mechanism to store and manage sensitive credentials (e.g., EC2 access keys, TOTP secrets) for users. These credentials must be encrypted at rest, support high-availability key distribution across clusters, and provide a safe path for key rotation without risking data loss.
Keystone-NG is deployed in parallel with the Python Keystone service and shares the same live database. This imposes hard constraints on this implementation:
- Keystone-NG never runs DDL against tables owned by the Python Keystone
service (schema evolution remains exclusively under Python Keystone’s
alembiccontrol). - All encryption and hashing behaviour must be byte-for-byte compatible with Python Keystone so that blobs written by either service can be decrypted by the other. Fernet compatibility is verified by cross-service tests.
Decision
1. Architecture & Data Model
The Credentials Provider serves as a secure, encrypted vault for storing
sensitive authentication secrets used by various Keystone identity mechanisms.
It implements a “blind storage” pattern where the core API manages the metadata
and encryption, while the specific meaning of the secret is defined by the
type field.
Supported Credential Types
The provider treats the type field as an open string to allow extensibility.
Common types include:
ec2: Stores AWS-compatibleaccessandsecretkeys. Triggers deterministic ID generation via SHA-256 of the access key.totp: Stores Base32 encoded TOTP seeds for Multi-Factor Authentication (MFA).- Custom Types: Any arbitrary string can be used to store secret blobs for third-party integrations.
Blob JSON Schemas Per Type
Each credential type carries a typed JSON blob. The following schemas define the expected structure:
EC2 Blob:
{
"access": "AKIAIOSFODNN7EXAMPLE",
"secret": "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY",
"trust_id": "optional, present if created via a trust-scoped token",
"app_cred_id": "optional, present if created via an application credential",
"access_token_id": "optional, present if created via an OAuth1 access token"
}
trust_id, app_cred_id, and access_token_id are mutually exclusive,
optional delegation-context fields. They are populated from the scope of the
token used to create the credential and must be passed through to the token
provider on POST /v3/ec2tokens (see §3, “Credential metadata in the token”).
They are part of the same encrypted JSON blob as access/secret — the field
names above (not access_id) are the cross-service contract; a Rust and a
Python node must serialize identical keys or the two services will silently fail
to exchange delegation metadata.
Server-managed, never client-settable (OSSA-2026-005 / CVE-2026-33551): on
create, the server discards any trust_id/app_cred_id/access_token_id
supplied in the request’s blob and re-derives them from the actual
authentication context of the creating request (trust or application credential;
absent for direct authentication). Without this, an EC2 credential created while
authenticated via a delegation would be indistinguishable from a
directly-authenticated one at /v3/ec2tokens validation time, silently
regaining the parent user’s full, unrestricted project role set on every
subsequent use. On update, these fields are immutable and carried forward from
the stored blob when the caller’s patch omits them (as any normal client would,
since the fields are never meant to be client-supplied); a patch that explicitly
supplies a different value, or supplies one where none was stored, is
rejected.
TOTP Blob:
{
"seed": "JBSWY3DPEHPK3PXP",
"digits": 6,
"period": 30
}
Custom types may use arbitrary JSON structures; the provider does not validate the blob contents beyond JSON-parseability.
Security Model
Because this provider stores critical secrets (including MFA seeds), it employs:
- Encryption-at-Rest: All secrets are stored as
encrypted_blobusing the Fernet (AES-128-CBC + HMAC-SHA256) scheme. - Key Isolation: The encryption keys are stored in a separate filesystem-based repository, not in the database.
- Integrity Verification: The
key_hashis stored alongside the blob to ensure the correct decryption key is used during rotation.
Persistence (Database Schema)
Credentials are persisted in the credential table, which is owned and
schema-managed exclusively by the Python Keystone service via alembic.
Keystone-NG treats this table as read/write but never issues DDL against it.
Schema Definition:
| Column | Type | Nullable | Description |
|---|---|---|---|
id | String(64) | No | Primary Key. (For EC2: SHA-256 hex of the access key, per §1 ID Generation). |
user_id | String(64) | No | Foreign key to the user who owns the credential. |
project_id | String(64) | Yes | Project association (Mandatory for EC2 credentials). |
encrypted_blob | Text | No | The Fernet-encrypted secret string. |
type | String(255) | No | Credential type (e.g., 'ec2', 'totp'). |
key_hash | String(64) | No | SHA-1 hex digest of the primary key used for encryption (see §4). |
extra | Text | Yes | Extensible JSON field. Python stores this via its JsonBlob TypeDecorator, which is backed by a plain Text column on every DB dialect Keystone supports — there is no native-JSON variant to detect. The Rust entity must model extra as Option<String> containing JSON text, parsed with serde_json (matching the existing extra handling in identity-driver-sql’s user/group entities), not a native JSON/JSONB column type. |
2. API Interface & Lifecycle
Create (POST /v3/credentials)
- Mandatory Fields:
type: The category of the credential.blob: A JSON string representing the secret. For EC2, must contain anaccesskey.
- Optional Fields:
project_id: Required iftypeis'ec2'.user_id: Defaults to the authenticated user. This default applies only when the request is user-scoped; it must not be applied when the caller holds a system-scoped token (to match Python Keystone behaviour). Under system scope there is no implicit “acting user” to fall back to, so ifuser_idis also omitted from the request body the server must reject the request with400 Bad Requestrather than defaulting it to anything (e.g. the system-scoped caller’s own user, which would silently create a credential owned by an operator account).
- ID Generation:
- EC2:
SHA-256(blob['access'])hex-encoded. - Others: Random UUID.
- EC2:
Read (GET /v3/credentials & GET /v3/credentials/{id})
- Returns the credential reference.
- Security: The
encrypted_blobandkey_hashare stripped from the response; theblobis decrypted to plaintext before serialisation. - Wire format of
blob: Python Keystone returnsblobas a JSON-encoded string (the same string form that was originally submitted on create), not as a nested JSON object — clients are expected tojson.loads()it themselves. Keystone-NG must serialise theblobfield in its API response the same way (a string value), not as a parsed/nested object, or existing SDKs and clients that calljson.loads(cred["blob"])will break against Keystone-NG. - List filtering — two-phase policy check (required to address
CVE-2019-19687): The
GET /v3/credentialsendpoint first enforces theidentity:list_credentialspolicy and applies driver-level hints (e.g.user_id,typequery parameters). It then iterates the returned set and re-enforcesidentity:get_credentialon each individual credential, dropping any record the caller is not permitted to read. This ensures that users with a project role cannot view credentials belonging to other users whenenforce_scopeis false. The performance implication is accepted and matches Python Keystone behaviour.- Policy target correctness: The per-item re-enforcement must build its
policy target from that record’s own
user_id/project_id, not from the requester’s identity or scope. Evaluatingidentity:get_credentialagainst the wrong target (e.g. the caller’s own attributes, or a cached target from the first item) would make the re-check a no-op and reintroduce a variant of CVE-2019-19687 rather than closing it.
- Policy target correctness: The per-item re-enforcement must build its
policy target from that record’s own
Delegation Project Boundary (OSSA-2026-015)
All CRUD operations on /v3/credentials must bind delegated authentication
(trust-scoped tokens, application credentials) to the delegation’s own
project_id, not just the credential’s user_id. Checking ownership via
user_id alone is insufficient: a stolen or reused trust/application-credential
token scoped to project A must not be able to read, modify, or delete a
credential belonging to the same user but bound to project B, nor reach
credentials with no project binding at all (e.g. TOTP/MFA seeds).
- The policy engine receives
input.credentials.is_delegated(derived from [AuthenticationContext::is_delegated], true for trust/application-credential auth, including when carried forward through a re-scoped token) on every request. - The boundary is anchored on
input.credentials.delegated_project_id— the delegation’s own immutable project taken directly from the authentication chain held in [ValidatedSecurityContext] (trust.project_id/application_credential.project_id), not oninput.credentials.project_id(the request’s token scope). Sourcing the boundary from the chain rather than the scope means a scope rebind can never move a delegated caller’s boundary. The two are pinned equal at token-issuance time ([SecurityContext::validate_scope_boundaries]), so policies additionally assertproject_id == delegated_project_idfor delegated callers as a scope-drift tripwire that fails closed. - Show/Delete/Update: a delegated caller may only act on a credential whose
project_idequalsdelegated_project_id; unscoped credentials (project_id == null) are unreachable via any delegated caller. - Update: additionally, a delegated caller’s patch must not move the
credential’s
project_idoutside the delegation’s own (delegated_project_id) project. - Create: a delegated caller’s new credential must set
project_idequal todelegated_project_id; delegated callers cannot create unscoped credentials. - List: unaffected directly — the delegation boundary is enforced entirely
by the per-item
identity/credential/showre-check described above. - Non-delegated authentication (password, token, TOTP, …) is unaffected;
user_id-only ownership remains sufficient. - Effective-role bounding on redemption: a trust presented on a plain
project scope (an EC2 credential created under a trust, redeemed at
POST /v3/ec2tokens, where the scope is rebuilt from the credential’s project rather than the trust’s ownTrustProjectscope) has its effective roles bounded by the trust’s delegated role set, never the trustee’s own project assignments — mirroring the application-credential role intersection so a delegated EC2 credential can never widen its role set beyond the delegation.
Restricted Application Credentials and EC2 (OSSA-2026-005)
A restricted application credential (unrestricted == false) must not be
usable to create an ec2-type credential at all, via either
POST /v3/credentials or POST /v3/users/{user_id}/credentials/OS-EC2. This is
independent of the project-boundary check above and of the delegation role set:
an EC2 credential, once created, authenticates via POST /v3/ec2tokens on its
own terms (see §1, “Server-managed, never client-settable”) — restricting who
may create one is the only point at which a restricted application credential’s
intentionally narrow capability set can be enforced against this particular
escape hatch.
- The policy engine receives
input.credentials.auth_type(e.g."application_credential") and, for application-credential authentication only,input.credentials.unrestricted(Some(bool); absent/nullfor every other auth method). identity/credential/createdenies whenauth_type == "application_credential",unrestrictedis falsy, and the target credential’stype == "ec2"; every other credential type is unaffected.identity/os_ec2/create_credentialapplies the same restricted-app-cred denial unconditionally, since every credential created through that endpoint isec2-typed.
Update (PATCH /v3/credentials/{id})
- Updatable:
type,blob,project_id. - Immutable:
user_idandproject_idmay not be changed to point at a user or project the acting user has no access to (CVE-2020-12691). Within theblob, the following fields are additionally immutable:access(the EC2 access key — changing it would desynchronize the record from its SHA-256-derivedid),trust_id,app_cred_id, andaccess_token_id. - Process: Updating the
blobtriggers automatic re-encryption with the current Primary Key and updateskey_hash.
Delete (DELETE /v3/credentials/{id})
- Supports deletion by ID, by User, or by Project.
Indirect User-Centric Endpoints (/v3/users/{user_id}/credentials/OS-EC2)
These endpoints provide legacy and user-scoped access to EC2 credentials:
- Listing:
GETcallslist_credentials_for_userfiltered bytype='ec2'. Results are flattened from theblobinto explicitaccessandsecretfields. - Automatic Creation:
POSTcan automatically generateaccessandsecretkeys via UUIDs if they are omitted from the request. - Plaintext ID Lookup: For
GETandDELETEoperations, thecredential_idprovided in the URL is the plaintext access key. The server must hash this key (SHA-256) to locate the record in the database.
3. System Integration & Dependencies
The Credentials Provider is not only a standalone API but a critical component integrated into several core Keystone workflows.
Authentication Pipeline (TOTP/MFA)
The provider is a blocking dependency for the authentication flow when TOTP is enabled:
- Workflow: The
TOTPauth plugin callslist_credentials_for_user(user_id, type='totp')during the token issuance process. - Operation: The system decrypts all TOTP seeds for the user and generates current/previous window passcodes to verify the user’s input.
- Performance Requirement: Because this occurs during the login path,
list_credentials_for_usermust be highly performant to avoid increasing authentication latency. A single TOTP decryption (Fernet AES-128-CBC) is approximately 0.1ms. For a user with multiple TOTP credentials, the cost scales linearly. If latency becomes a bottleneck, consider caching decrypted TOTP seeds in memory with a short TTL (e.g., 60 seconds). The cache must store the plaintext seed (not the encrypted blob), and its TTL must be well within the key rotation window. In a multi-node deployment (Python nodes + Rust nodes sharing the same DB), each node maintains its own in-process cache; this is safe because TOTP seeds are not changed by key rotation — only the encrypted form changes — and the decrypted value remains stable across rotations.
Identity Lifecycle Management
The provider must support cascading deletions to prevent orphaned secrets:
- User Deletion: When a user is deleted, the system must call
delete_credentials_for_user(user_id)to wipe all associated secrets. - Project Deletion: When a project is deleted, the system must call
delete_credentials_for_project(project_id)(primarily impacting EC2 credentials bound to projects).
API Transformation Layer
The provider supports the legacy /v3/users/{user_id}/credentials/OS-EC2
interface:
- Flattening: The provider’s
bloboutput is flattened into explicitaccessandsecretfields. - Plaintext Mapping: The provider must support resolving credentials using the SHA-256 hash of a plaintext access key provided in the URL.
4. Encryption Architecture
The provider uses Fernet (symmetric encryption), which is built upon:
- AES-128 in CBC mode for encryption.
- HMAC-SHA256 for authentication.
- Base64url encoding for the final encrypted token.
Key Management (The Key Repository)
-
Configuration:
[credential] provider: Defaults tofernet.[credential] key_repository: Path to the keys directory (default:/etc/keystone/credential-keys/).- Important: This repository must be separate from the
[fernet_tokens]repository.
-
Storage: Keys are stored as individual files in a filesystem directory.
-
Naming: Files use integer names. The highest number is the Primary Key.
-
Cross-node synchronization (required precondition): Because the key repository is a local filesystem directory, not a table in the shared database, the whole byte-compatibility story in this section only holds if every Python node and every Rust node reads the same set of key files (e.g. a shared network filesystem, or a config-management job that distributes the directory to all nodes).
credential_setupandcredential_rotateare cluster-wide operations: a run is not complete until the resulting key files have been propagated to every node of both services. Keystone-NG must document this as a deployment requirement, not assume it happens implicitly from “sharing the same database”. -
Maximum active keys: The credential key repository is hard-capped at 3 active keys (
MAX_ACTIVE_KEYS = 3). This matches the Python Keystone constant and is intentionally not configurable. Unlike Fernet token key rotation, credential key rotation is driven bykey_hashtracking, not by a configurable window. The Rust implementation must enforce this same limit when loading the key repository. -
Rotation Logic (staged-key promotion, not primary renumbering):
- Setup (
credential_setup) creates the first key as0.tmp$\rightarrow$0(staged; not yet used for encryption). - On rotation (
credential_rotate), the staged key0is renamed to(current_primary_index + 1)— e.g. if1is the current primary,0is renamed to2. This renamed file is the new Primary. The old primary (1) is left in place, unchanged, and is still used for decryption. - A fresh key is generated and written as the new staged
0.tmp$\rightarrow$0, ready for the next rotation cycle. - If the number of key files now exceeds
MAX_ACTIVE_KEYS(3), the oldest non-staged key file(s) are deleted to bring the count back down to 3.
Note the staged key is never renumbered “in place” to become primary while simultaneously incrementing some other file — those are the same rename operation applied to the staged file, not the outgoing primary. An implementation that instead renames the outgoing primary upward while separately trying to promote
0(as an earlier ambiguous phrasing of this section could be read) produces two files claiming to be primary and must be avoided. - Setup (
-
Security:
- Directory must not be world-readable.
- Files are created with
umask 0o177and a temporary-file-then-rename strategy to ensure atomicity. - A Null Key (
base64.urlsafe_b64encode(b'\x00' * 32)) is provided as a fallback to facilitate upgrades. The Null Key must be removed immediately after initial setup. Any credential encrypted with the Null Key is effectively stored in plaintext with a well-known key. It exists solely as a transient migration aid and carries zero production tolerance. - Startup enforcement: Keystone-NG must check the key repository on
startup. If any key file decodes to 32 null bytes (the Null Key), it must
emit a hard warning log. Whether this is a hard-refuse-to-start condition
must be controlled by an explicit, named configuration value (e.g.
[credential] insecure_allow_null_key, defaulting tofalse) rather than an undefined “production mode” — refuse to start unless the operator has explicitly opted in. The Python service emits a warning on every encryption operation that uses the Null Key; Keystone-NG matches this behaviour and adds the startup gate.
key_hash Specification
This is a cross-service compatibility contract. Python Keystone and Keystone-NG share the same
credentialtable; akey_hashwritten by one service must be interpretable by the other’scredential_migrateandcredential_rotatecommands.
The key_hash column is computed as follows (derived from
keystone/credential/providers/fernet/core.py):
key_hash = SHA-1( keys[0] ) # hexdigest, lowercase
Where keys[0] is the raw bytes of the primary key file as read from disk —
that is, the base64url-encoded key string, encoded as UTF-8, before
base64url-decoding. This matches Python’s hashlib.sha1(keys[0]).hexdigest()
where keys[0] is a bytes object obtained by reading the key file and
stripping the trailing newline.
Important notes:
- The hash function is SHA-1, not SHA-256. Although SHA-1 is not recommended
for security-sensitive uses, it is used here solely as a key-identifier (not
for authentication), matching the Python implementation. Changing this to
SHA-256 would silently break
credential_migrateandcredential_rotateagainst an existing database. - The output is a lowercase hex string (40 characters), stored in the
key_hashString(64)column. - The input is the base64url-encoded key bytes as they appear in the file, not the raw 32-byte AES key they decode to.
Management Commands (keystone-manage / keystone-ng manage)
Administrative tasks are handled via three specific commands. These commands
must not be run simultaneously from both the Python and Rust services against
the same database. Because credential_rotate performs a safety check
followed by a key promotion in two steps, concurrent execution from two nodes
can race: one node’s migrate could change key_hash values between another
node’s check and promote. Operational runbooks must treat these commands as
mutually exclusive across services.
credential_setup: Populates thekey_repositorywith initial keys. Must be run once during deployment.credential_migrate: Identifies credentials encrypted with older keys (wherekey_hash$\neq$ SHA-1 hex of the current Primary Key) and re-encrypts them using the current Primary Key. Runs in batch chunks (default: 1000 credentials per transaction) withCOMMITbetween batches. Safe to run concurrently with active auth — reads are unaffected, writes are idempotent.credential_rotate:- Safety Check: Verifies that all credentials are already encrypted
with the current Primary Key (i.e. all
key_hashvalues equal the SHA-1 hex of the current primary). - Action: Promotes a new key to Primary.
- Failure: Aborts if any credential still uses an older key to prevent “over-rotation” (which would make those credentials indecipherable).
- Safety Check: Verifies that all credentials are already encrypted
with the current Primary Key (i.e. all
Encryption/Decryption Workflow
- Encryption: Use Primary Key $\rightarrow$ Encrypt $\rightarrow$ Store
encrypted_blobandSHA-1(primary key file bytes as UTF-8)askey_hash. - Decryption: Use
MultiFernet(all active keys in repo, up toMAX_ACTIVE_KEYS = 3) to decrypt. The system attempts decryption with all available keys until one succeeds.
Re-encryption & Safety
To prevent data loss during rotation:
credential_migrate: Decrypts all credentials and re-encrypts them with the current Primary Key, updatingkey_hashto the SHA-1 of the new primary.credential_rotate:- Check: Aborts if any credential’s
key_hash$\neq$ SHA-1 of current Primary Key’s file bytes. - Action: Promotes a new primary key only after successful migration.
- Check: Aborts if any credential’s
5. EC2 Credentials & Authentication
EC2 credentials enable AWS-style authentication, allowing clients to prove identity via request signing without transmitting the secret key.
Request Body Structure (POST /v3/ec2tokens)
The request body is a JSON object with a top-level "credentials" key:
{
"credentials": {
"access": "AKIAIOSFODNN7EXAMPLE",
"signature": "<computed-signature>",
"host": "identity.example.com:5000",
"verb": "GET",
"path": "/",
"params": {
"Action": "DescribeInstances",
"SignatureVersion": "2",
"SignatureMethod": "HmacSHA256",
"Timestamp": "2026-06-11T12:00:00Z",
"AWSAccessKeyId": "AKIAIOSFODNN7EXAMPLE"
},
"headers": {
"Authorization": "AWS4-HMAC-SHA256 Credential=AKIAIOSFODNN7EXAMPLE/20260611/RegionOne/ec2/aws4_request, SignedHeaders=host;x-amz-date, Signature=...",
"X-Amz-Date": "20260611T120000Z"
},
"body_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855"
}
}
The server extracts credentials["access"] to locate the credential record, and
passes the full credentials dict to the signature verification logic. The
body_hash is the SHA-256 hex digest of the request body (required for SigV4;
use the empty-string hash for requests with no body).
Signature Version Detection and Dispatch
The server determines which signing algorithm to use via the following
ordered decision procedure, sourced from Ec2Signer.generate() in
keystoneclient.contrib.ec2.utils:
-
Read
params["SignatureVersion"]from the credentials dict. -
If
SignatureVersion == "0"→ use Version 0 algorithm. -
If
SignatureVersion == "1"→ use Version 1 algorithm. -
If
SignatureVersion == "2"→ use Version 2 algorithm. -
If
SignatureVersionis absent or does not match"0","1","2"→ attempt SigV4 detection via_v4_creds():- Check
credentials["headers"]["Authorization"]— if it starts with"AWS4-HMAC-SHA256", use Version 4. - Otherwise check
credentials["params"]["X-Amz-Algorithm"]— if it equals"AWS4-HMAC-SHA256", use Version 4. - Important: AWS removed the
SignatureVersionfield from the SigV4 spec. SigV4 requests therefore never carrySignatureVersioninparams; the_v4_credsdetection path is not a fallback — it is the primary detection mechanism for SigV4.
- Check
-
If no version is identified → raise
400 Bad Request(unknown signature format).
This means the SignatureVersion query parameter is authoritative for
v0/v1/v2, but absent for v4. The Rust implementation must replicate this exact
precedence: do not assume SigV4 when SignatureVersion is simply missing —
check the Authorization header or X-Amz-Algorithm param explicitly.
Per-Version Signature Algorithms
All versions use the decrypted secret from the credential blob as the key
material.
Version 0 (Keystone-legacy, HMAC-SHA1)
Concatenate Action and Timestamp params, then HMAC-SHA1:
string_to_sign = params["Action"] + params["Timestamp"] # UTF-8 bytes
signature = Base64( HMAC-SHA1(secret_key, string_to_sign) )
Version 1 (Keystone-extended, HMAC-SHA1)
Iterate all params sorted case-insensitively by key, concatenate key+value, then HMAC-SHA1:
sorted_pairs = sort params by key.lower()
string_to_sign = concat(key + value for key, value in sorted_pairs) # UTF-8 bytes
signature = Base64( HMAC-SHA1(secret_key, string_to_sign) )
Version 2 (AWS Query, HMAC-SHA256 preferred / HMAC-SHA1 fallback)
Build a canonical query string (keys and values percent-encoded with
safe='-_~', sorted, joined with &), then HMAC:
canonical_qs = "&".join( quote(k) + "=" + quote(v, safe='-_~')
for k, v in sorted(params.items()) )
string_to_sign = verb + "\n" + host + "\n" + path + "\n" + canonical_qs
Use HMAC-SHA256 if available (set params["SignatureMethod"] = "HmacSHA256"),
otherwise fall back to HMAC-SHA1. The signature is Base64 of the HMAC digest.
Version 4 (SigV4, HMAC-SHA256 throughout)
SigV4 is a multi-stage process. All HMAC operations use SHA-256.
Step 1 — Canonical Request:
cr = "\n".join([
verb.upper(),
path,
canonical_qs(verb, params), # empty string for POST
canonical_header_str(), # lowercased key:stripped_value pairs + trailing \n
auth_param("SignedHeaders"), # from Authorization header or X-Amz-SignedHeaders param
body_hash # SHA-256 hex of request body
])
canonical_header_str() iterates only the headers listed in SignedHeaders
(from the Authorization header or the X-Amz-SignedHeaders query param).
Header keys are lowercased and stripped; values are stripped. Each entry is
key:value, lines joined by \n, with a trailing \n.
Boto compatibility quirk: Boto versions < 2.9.3 strip the port from the
Host header when signing. The server detects this via the User-Agent header
(regex Boto/2\.[0-9]\.[0-2]). When detected, the host entry in
canonical_header_str uses only the hostname, dropping the port. This is
separate from the port-stripping fallback in _check_signature and runs
inside the canonical request construction itself.
Step 2 — String-to-Sign:
string_to_sign = "\n".join([
"AWS4-HMAC-SHA256",
param_date, # X-Amz-Date header (YYYYMMDDTHHMMSSZ), or X-Amz-Date param
credential_scope, # date/region/service/aws4_request from Credential field
SHA256(cr.encode("utf-8")).hexdigest()
])
The date used in param_date must match the YYYYMMDD prefix in the Credential
scope (credential_split[1]). If they do not match, the server raises an error
immediately (no signature attempt).
Step 3 — Derived Signing Key:
k_date = HMAC-SHA256( b"AWS4" + secret_key.encode(), date_str )
k_region = HMAC-SHA256( k_date, region )
k_service = HMAC-SHA256( k_region, service )
k_signing = HMAC-SHA256( k_service, "aws4_request" )
region and service are extracted from credential_scope (positions 2 and 3
of the /-split). For Keystone EC2, service is typically ec2; for S3
tokens, service must be s3.
Step 4 — Final Signature:
signature = HMAC-SHA256( k_signing, string_to_sign.encode("utf-8") ).hexdigest()
Note: the final signature is a hex digest (lowercase), not Base64.
Signature Verification Flow
The full verification procedure in EC2TokensResource._check_signature():
- Instantiate the signer with the decrypted secret key.
- Call
signer.generate(credentials)to produce the expected signature. - If
credentials["signature"]is absent → raise401(“EC2 signature not supplied”). - Perform a constant-time string comparison between the client-supplied
credentials["signature"]and the generated signature. Usehmac.compare_digest(or equivalent) to prevent timing attacks. - Port-stripping fallback: If comparison fails and
credentials["host"]contains:, parse the host, strip the port (use hostname only), reinitialise the signer (a fresh HMAC instance is required to avoid state contamination), regenerate the signature, and repeat the constant-time comparison. - If either comparison succeeds → proceed to token issuance.
- If both fail → raise
401(“Invalid EC2 signature”).
The signer must be reinitialised between the original attempt and the
port-stripping retry. The Python HMAC object is stateful (accumulated via
update()); reusing it after a failed attempt produces incorrect results.
Timestamp Validation (Replay Attack Prevention)
Two timestamp locations depending on signature version (CVE-2020-12692 fix):
- v0 / v1 / v2: Timestamp is in
credentials["params"]["Timestamp"]. Format: ISO 8601 (2026-06-11T12:00:00Z). - v4: Timestamp is in
credentials["headers"]["X-Amz-Date"]orcredentials["params"]["X-Amz-Date"]. Format:YYYYMMDDTHHMMSSZ.
The server must check both locations and reject any request where the timestamp
is outside the configured TTL window. The TTL is read from [ec2] auth_ttl in
the shared keystone.conf, defaulting to 300 seconds (5 minutes) — this is
the Python Keystone default and must not be confused with the 4-hour window that
is only an AWS SigV2 recommendation, not what Keystone implements. Keystone-NG
must read the [ec2] auth_ttl config value from keystone.conf and apply it
identically.
Prior to the CVE-2020-12692 fix, SigV4 requests had no timestamp check because
the timestamp appears in the Authorization header rather than a query
parameter, and the original implementation only inspected query parameters.
Keystone-NG must check both locations from the start.
The /v3/ec2tokens API Interface
Step-by-Step Authentication Algorithm:
- Request Parsing: Parse the JSON body; extract the
credentialsobject. Accept the body under either the top-level"credentials"key, or a legacy"ec2Credentials"key (Python Keystone accepts both for backwards compat). - Policy Enforcement: Enforce
identity:ec2tokens_validateRBAC. Note: As of CVE-2025-65073, this endpoint now requires the caller to be authenticated (a user in theservicegroup). Earlier versions treated/v3/ec2tokensas fully unauthenticated. Keystone-NG must enforce this policy and not mark the endpoint as@unenforced_api. - Credential Lookup: Query the
credentialtable usingSHA-256(access)as the record ID. Return401if not found. Type guard: reject (401) any record whosetype != "ec2". The lookup keys onSHA-256(access) == id, an invariant only established forec2-type credentials at creation (see §1, “Automatic Creation”); without this guard a credential mislabelled to a non-ec2type — thereby dodging theec2-only create-time guards (project binding, delegation stamping, the restricted-app-cred gate of OSSA-2026-005) — could still be redeemed here if its id ever collided with an access hash. - User/Project Validation: After locating the credential, verify:
- The owning user is enabled (
identity_api.assert_user_enabled). - The user’s domain is enabled.
- The bound project is enabled (
resource_api.assert_project_enabled). Return401for any disabled entity.
- The owning user is enabled (
- Secret Decryption: Decrypt
encrypted_blobvia the Fernet provider to recover the plaintextsecretkey. - Timestamp Validation: Extract the timestamp per the version-dependent
rules above. Reject with
401if outside the[ec2] auth_ttlwindow. - Signature Verification: Run the version-detection dispatch and
_check_signatureprocedure described above. - Token Issuance: On success, issue a standard Keystone token scoped to the
credential’s
project_idanduser_id. Return the token in the response body and in theX-Subject-Tokenheader. - Failure: Return
401 Unauthorizedfor any verification failure.
Credential metadata in the token: If the EC2 credential was created via a
trust (trust_id in the blob) or application credential (app_cred_id), this
delegation metadata must be passed through to the token provider so it resolves
the correct (bounded) role assignments — the trust/application-credential
authentication context is rebuilt so the effective roles are bounded by the
delegation’s role set, never the owner’s full project assignments. Omitting this
was a historical bug fixed in Python Keystone. access_token_id (OAuth1) is
rejected until OAuth1 delegation is implemented: redeeming such a credential
would otherwise fall through to an unbounded EC2 authentication and silently
drop the OAuth1 restriction.
Policy-input hygiene: the credential blob holds the decrypted secret
(EC2 secret key, TOTP seed). No credential policy rule references it, so the API
layer strips blob from every credential object before it is sent to the policy
engine (identity/credential/{create,show,update} and the per-item show
re-check on list). Shipping the plaintext secret to an external OPA would expose
it to decision logging, turning the authorization channel into a secret
exfiltration path.
Error Codes
| Status | Condition |
|---|---|
401 | Access key not found, signature mismatch, missing signature, or timestamp expired |
401 | Owning user, domain, or project is disabled |
403 | Policy check fails (identity:ec2tokens_validate) |
409 | EC2 access key hash collision (create) |
422 | Invalid blob JSON structure or missing fields |
404 | User or project no longer exists |
6. Access Control & Permissions
RBAC Policies
get_credential/list_credentials:ADMIN_OR_SYSTEM_READER_OR_CRED_OWNER.create/update/delete:ADMIN_OR_CRED_OWNER.
OS-EC2 Endpoint Policies
The legacy user-centric endpoints (/v3/users/{user_id}/credentials/OS-EC2)
enforce the following access controls:
GET(list):identity:os-ec2:read_credential. Requires the requester to be the credential owner or an administrator.POST(create):identity:os-ec2:create_credential. Requires owner or admin authorization. When called via application credential, the project ID must match the app credential’s bound project.GET/{credential_id}(read):identity:os-ec2:read_credential. Thecredential_idis resolved via SHA-256 hash of the plaintext access key.DELETE/{credential_id}(delete):identity:os-ec2:delete_credential. Same access rules as read.
Guardrails
- Application Credentials: Must be
unrestrictedto manage other credentials. - EC2 Project Match: When creating EC2 credentials via an application
credential, the
project_idmust match the application credential’s project.
20. Decoupled Multi-Tenant Identity Federation & Named ABAC Mapping Engine
Date: 2026-06-11
Status
Approved
Phase 5 Status: Mapping engine migration complete. Legacy federation mappings (
/v4/federation/mappings) removed.allowed_redirect_urismigrated from mapping toIdentityProvider. All federation tests passing.
Context
High-performance, low-latency identity federation mapping for keystone-rs
using a distributed Raft + FjallDB architecture.
1. Context & Motivation (Single vs. Dedicated Engine)
Identity federation platforms must map external cryptographic assertions into
localized authorization contexts. Traditional identity systems treat
authentication vectors as isolated, self-contained plugin mounts (e.g., separate
auth/kubernetes, auth/oidc, and auth/cert backends).
In a high-throughput, multi-tenant distributed cloud operating system like
keystone-rs built on a Raft + FjallDB consensus architecture, continuing down
the path of dedicated plugin mapping engines creates severe architectural
liabilities:
- Massive Code Duplication: Re-implementing conditional expression
evaluation (
equals,any_of,regex) and macro-string parsing across multiple distinct protocol blocks widens the bug surface area. - Fragmented Security Boundaries: Ensuring strict multi-tenant isolation, data sanitization, and domain containment becomes exceptionally brittle when logic is spread across completely separate protocol codebases.
- Raft Log Bloat & Invalidation Risks: Modifying multi-auth tenant parameters simultaneously requires executing separate, non-atomic API writes across distinct plugin endpoints, forcing independent entries through the Raft consensus log and risking partial, inconsistent authorization states.
- Abuse of Token Restrictions: In legacy iterations, because external
service accounts lacked a standard local user row, the system was forced to
issue an unscoped token format and attach a heavy
token_restrictionpayload to “clamp” the token into a project container. This abused a client-side narrowing tool as an administrative configuration table.
The Unified Engine Advantage
keystone-rs enforces a strict Split-Execution Model. Ingress adapters
manage protocol-specific cryptographic validation (signature checking, CRL
validation, remote TokenReview executions, and SPIFFE SVID bundle
verification) and immediately flatten the output into a uniform text claims map
(HashMap<String, Vec<String>>). Downstream authorization is then handled by a
single, centralized, protocol-blind Unified Mapping Engine.
By combining this unified engine with a two-phase SecurityContext
validation framework, unbacked service accounts (Kubernetes pods, automated mTLS
agents, and SPIFFE control-plane daemons) become native, first-class citizens.
They receive fully scoped, immutable tokens from birth without generating
orphaned rows or database bloat in the local user tables.
Furthermore, to eliminate privilege escalation pathways, any ruleset that
contains a control-plane bypass instruction (is_system: true) is structurally
classified as an Immutable System Mapping. These maps are blocked from
undergoing subsequent API modifications, updates, or incremental mutations of
any kind.
Convergence of Local and Distributed Control Planes
To simplify verification logic and prevent security context bifurcation, the system merges its two system-level superuser authorization paths:
- The Mapped Route (
is_system): Mapped via global, cluster-wide SPIFFE or infrastructure rulesets for service-to-service communication across separate physical nodes. - The Local Bootstrap Route (
is_admin): Established natively within theSecurityContextwhen an operator connects locally over a secure Unix Domain Socket (UDS) with a loopback SPIFFE identity matching the static application configuration file.
Both paths are verified through the same strict execution gates, resolving onto identical system-service shortcut variables to permit fast-path control-plane transactions.
Scope, Exclusions, and Trust Boundaries
This ADR defines the unified mapping model, validation rules, storage keyspace, and execution engine. It explicitly excludes:
- Session State Management (
AuthState) — PKCE verifiers, OIDC state tokens, and nonce tracking are handled by the ingress layer. - Ingress Trust Boundary — Ingress adapters are compiled in-tree or run as internal static libraries within the application binary’s native memory space, preventing side-channel data injection.
- Application Credentials for Virtual Users — Any principal initialized as
IdentityInfo::Principalis strictly blocked from executing application credentials, regular credentials and trusts eliminating the risk of unbacked service accounts spawning persistent API keys.
2. Ingress Phase: Provider Configuration Resources (The Crypto Inputs)
The provider_id is a tenant-local functional slug binding an ingress protocol
instance to its access rules. These configuration resource models explicitly
contain their own domain_id and provider_id keys to enforce structural
identifier symmetry across administrative lookup routines.
A. OIDC Identity Provider (IdP) Resource
#![allow(unused)]
fn main() {
pub struct OidcProviderResource {
pub domain_id: Option<String>, // Owning tenant domain boundary (None if global system mapping)
pub provider_id: String, // Functional configuration slug anchor
pub issuer: String, // e.g., "https://auth.acme.com"
pub client_id: String, // Client ID registered at the external IdP
pub client_secret: Option<String>, // Secret used for authorization code exchanges
pub jwks_uri: String, // Cached public keys URI for signature verification
pub allowed_redirect_uris: Vec<String>,
pub oidc_scopes: Vec<String>,
pub token_endpoint_auth_method: Option<String>,
}
}
B. Kubernetes Cluster Issuer Resource
#![allow(unused)]
fn main() {
pub struct K8sClusterResource {
pub domain_id: String,
pub provider_id: String, // Functional configuration slug anchor
pub kubernetes_host: String, // e.g., "https://api.eks.amazonaws.com"
pub kubernetes_ca_cert: String, // Public cluster CA certificate
pub token_reviewer_jwt: String, // Service account token to execute TokenReviews
pub disable_local_ca_jwt: bool, // Force remote verification over local decoding
}
}
C. SPIFFE Trust Domain Resource
#![allow(unused)]
fn main() {
pub struct SpiffeTrustResource {
pub domain_id: String,
pub provider_id: String, // Functional configuration slug anchor
pub trust_domain: String, // e.g., "prod.keystone.internal"
pub trust_bundle_pem: String, // Validating root keys for SVID validation
}
}
3. Downstream Phase: The Named Mapping Model
The rules engine evaluates claims maps using the MappingRuleSet. Rules are
structured as an ordered vector where array position defines execution priority.
However, each individual rule includes an immutable, alphanumeric name handle.
This enables operators to execute fine-grained additions, deletions, and updates
in the middle of the priority vector without relying on volatile integer
indices.
Data Structural Spec (src/identity/mapping/model.rs)
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum DomainResolutionMode {
Fixed, // Locked to mapping.domain_id; claims templates in user_domain_id are rejected
ClaimsOrMapping, // System-Admin Only: Rules may override mapping.domain_id via claims templates
ClaimsOnly, // System-Admin Only: Neither mapping nor provider is bound to a domain
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum IdentitySource {
Federation { idp_id: String },
K8s { cluster_id: String },
Spiffe { trust_domain: String },
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MappingRuleSet {
pub mapping_id: String,
pub domain_id: Option<String>, // Forced to None ("global") for ClaimsOnly/ClaimsOrMapping modes
pub provider_id: String,
pub source: IdentitySource,
pub domain_resolution_mode: DomainResolutionMode,
pub allowed_domains: Vec<String>, // Whitelist of domain IDs that claims-based interpolation may resolve to. Mandatory and non-empty for ClaimsOnly/ClaimsOrMapping modes. For Fixed mode, must be empty (no claims-based interpolation possible)
pub enabled: bool,
pub rules: Vec<MappingRule>,
pub ruleset_version: u128, // Content-aware SHA-256 hash (first 16 bytes) of full ruleset — detects reordering, renaming, authorization swaps, not just addition/deletion
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MappingRule {
pub name: String,
pub description: Option<String>,
pub r#match: MatchCriteria,
pub identity: IdentityBinding,
pub authorizations: Vec<Authorization>,
pub groups: Vec<GroupAssignment>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IdentityBinding {
pub identity_mode: Option<IdentityMode>, // Virtual shadow record (Ephemeral) or real user CRUD (Local)
pub is_system: bool, // Nuclear control-plane shortcut bypass flag; defaults to false
pub user_name: String,
pub user_id: Option<String>,
pub user_domain_id: Option<String>, // Template: resolves to domain UUID string at evaluation time
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum IdentityMode {
/// Real user CRUD: create/find federated user row, sync group memberships.
Local,
/// Virtual shadow registry: HMAC-derived ID, no persistent user row.
Ephemeral,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum Authorization {
Project {
project_id: String,
project_domain_id: String,
roles: Vec<RoleRef>,
},
Domain {
domain_id: String,
roles: Vec<RoleRef>,
},
System {
system_id: String,
roles: Vec<RoleRef>,
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum ClaimCondition {
Equals { claim: String, value: serde_json::Value },
AnyOf { claim: String, values: Vec<serde_json::Value> },
MatchesRegex { claim: String, regex: String },
}
/* ClaimCondition helpers:
- claim_name(): Extracts the claim key from any variant (Equals, AnyOf, MatchesRegex).
- walk_all_claim_conditions(): Flattened iterator over all claim conditions nested
within a MappingRule's match criteria. Walks recursively through nested groups
to collect every leaf ClaimCondition. Used during write-time validation to verify
all regex patterns and check template safety before persistence.
*/
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum MatchCriteria {
AllOf(Vec<MatchCondition>),
AnyOf(Vec<MatchCondition>),
AllOfStrict {
conditions: Vec<MatchCondition>,
require_all_keys: bool, // If true, match fails if any referenced claim key is absent from the claims map
},
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum MatchCondition {
Condition(ClaimCondition),
Nested(Box<MatchCriteria>),
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub struct GroupAssignment {
pub group_id: Option<String>, // Optional group UUID — absent for Local identity mode (resolved by name at runtime)
pub group_name: String, // Template for display/lookup; interpolated at runtime
pub group_domain_id: Option<String>,
pub strategy: Option<GroupStrategy>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum GroupStrategy {
CreateOrGet,
Get,
}
}
4. State Persistence: The Shadow Virtual User Registry
Downstream OpenStack microservices (e.g., Nova, Neutron) or admin users may
perform a GET /v3/users/{user_id} call to resolve user attributes.
Furthermore, during token verification, the original HTTP claims map is
completely gone - Keystone only receives the encrypted Fernet token byte string.
To fulfill the token roundtrip without passing bloated claims inside the token,
the mapping engine derives a deterministic identifier for unbacked principals
during authentication — computed as the first 16 bytes of
HMAC-SHA256(cluster_salt, workload_id || provider_id), formatted as a
UUIDv4-compatible string — and registers a stateful bridge record inside the
Shadow Virtual User Registry within FjallDB.
cluster_salt is a 256-bit cryptographically random key generated at cluster
bootstrap, stored in the static application configuration, and excluded from all
API responses. HMAC-SHA256 replaces naive UUIDv5 (which relies on SHA-1) to
provide a one-way, non-invertible derivation: even if an attacker knows the
provider_id and workload_id (e.g., K8s service account names, SPIFFE URIs),
they cannot reverse the salt or feasibly enumerate shadow registry keys without
brute-forcing the full HMAC output space.
#![allow(unused)]
fn main() {
pub struct ResolvedGroupBinding {
pub resolved_group_id: String, // Immutable UUID anchor — prevents name-collision attacks
pub group_domain_id: Option<String>,
pub strategy: Option<GroupStrategy>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct VirtualUserMetadata {
pub user_id: String, // Deterministic HMAC-SHA256-derived handle (formatted as UUIDv4-compatible string)
pub unique_workload_id: String,
pub mapping_id: String, // Direct anchor to the MappingRuleSet that matched; deterministic verification lookup
pub matched_rule_name: String,
pub domain_id: Option<String>,
pub resolved_user_name: String,
pub is_system: bool, // Immutably preserved from initial upsert — cannot be escalated or revoked by rule modification, preventing runtime privilege escalation
pub resolved_group_bindings: Vec<ResolvedGroupBinding>,
pub authorizations: Vec<Authorization>, // Snapshot of authorizations at issuance — prevents live rule modification from altering cached tokens
pub ruleset_version: u128, // SHA-256 hash (first 16 bytes) captured at issuance — used to detect stale tokens against live ruleset
pub enabled: bool,
pub created_at: i64,
pub last_authenticated_at: i64, // PCI-DSS compliance tracking variable
}
}
A. Shadow Record Lifecycle & Virtual User Deactivation
Because the HMAC-SHA256-derived identifier is deterministic, repeated
authentication by the same principal always resolves to the same user_id. On
token issuance, the engine performs an upsert: if the shadow record already
exists, it completely refreshes matched_rule_name, resolved_group_bindings,
and resolved_user_name. Set enabled: true (a successful match indicates the
principal is active; if previously deactivated, successful authentication
reactivates). The created_at timestamp is immutably preserved from initial
creation. The is_system flag is intentionally preserved from initial
creation — on a subsequent upsert meta.is_system = meta.is_system prevents the
flag from being modified. This is a deliberate security measure: once a
principal is granted system-level service privileges, those privileges cannot be
escalated nor revoked through ruleset modification alone. Revoking is_system
requires setting enabled: false (deactivation) via the provider API, followed
by a fresh authentication lifecycle against a corrected ruleset to re-evaluate
privileges.
To maintain PCI-DSS compliance, the field last_authenticated_at tracks
real-time usage. A dedicated background janitor task range-scans the registry
keyspace nightly; any virtual profile that has failed to log an authentication
event for more than 90 days is deactivation-set (enabled: false), and all
corresponding live authorizations are dropped. This policy applies uniformly —
including virtual users with is_system: true, which must re-attest within the
90-day window or be deactivated (SPIFFE control-plane daemons that authenticate
periodically will naturally stay within this window).
Deactivation preferred over deletion. The janitor sets enabled: false
instead of deleting records, preserving forensic evidence (identity bindings,
authorization snapshots, activity timestamps) for incident response and
compliance auditing. A separate archive cleanup task permanently deletes
deactivated records after a configurable retention period (default: 365 days,
configurable via [keystone] shadow_registry_archive_retention_days). The CADF
maintenance event type captures these archive deletions with the record’s
identity metadata in the attachment payload. The archive cleanup cadence is
configurable (default: weekly, configurable via
[keystone] shadow_registry_archive_cleanup_interval).
5. Execution Engine Logic (src/identity/mapping/engine.rs)
5.1. Claim Condition Evaluation Semantics
Each ClaimCondition variant is evaluated against the flattened claims map
(HashMap<String, Vec<String>>). JSON primitive values from the claims are
normalized to strings for comparison: Number and Bool are converted via
their Display representation, String is used directly, and nested objects
fall back to their JSON serialization. This ensures that a claim value of
boolean true matches a rule condition specifying the string "true".
| Variant | Evaluation Semantics |
|---|---|
Equals | The claim key must exist in the claims map, and at least one value must match the target after JSON-to-string normalization |
AnyOf | The claim key must exist, and at least one claim value must match at least one target value in values |
MatchesRegex | The claim key must exist, and at least one claim value must match the precompiled regex pattern; evaluation is bounded by a 2-second per-regex deadline and a 4 KiB per-value limit to prevent CPU exhaustion |
Regex caching. Precompiled regex patterns are cached in a thread-safe
OnceLock-backed DashMap<String, Regex>. To prevent adversarial cache
partition attacks, the map enforces a 1024-entry cap; once exceeded, the 100
least-recently-used entries are evicted (LRU policy) to retain frequently used
patterns and minimize adversarial cache thrashing.
Runtime evaluation bounds. Two defenses protect against resource exhaustion during regex matching:
- Per-claim value limit. Each individual claim value is capped at 4096 bytes. Claims exceeding this limit are silently dropped from the flattened claims map before evaluation. This limits the input size against which any regex operates.
- Per-match timeout. Each
MatchesRegexevaluation runs with a 2-second deadline. If a single regex match exceeds the timeout, it short-circuits tofalseand emits a CADFaccessevent withRegexMatchTimeoutoutcome. This prevents adversarial claim values against legitimate regex patterns from causing CPU exhaustion.
Walker utility. MappingRule::walk_all_claim_conditions() provides a flat
iterator over every ClaimCondition instance nested within a rule’s match
criteria, used during write-time validation to verify all regex patterns pass
ReDoS safety checks before persistence.
5.2. Match Criteria Resolution Semantics
A MatchCriteria node evaluates nested boolean structures recursively:
| Criteria | Semantics |
|---|---|
AllOf | Every child MatchCondition within the vector must evaluate to true |
AnyOf | At least one child MatchCondition within the vector must evaluate to true |
AllOfStrict | Identical to AllOf, but when require_all_keys is true, the match fails immediately if any ClaimCondition references a key absent from the claims map. This prevents attackers from suppressing higher-priority rules by omitting specific claims, forcing fallback to lower-privilege catch-all rules |
A MatchCondition dispatches to one of two branches:
| Condition | Semantics |
|---|---|
Condition(claim_cond) | Delegates to claim condition evaluation (§5.1) |
Nested(nested_criteria) | Recursively evaluates the embedded MatchCriteria |
This structure allows arbitrary nesting depth, enabling complex multi-claim
combinations such as requiring an exact namespace match AND a regex-matched
service account name within a claims_or_mapping scope.
5.3. Ruleset Entry Point: Match Evaluation
The ruleset evaluator iterates the rules vector top-to-bottom until the first
matching rule. Evaluation is short-circuit: once a rule matches, subsequent
rules are ignored (first-match-wins semantics).
Returned struct. A successful match populates MatchResult:
#![allow(unused)]
fn main() {
pub struct MatchResult {
pub rule_name: String,
pub user_name: String,
pub user_id: Option<String>,
pub user_domain_id: Option<String>,
pub is_system: bool,
pub identity_mode: Option<IdentityMode>, // Propagated from matched rule's IdentityBinding
pub authorizations: Vec<Authorization>,
pub resolved_group_bindings: Vec<GroupRef>, // D3: resolved by name at runtime for Local identity mode
pub ruleset_version: u128, // Content-aware SHA-256 hash (first 16 bytes) — anchors token validity to ruleset state
}
}
Evaluation algorithm. For each rule in priority order:
- Enable gate. If
ruleset.enabled == false, evaluation terminates immediately, returningNone. - Match gate. Evaluate
rule.matchcriteria against the claims map. If it does not match, proceed to the next rule. - Domain resolution. Determine
user_domain_idaccording to the activeDomainResolutionMode:
- If
identity.user_domain_idcontains a template, interpolate it via safe string interpolation.- If interpolation yields an empty string, fall back to
ruleset.domain_id(enclosing domain). - If
domain_resolution_modeisFixedand the interpolated value does not matchruleset.domain_idexactly, abort the rule match and proceed to the next rule. This prevents silent fallback to an unexpected domain. - If the interpolated value matches the enclosing domain directly, accept it.
- If the interpolated value is a valid UUID format, accept it pending existence check during upsert (the evaluator itself has no DB access; domain existence is validated later).
- If the interpolated value is neither the enclosing domain nor a valid UUID,
fall back to
ruleset.domain_idto prevent domain escape. - Domain whitelist check. If
ruleset.allowed_domainsis present and the interpolateduser_domain_idis not contained within it, fall back toruleset.domain_id. This prevents a compromised IdP from injecting arbitrary domain identifiers to redirect principal resolution. - If no
user_domain_idtemplate exists, default toruleset.domain_id.
- If interpolation yields an empty string, fall back to
- user_name gate. Interpolate
identity.user_name. If interpolation fails or produces an empty string, skip this rule and try the next. This prevents blank shadow registry records. - user_id resolution. If
identity.user_idis present, interpolate it. Empty result falls back to the enclosing domain (permitted unlikeuser_name). - Group binding resolution. For each
GroupAssignment, interpolategroup_nameusing the truncating variant (overflow is acceptable for display fields). Emit aResolvedGroupBindingcontaining only the resolved anchor UUID, domain, and strategy — the interpolated display name is discarded. - Result assembly. Return
Some(MatchResult)populated with interpolated identity, snapshotted authorizations, resolved groups, and the content-aware ruleset version.
If no rule matches, return None.
Content-aware ruleset version. The ruleset_version is computed by
serializing the structural payload — mapping_id, provider_id, domain_id,
the JSON-serialized rules vector, domain_resolution_mode, allowed_domains,
and enabled flag — into a canonical string, then hashing it with SHA-256 and
extracting the first 16 bytes as a u128. This replaces a naive length-based
counter, making the version resistant to rule reordering, renaming,
authorization swaps, and cross-rule priority manipulation. The SHA-256 hash (128
bits yields a birthday collision window of ~2^64 attempts), preventing
adversaries from crafting colliding rulesets to bypass TOCTOU detection during
token verification.
5.4. String Interpolation & Template Safety
All template expansion is single-pass with no recursive substitution. Two macro
patterns are recognized: ${claims.<key>} for claim values and
${enclosing_domain_id} for the ruleset’s enclosing domain. The interpolation
regex is compiled once and cached via OnceLock to eliminate per-request
compilation cost.
Non-truncating variant (strict). Used for user_name, user_id, and
user_domain_id. The interpolation accumulates literal segments and substituted
values in order. On any intermediate or final overflow past 256 characters, it
returns Err(InterpolatedValueTooLong) — the caller is responsible for handling
the error (e.g., skipping the rule for user_name).
Truncating variant (display). Used for group_name and other display-only
fields. If interpolation exceeds 256 characters, the original template string is
truncated to 253 characters and appended with .... This never extracts
arbitrary claim values on overflow — only the static template is preserved for
operator debugging.
Security properties:
- Single-pass expansion prevents nested template injection chains.
- Missing claims resolve to empty string (no error), but empty
user_namecauses the rule to be skipped during evaluation. - The
enclosing_domain_idmacro is excluded from claim templates via write-time validation (§10.1) to prevent domain shadowing.
5.5. Engine Error Type
All execution-path failures funnel through a single typed enum:
#![allow(unused)]
fn main() {
#[derive(Debug, thiserror::Error)]
pub enum MappingEngineError {
#[error("mapping ruleset is disabled")]
MappingDisabled,
#[error("mapping not found")]
MappingNotFound,
#[error("matched rule no longer exists in live ruleset")]
MappingRuleNoLongerExists,
#[error("database transaction error")]
TransactionError,
#[error("interpolation failed — claim key not available")]
ClaimKeyNotFound,
#[error("interpolated value exceeds length limit")]
InterpolatedValueTooLong,
#[error("ruleset version mismatch — token issued against stale ruleset")]
RulesetVersionMismatch,
}
}
5.6. Real-Time Effective Role Calculation Core (core/src/auth.rs)
During token verification, calculate_effective_roles reconstructs the
authorization context from the shadow registry. The algorithm:
-
System-service shortcut convergence. If
ctx.is_admin(), set theis_systemflag on the context. This unifies local loopback credentials with remote service accounts. -
Principal dispatch. For
IdentityInfo::Principal, proceed with shadow registry lookup. ForIdentityInfo::User, use the existing role resolution path (unchanged). -
Shadow registry bridge. Look up
VirtualUserMetadatausing the virtualuser_idfrom the token. If missing or disabled, return an authentication error. -
System flag propagation. If
shadow_meta.is_system == true, set the system service flag on the context, enabling control-plane shortcut bypasses. -
Live ruleset fetch. Use
shadow_meta.mapping_idto resolve the index keyindex:mapping_id:<mapping_id>, which yields(domain_id, provider_id)coordinates, then fetch the liveMappingRuleSet. If disabled, abort. -
TOCTOU version check. Compute the content-aware SHA-256 version of the live ruleset. If it differs from
shadow_meta.ruleset_version, reject the token withRulesetVersionMismatchcontaining both shadow and live versions for incident response audit trail. -
Rule existence check. Verify that
shadow_meta.matched_rule_namestill exists in the live ruleset. If the rule was removed, abort withMappingRuleNoLongerExists. -
Authorization from snapshot. Iterate
shadow_meta.authorizations(the snapshotted version from issuance time, not the live ruleset). For each authorization variant, check scope match:Project: If scope matches the target project and domain, extend roles.Domain: If scope matches the target domain, extend roles.System: If context is marked system service and scope is system-level, extend roles.
-
Group role resolution. For each
resolved_group_bindingin the shadow record, resolve group roles from the assignment provider:- On success, extend effective roles.
- On failure with
GroupStrategy::Get, abort withGroupNotFound. - On failure with
GroupStrategy::CreateOrGet, create the group synchronously within the current authorization transaction, then extend effective roles. This ensures the group exists before the token is considered valid, eliminating a race window between async enqueuing and subsequent verification.
-
Token restriction application. If context has a token restriction, narrow the effective roles accordingly.
-
Empty role check. If no roles were accumulated and the scope is not unscoped and the context is not a system service, return
ActorHasNoRolesOnTarget. -
Deduplication. Sort and deduplicate the effective role list before returning.
6. Concrete Examples: How Mapping Rulesets Look
Use Case 1: SPIFFE Control-Plane Service Binding (is_system Enabled)
- Stored at:
data:mapping:v1:domain_admin_infra:spiffe-local - Context: Authorizes the core Nova service account to issue tokens and perform service-to-service background API transactions over the OpenStack control plane using an explicit system shortcut flag.
{
"mapping_id": "7c8d9e0f-1a2b-3c4d-5e6f-7a8b9c0d1e2f",
"domain_id": "domain_admin_infra",
"provider_id": "spiffe-local",
"source": {
"type": "spiffe",
"trust_domain": "prod.keystone.internal"
},
"domain_resolution_mode": "fixed",
"allowed_domains": [],
"enabled": true,
"rules": [
{
"name": "nova-to-neutron-control-plane",
"description": "Authorize Nova compute workload to bypass target constraints via system flag shortcut",
"match": {
"all_of": [
{
"type": "equals",
"claim": "spiffe.id",
"value": "spiffe://prod.keystone.internal/ns/openstack/sa/nova"
}
]
},
"identity": {
"user_name": "svc-nova-compute",
"user_id": "spiffe-nova-compute",
"is_system": true
},
"authorizations": [
{
"type": "system",
"system_id": "all",
"roles": [{ "type": "system_role", "name": "default-role" }]
}
],
"groups": []
}
]
}
Use Case 2: OIDC Federation (Enterprise SSO Mapping)
- Stored at:
data:mapping:v1:domain_hr:oidc-okta - Context: Maps Okta enterprise SSO claims to internal project roles and
groups. Demonstrates
ClaimsOrMappingdomain resolution, regex-based group parsing, and multi-role assignment per matched rule.
{
"mapping_id": "a1b2c3d4-5678-90ab-cdef-123456789abc",
"domain_id": "domain_hr",
"provider_id": "oidc-okta",
"source": {
"type": "federation",
"idp_id": "okta-enterprise-idp"
},
"domain_resolution_mode": "claims_or_mapping",
"allowed_domains": ["domain_hr", "550e8400-e29b-41d4-a716-446655440001"],
"enabled": true,
"rules": [
{
"name": "hr-admin-role-binding",
"description": "Grant HR admin team _member_ and hr_admin role on HR project",
"match": {
"all_of": [
{
"type": "matches_regex",
"claim": "email",
"regex": "^.*\\.hr@acme\\.com$"
},
{
"type": "any_of",
"claim": "groups",
"values": ["HR-Admin", "HR-Super-Admin"]
}
]
},
"identity": {
"user_name": "${claims.preferred_username}",
"user_id": "${claims.sub}",
"user_domain_id": "${claims.domain_id}"
},
"authorizations": [
{
"type": "project",
"project_id": "550e8400-e29b-41d4-a716-446655440001",
"project_domain_id": "domain_hr",
"roles": [
{ "type": "system_role", "name": "_member_" },
{ "type": "system_role", "name": "hr_admin" }
]
},
{
"type": "domain",
"domain_id": "domain_hr",
"roles": [{ "type": "system_role", "name": "domain_admin" }]
}
],
"groups": [
{
"group_id": "550e8400-e29b-41d4-a716-446655440010",
"group_name": "HR-Admins-Global",
"group_domain_id": "domain_hr",
"strategy": "create_or_get"
}
]
},
{
"name": "regional-team-scope",
"description": "Match regional HR team members and assign to project using regex",
"match": {
"all_of": [
{
"type": "matches_regex",
"claim": "groups",
"regex": "^HR\\-Team\\-(NA|EU|APAC)$"
}
]
},
"identity": {
"user_name": "${claims.preferred_username}",
"user_id": "${claims.sub}",
"user_domain_id": null
},
"authorizations": [
{
"type": "project",
"project_id": "550e8400-e29b-41d4-a716-446655440002",
"project_domain_id": "domain_hr",
"roles": [{ "type": "system_role", "name": "_member_" }]
}
],
"groups": [
{
"group_id": "550e8400-e29b-41d4-a716-446655440020",
"group_name": "Regional-HR-${claims.groups}",
"group_domain_id": "domain_hr",
"strategy": "create_or_get"
}
]
},
{
"name": "default-reader",
"description": "Catch-all fallback for unhandled Okta users",
"match": {
"all_of": [
{
"type": "matches_regex",
"claim": "email",
"regex": "^.*@acme\\.com$"
}
]
},
"identity": {
"user_name": "${claims.email}",
"user_id": "${claims.sub}"
},
"authorizations": [
{
"type": "project",
"project_id": "550e8400-e29b-41d4-a716-446655440003",
"project_domain_id": "domain_hr",
"roles": [{ "type": "system_role", "name": "reader" }]
}
],
"groups": []
}
]
}
Use Case 3: Kubernetes Service Account Authorization
- Stored at:
data:mapping:v1:domain_infra:k8s-eks-prod - Context: Grants EKS-deployed workloads scoped access to OpenStack
resources based on service account name and namespace. Demonstrates
Fixeddomain resolution with nestedAnyOfmatch criteria.
{
"mapping_id": "b2c3d4e5-6789-01bc-def0-23456789abcd",
"domain_id": "domain_infra",
"provider_id": "k8s-eks-prod",
"source": {
"type": "k8s",
"cluster_id": "eks-prod-cluster-01"
},
"domain_resolution_mode": "fixed",
"allowed_domains": [],
"enabled": true,
"rules": [
{
"name": "ci-pipeline-admin",
"description": "Grant CI/CD pipeline service account admin access to infra projects",
"match": {
"all_of": [
{
"type": "equals",
"claim": "k8s.serviceaccount.namespace",
"value": "ci-pipeline"
},
{
"type": "any_of",
"claim": "k8s.serviceaccount.name",
"values": ["build-runner", "deploy-agent"]
}
]
},
"identity": {
"user_name": "svc-k8s-${claims.k8s.serviceaccount.name}"
},
"authorizations": [
{
"type": "project",
"project_id": "550e8400-e29b-41d4-a716-446655440010",
"project_domain_id": "domain_infra",
"roles": [{ "type": "system_role", "name": "admin" }]
}
],
"groups": []
},
{
"name": "monitoring-reader",
"description": "Read-only access for Prometheus/Grafana monitoring agents",
"match": {
"all_of": [
{
"type": "equals",
"claim": "k8s.serviceaccount.namespace",
"value": "monitoring"
},
{
"type": "matches_regex",
"claim": "k8s.serviceaccount.name",
"regex": "^prometheus-.*$"
}
]
},
"identity": {
"user_name": "svc-k8s-${claims.k8s.serviceaccount.name}"
},
"authorizations": [
{
"type": "project",
"project_id": "550e8400-e29b-41d4-a716-446655440010",
"project_domain_id": "domain_infra",
"roles": [{ "type": "system_role", "name": "reader" }]
}
],
"groups": [
{
"group_id": "550e8400-e29b-41d4-a716-446655440030",
"group_name": "Monitoring-Agents",
"group_domain_id": "domain_infra",
"strategy": "get"
}
]
}
]
}
7. Runtime Mechanics & Token Lifecycle Roundtrips
Workflow: SPIFFE Workload Authentication & Verification
Phase A: Token Issuance (Login)
- The Nova compute driver forwards its signed SPIFFE SVID certificate context
to
keystone-rs. - The SPIFFE ingress provider validates the cryptographic signature using the
matching
SpiffeTrustResourcetrust bundle PEM. It flattens the workload metrics into clean text claims (spiffe.id,spiffe.trust_domain) and hands them to the Mapping Engine. - The engine matches the rule
"nova-to-neutron-control-plane"and reads its explicitidentityproperties, picking up theis_system: trueinstruction. - The system derives the persistent HMAC-SHA256
user_idfor the workload. It executes an atomic transactional upsert against the shadow virtual user registry (detailed in Section 7.2 below), recordingmapping_id(to anchor the live ruleset),matched_rule_name: "nova-to-neutron-control-plane", capturingresolved_user_nameandresolved_group_bindingsfrom the live claims map, and immutably recordingis_system: trueinto the registry row. Once set, this flag is preserved across all subsequent upserts — theis_systemprivilege cannot be revoked by rule modification alone. - The token engine compiles a native
SystemScopeFernet Token payload variant directly containing the virtualuser_id. The token is returned to the Nova driver.
Phase B: Token Verification (Roundtrip)
- The Nova driver calls Neutron to wire up a VM interface, attaching its token.
Neutron hands the token back to Keystone’s
authorize_by_tokenendpoint for verification. - The token provider decrypts the Fernet payload, matching the
SystemScopelayout and pulling the virtualuser_id. - The engine builds an unverified, raw
SecurityContextinitializing the identity as an unbackedIdentityInfo::Principal. - The handler calls
ValidatedSecurityContext::new_for_scope(). Insidecalculate_effective_roles(), the engine reads the shadow user profile from FjallDB using theuser_id. It usesmapping_idto look up the index keyindex:mapping_id:<mapping_id>which resolves the(domain_id, provider_id)coordinates, then fetches the liveMappingRuleSetfromdata:mapping:v1:<domain_id>:<provider_id>. It encounters the activeis_system: trueflag. - The method calls
ctx.set_system_service_flag(true), applying the control-plane shortcut bypass directly to the context memory segment. When projected intoCredentialsfor policy analysis, the OPA engine registersis_system: trueand cleanly validates the communication path.
7.2. Atomic Transactional Upsert Flow & Adaptive Rate Limiter
To shield the shadow registry from write-amplification DoS attacks, creation
lookups pass through a sliding-window token bucket rate-limiter tracked per
provider_id. The threshold is configurable via
[keystone] shadow_registry_creation_rate_limit in keystone.conf (default: 50
operations per minute). When unique principal creation events spike past this
threshold, the login path drops further entries with an HTTP
429 Too Many Requests status code.
A second rate-limit tier governs total authentications per provider_id (both
new and existing principals), configurable via
[keystone] shadow_registry_auth_rate_limit (default: 500 operations per
minute). This prevents replay attacks against known valid principals from
bypassing the creation-only limit. When this threshold is exceeded, all
authentication attempts against the provider are rejected with HTTP
429 Too Many Requests until the sliding window expires.
Upsert algorithm. The virtual user record is persisted atomically within a single database transaction:
-
HMAC-SHA256 user_id derivation. Compute a deterministic identifier for the principal using a 256-bit per-cluster secret as the HMAC key:
HMAC-SHA256(cluster_salt, workload_id || provider_id), taking the first 16 bytes and formatting as a UUIDv4-compatible string. Thecluster_saltis generated at cluster bootstrap, stored in the static application configuration, and excluded from all API responses. HMAC-SHA256 (rather than UUIDv5/SHA-1) provides a one-way, non-invertible derivation: an attacker knowingprovider_idandworkload_idcannot reverse the salt or enumerate shadow registry keys. Identical principals always resolve to the sameuser_idwithin a cluster, while cross-cluster correlation is blocked by the per-cluster salt. -
Domain existence validation. The
effective_domainis determined fromMatchResult.user_domain_id:- If
user_domain_idis a valid UUID, checkindex:auth:domain_id:<uuid>for existence. If the domain exists, use it. - If
user_domain_idis a human-readable slug, checkindex:auth:domain_slug:<slug>for existence. If found, resolve the mapped UUID and use it. - If the interpolated domain does not exist in either index, reject the
upsert with
ValidationResult::DomainNotFound, preventing non-existent UUIDs from persisting in shadow records (fixes the UUID-format domain gap). - If
user_domain_idis absent or the above checks fail, fall back toruleset.domain_id(the enclosing domain).
- If
-
Read existing shadow record. Attempt to fetch from
user:v1:virtual:<user_id>. -
Merge or create.
- Update path (existing record): Refresh
mapping_id,matched_rule_name,resolved_user_name,resolved_group_bindings, and snapshottedauthorizations. Updateruleset_versionfrom the match result andlast_authenticated_atto current timestamp. Setenabled: true— a successful match indicates the principal is active; if the record was previously deactivated, successful authentication reactivates it. Thecreated_attimestamp is immutably preserved from initial creation. Theis_systemflag is immutably preserved from initial creation (meta.is_system = meta.is_system) — once a principal is granted system-level privileges, those privileges cannot be escalated nor revoked through ruleset modification alone. Revokingis_systemrequires settingenabled: false(deactivation) via the provider API. - Insert path (new record): Create a fresh
VirtualUserMetadatapopulated with all fields from the match result, the validatedeffective_domain,enabled: true, and the current timestamp forcreated_atandlast_authenticated_at.
- Update path (existing record): Refresh
-
Persist. Write the merged or new record atomically to the shadow keyspace.
7.3. Validation Error Code Reference
| Error Variant | HTTP Status | JSON Detail Key | Triggering Condition |
|---|---|---|---|
SystemTokenShadowing(key) | 422 | "detail.system_token_shadowing" | Template references ${claims.enclosing_domain_id} |
DomainClaimRequired | 422 | "detail.domain_claim_required" | ClaimsOnly mode without ${claims.*} in user_domain_id |
DomainOverrideInFixedMode | 422 | "detail.domain_override_fixed_mode" | Claims template in user_domain_id when resolution mode is Fixed |
InvalidRuleName(name) | 422 | "detail.invalid_rule_name" | Rule name fails regex rules or exceeds length limits |
DuplicateRuleName(name) | 422 | "detail.duplicate_rule_name" | Two rules within the same ruleset share the same name |
RegexSafetyViolation(pattern, msg) | 422 | "detail.regex_safety_violation" | Regex pattern fails write-time ReDoS safety check |
ShadowRegistryConflict | 409 | "detail.shadow_registry_conflict" | Transactional upsert fails after exhaustion of retries |
GroupNotFound(name) | 403 | "detail.group_not_found" | GroupStrategy::Get evaluates against a non-existing group |
MappingRuleNoLongerExists | 403 | "detail.mapping_rule_removed" | Shadow record references a rule name missing from the live ruleset (rule name omitted from error response) |
MappingDisabled | 403 | "detail.mapping_disabled" | Ruleset enabled flag is false during verification |
MappingNotFound | 404 | "detail.mapping_not_found" | No ruleset exists at the computed keyspace coordinate |
SystemMappingIsImmutable | 422 | "detail.system_mapping_immutable" | Operator attempts to modify a ruleset containing system-level flags |
RoleGrantUnauthorized(role, project_id) | 403 | "detail.role_grant_unauthorized" | Non-admin operator lacks role on project_id |
CrossDomainMapping(domain_id) | 403 | "detail.cross_domain_mapping" | Non-admin operator targets domain UUID outside own domain |
GroupAssignmentUnauthorized(group_id) | 403 | "detail.group_assignment_unauthorized" | Non-admin operator lacks admin on target group_id |
SystemScopeRequiresIsSystem | 422 | "detail.system_scope_requires_is_system" | Authorization::System used without is_system: true on the rule |
DomainMappingUnauthorized(domain_id) | 403 | "detail.domain_mapping_unauthorized" | Non-admin operator grants roles at domain scope outside their own |
DomainResolutionModeRequiresAdmin(mode) | 422 | "detail.domain_resolution_mode_requires_admin" | Non-admin operator creates ClaimsOrMapping/ClaimsOnly ruleset |
AllowedDomainsRequired(mode) | 422 | "detail.allowed_domains_required" | ClaimsOnly/ClaimsOrMapping ruleset submitted without non-empty allowed_domains |
InterpolatedValueTooLong(msg) | 400 | "detail.interpolated_value_too_long" | Template interpolation exceeds 256 char limit (rejects blank records) |
RulesetVersionMismatch | 401 | "detail.ruleset_version_mismatch" | Token shadow version differs from live ruleset (version numbers omitted from error response) |
RoleNotFound(id) | 422 | "detail.role_not_found" | A rule’s Authorization::{Project,Domain,System}.roles references a RoleRef whose id does not match any existing Role |
RoleNotFound closes a gap distinct from the other write-time checks above:
a typo’d or invented role reference previously produced a rule whose
authorization could never resolve to a real Role, and failed silently
rather than at creation time (see ADR 0024 §8, where exactly this class of
bug — invented SystemAdmin/DomainManager role literals with no backing
Role — was found and fixed in the SCIM policies). Rule create/update now
resolves every referenced RoleRef.id against the Role store
(RoleApi::get_role) and rejects the ruleset with 422 if it does not
exist. RoleRef.id is mandatory (unlike name, which is optional), so this
is the field the check keys on.
8. Unified Keyspace Naming Scheme Summary
All indices, entries, structures, and metadata elements are maintained inside a single consolidated partition layer in FjallDB.
| Functional Purpose | Key Namespace Pattern | Value Payload |
|---|---|---|
| Global Domain Slug Index | index:auth:domain_slug:<domain_slug> | String domain_id (UUIDv4) |
| Scoped Mapping Index | index:auth:mapping:<domain_id>:<mapping_name> | String provider_id (Slug) |
| Global Mapping ID Index | index:mapping_id:<mapping_id> | {"domain_id": "...", "provider_id": "..."} |
| Global JWT Invariant Index | index:auth:jwt:<sha256(iss+"\0"+aud)> | {"domain_id": "...", "provider_id": "..."} |
| Global Client Index | index:oauth2:client:<client_id> | {"domain_id": "...", "provider_id": "..."} |
| Virtual User Shadow Records | data:user:virtual:<user_id_hmac> | VirtualUserMetadata (Struct object) |
| OIDC Crypto Resource | data:federation:oidc:<domain_id>:<provider_id> | OidcProviderResource (Struct) |
| K8s Crypto Resource | data:k8s_auth:<domain_id>:<provider_id> | K8sClusterResource (Struct object) |
| SPIFFE Crypto Resource | data:spiffe:<domain_id>:<provider_id> | SpiffeTrustResource (Struct object) |
| OAuth2 Client Resource | data:oauth2:client:<domain_id>:<provider_id> | OAuth2ClientResource (Struct object) |
| Unified ABAC Ruleset | data:mapping:<domain_id>:<provider_id> | MappingRuleSet (Contains named rule vector) |
9. Administrative CRUD Management API Specification
A. Create Mapping Configuration
- HTTP Method / Path:
POST /v4/mappings - Validation Bounds — Two-Tier Enforcement:
- Tier 1 (SystemAdmin Gate): If
mapping.rulescontains at least one target whereidentity.is_system == true, andctx.is_admin() == false(noSystemAdmincredentials), the call is rejected with HTTP403 Forbidden. Mappings with an active system-bypass flag are stamped as Immutable System Mappings. - Tier 2 (Authorization Bounds — Non-Admin Only): If
ctx.is_admin() == false, the engine validates creator scope before persistence:
- Tier 1 (SystemAdmin Gate): If
- Domain confinement — for
Authorization::Project,project_domain_idmust match the operator’s effective domain UUID; forAuthorization::Domain,domain_idmust match the operator’s domain. Cross-domain mappings are rejected with403 Forbidden(CrossDomainMapping).- Role grant parity — for every
Authorization, the operator must hold each role listed inroleson the target scope. Failure returns403 Forbidden(RoleGrantUnauthorized). - System scope restriction —
Authorization::Systemrequiresis_system: trueon the mapping rule andis_admin()on the operator. Non-admin operators are rejected with422 Unprocessable Entity(SystemScopeRequiresIsSystem). - Group assignment authority — for every
GroupAssignment, the operator must holdadminon the target group. Failure returns403 Forbidden(GroupAssignmentUnauthorized). - SystemAdmin bypass: When
ctx.is_admin() == true, Tier 2 is bypassed entirely. The admin operator may map to any domain, grant any roles, and assign to any groups.
- Role grant parity — for every
B. List Mapping Configurations (Tenant-Isolated)
- HTTP Method / Path:
GET /v4/mappings?domain_id=domain_admin_infra - Response:
200 OK
C. Get Mapping Profile
- HTTP Method / Path:
GET /v4/mappings/{mapping_id} - Response:
200 OK
D. Declarative Overwrite / Apply Update
- HTTP Method / Path:
PUT /v4/mappings/{mapping_id} - Immutability Protection: If the target configuration is flagged as an
Immutable System Mapping, the engine will completely abort the operation,
throwing an HTTP
422 Unprocessable Entityresponse. System mappings can never be updated; changes require an explicitDELETEstatement followed by a freshPOSTinitialization block to preserve clean audit separation. - Domain Immutability: The
domain_idfield is structurally immutable upon ruleset creation. AnyPUTrequest attempting to modifydomain_idto a different value is rejected with HTTP422 Unprocessable Entity. The owning domain anchor cannot be migrated post-creation, as it forms the basis of the keyspace coordinate (data:mapping:v1:<domain_id>:<provider_id>) and all shadow registry lookups for principals issued under the ruleset.
E. Imperative Rule Mutation (Relative Anchoring Path)
- HTTP Method / Path:
POST /v4/mappings/{mapping_id}/rules/mutate - Immutability Protection: Throws an immediate HTTP
422 Unprocessable Entityif the target is an Immutable System Mapping, ensuring no mutation deltas can manipulate control-plane assets.
F. Virtual User Lifecycle Management
-
Disable Virtual User:
- HTTP Method / Path:
PATCH /v4/virtual_users/{user_id}/disable - Response:
200 OK— returns the deactivatedVirtualUserrecord - Effect: Sets
enabled: false, triggers token revocation pipeline (revocation:v1:user:<user_id>), preserves forensic record for audit trail
- HTTP Method / Path:
-
Enable (Reactivate) Virtual User:
- HTTP Method / Path:
PATCH /v4/virtual_users/{user_id}/enable - Response:
200 OK— returns the reactivatedVirtualUserrecord - Effect: Sets
enabled: true, re-activates the principal for future authentication
- HTTP Method / Path:
-
Get Virtual User Profile:
- HTTP Method / Path:
GET /v4/virtual_users/{user_id} - Response:
200 OK— returns fullVirtualUsermetadata including snapshotted authorizations, resolved groups, and activity timestamps
- HTTP Method / Path:
10. Security Architecture, Invariant Protections & Auditing
10.1. Write-Time ReDoS and Immutability Validation Rules
The ValidationError structure explicitly manages our safety parameters:
#![allow(unused)]
fn main() {
#[derive(Debug, thiserror::Error)]
pub enum ValidationError {
#[error("template references reserved key: {0}")]
SystemTokenShadowing(String),
#[error("ClaimsOnly mode requires user_domain_id template with ${claims.*}")]
DomainClaimRequired,
#[error("Fixed mode does not allow claims templates in user_domain_id")]
DomainOverrideInFixedMode,
#[error("rule name '{0}' is not a valid identifier")]
InvalidRuleName(String),
#[error("duplicate rule name '{0}' within ruleset")]
DuplicateRuleName(String),
#[error("system level bypass mappings are strictly immutable and can never be modified")]
SystemMappingIsImmutable,
#[error("regex pattern '{0}' is syntactically invalid")]
InvalidRegexSyntax(String),
#[error("regex pattern '{0}' exceeds complexity limit (AST size > 4096)")]
RegexTooComplex(String),
#[error("regex pattern '{0}' fails write-time ReDoS safety check: {1}")]
RegexSafetyViolation(String, String),
#[error("operator lacks role '{0}' on project '{1}' — cannot grant via mapping")]
RoleGrantUnauthorized(RoleRef, String),
#[error("mapping targets domain '{0}' which is outside the operator's domain")]
CrossDomainMapping(String),
#[error("operator cannot assign members to group '{0}'")]
GroupAssignmentUnauthorized(String),
#[error("system scope authorization requires is_system: true on the mapping rule")]
SystemScopeRequiresIsSystem,
#[error("non-admin operator cannot grant roles at domain scope for '{0}'")]
DomainMappingUnauthorized(String),
#[error("domain_resolution_mode '{0}' requires SystemAdmin privileges")]
DomainResolutionModeRequiresAdmin(DomainResolutionMode),
#[error("domain_resolution_mode '{0}' requires non-empty allowed_domains")]
AllowedDomainsRequired(DomainResolutionMode),
#[error("allowed_domains contains domain '{0}' outside operator scope")]
AllowedDomainOutOfScope(String),
}
}
ReDoS Protection at Write-Time
Runtime caching mitigates repeated compilation of expensive regexes, but write-time validation is the primary defense. A regex condition is safe if it:
- Passes
regex_syntax::ParserAST validation — detects invalid syntax at parse time - Lacks nested quantifiers —
(a+)+,(a*)*,(a{2,})*are rejected by recursive AST walk detectingRepetitionnodes that directly contain anotherRepetitionor alternation group as their child expression - Lacks unbounded alternation under quantifiers —
(a|a)+,(a|b|c)*with overlapping branches are rejected - Stays within complexity bounds — AST string representation exceeds 4096 characters
The regex crate’s NFA engine guarantees linear backtracking at runtime,
neutralizing many ReDoS vectors intrinsically. The write-time AST walk functions
as a defense-in-depth layer, rejecting the remaining pathological patterns at
ingestion time before they can enter the compiled cache.
Authorization Bound Validation
Every mapping rule is validated before persistence against the operator’s current privileges, enforcing two-tier authorization:
Tier 1 — SystemAdmin bypass. When ctx.is_admin() == true, all
authorization bound checks are skipped. The admin operator may map to any
domain, grant any roles, and assign to any groups.
Tier 2 — Regular operator constraints. When ctx.is_admin() == false, the
engine validates:
- System scope requirement. For every
Authorization::Systemvariant, the parent rule must haveidentity.is_system == true. Otherwise, reject withSystemScopeRequiresIsSystem. - Domain confinement. For
Authorization::Project,project_domain_idmust match the operator’s effective domain UUID. ForAuthorization::Domain,domain_idmust match the operator’s domain. Cross-domain mappings are rejected withCrossDomainMappingorDomainMappingUnauthorized. - Role grant parity. For every role listed in an authorization’s
rolesvector, the operator must hold that same role on the target scope. For project scope, this is checked against project-specific role assignments. For domain scope, against domain-level role assignments. Failure returnsRoleGrantUnauthorized. - Group assignment authority. For every
GroupAssignment, the operator must holdadminon the targetgroup_id. Failure returnsGroupAssignmentUnauthorized.
Mapping Definition Validation
The validate_mapping_definition function orchestrates all write-time guards:
-
Template claim extraction. Extract every
${claims.<key>}reference fromuser_name,user_id, anduser_domain_idtemplates. If any key equalsenclosing_domain_id, reject withSystemTokenShadowingto prevent domain context shadowing. -
Regex safety. For every
MatchesRegexcondition reachable viawalk_all_claim_conditions(), invoke ReDoS validation. Any failure short-circuits withRegexSafetyViolation. -
Authorization bounds. Invoke the two-tier authorization validation against the operator’s SecurityContext, project roles, domain roles, and group roles.
-
Domain resolution mode enforcement. Non-admin operators are strictly prohibited from creating
ClaimsOrMappingorClaimsOnlyrulesets, as these modes enable domain escape and cross-domain virtual user creation. If a non-admin operator specifies these modes, reject withDomainResolutionModeRequiresAdmin. -
Mode-internal consistency.
ClaimsOnly: Theuser_domain_idtemplate must contain at least one${claims.*}reference. Otherwise, reject withDomainClaimRequired.Fixed: Theuser_domain_idmust NOT contain any${claims.*}reference. Otherwise, reject withDomainOverrideInFixedMode.ClaimsOrMapping: Both claim templates and static values are permitted (admin only).
9. Claim Value Size Enforcement
Flattened claims maps are subject to size caps before evaluation:
- Per-claim limit. Each claim value must not exceed 4096 bytes. Values exceeding this limit are silently dropped from the claims map. This prevents a single oversized claim causing memory pressure or CPU exhaustion during regex evaluation.
- Total map limit. The total serialized size of the flattened claims map
(
HashMap<String, Vec<String>>) must not exceed 64 KiB. If exceeded, the ingress adapter rejects the authentication attempt with413 Payload Too Large.
10. Domain Whitelist Enforcement
Mandatory for ClaimsOnly/ClaimsOrMapping. If domain_resolution_mode is
ClaimsOnly or ClaimsOrMapping, allowed_domains must be present and
non-empty. This prevents a compromised IdP from injecting arbitrary domain
identifiers to redirect principal resolution. If allowed_domains is empty,
reject with AllowedDomainsRequired. For Fixed mode, allowed_domains must
be empty (no claims-based interpolation possible).
For every domain in allowed_domains:
- For non-admin operators, each domain must be within the operator’s effective
domain or the operator’s own domain. Otherwise, reject with
AllowedDomainOutOfScope. - For admin operators, each domain must exist in the global domain index.
Otherwise, reject with
DomainNotFound.
11. Domain Whitelist Intersection Check
At evaluation time (§5.3, step 3), after interpolating user_domain_id from
claims, the engine checks that the resolved domain falls within
allowed_domains. If the interpolated value is not contained in the whitelist,
fall back to ruleset.domain_id to prevent domain escape.
12. Real-Time Token Revocation Pipeline
Any Raft proposal that deactivates (enabled: false), deletes (archive
cleanup), or alters a MappingRuleSet will automatically append explicit token
validation revocation objects directly into the global validation engine (ADR
0009 keyspace):
revocation:v1:mapping:<mapping_id>$\rightarrow$ Timestamprevocation:v1:user:<virtual_user_id>$\rightarrow$ Timestamp
All token lookup evaluation tasks cross-reference this keyspace prefix layout; matching tokens drop validation sessions instantly upon Raft log entry application on the local node. The mapping provider triggers the revocation pipeline by calling the revocation provider on virtual user deactivation (admin-initiated or janitor-triggered).
13. Normative CADF Auditing Trail Specifications
Every rule mutation, API declarative replacement, or administrative override emits a normative Cloud Auditing Data Federation (CADF) event format log into the system notifier bus. The following event types are emitted:
| Event Type | Triggering Condition |
|---|---|
control | Mapping CRUD operations (create, update, delete, mutate) |
access | Failed authentication attempts, RulesetVersionMismatch rejections |
maintenance | Janitor shadow record deactivations, archive cleanup deletions, virtual user enable/disable, token revocation pipeline activations |
privileged | Admin Tier 1 bypass API invocations (ctx.is_admin() == true paths) |
Example: Control Event (Mapping Mutation)
{
"id": "cadf-uuid-v4-event-id",
"typeURI": "http://schemas.dmtf.org/cloud/audit/1.0/event",
"eventType": "control",
"eventTime": "2026-06-11T14:17:16Z",
"action": "update/identity/mapping",
"outcome": "success",
"initiator": {
"id": "usr_uuid_of_admin_initiator",
"typeURI": "data/security/user",
"name": "cloud-admin-operator",
"domain_id": "default"
},
"target": {
"id": "7c8d9e0f-1a2b-3c4d-5e6f-7a8b9c0d1e2f",
"typeURI": "data/security/mapping",
"name": "spiffe-internal"
},
"observer": {
"id": "keystone-rs-raft-cluster-node-01",
"typeURI": "service/compute/identity"
},
"attachments": [
{
"name": "mutation_delta",
"contentType": "application/json",
"content": {
"operation": "insert",
"rule_name": "nova-to-cinder",
"is_system_applied": true
}
}
]
}
Example: Access Event (RulesetVersionMismatch)
{
"id": "cadf-uuid-v4-event-id",
"typeURI": "http://schemas.dmtf.org/cloud/audit/1.0/event",
"eventType": "access",
"eventTime": "2026-06-11T14:20:00Z",
"action": "read/identity/token/verify",
"outcome": "failure",
"initiator": {
"id": "virtual-user-uuid",
"typeURI": "data/security/virtual-user",
"name": "svc-nova-compute"
},
"target": {
"id": "7c8d9e0f-1a2b-3c4d-5e6f-7a8b9c0d1e2f",
"typeURI": "data/security/mapping",
"name": "spiffe-internal"
},
"attachments": [
{
"name": "reason",
"contentType": "application/json",
"content": {
"error": "RulesetVersionMismatch",
"shadow_version": 12345678901234567890,
"live_version": 12345678901234567891
}
}
]
}
Example: Maintenance Event (Janitor Deactivation)
{
"id": "cadf-uuid-v4-event-id",
"typeURI": "http://schemas.dmtf.org/cloud/audit/1.0/event",
"eventType": "maintenance",
"eventTime": "2026-06-11T03:00:00Z",
"action": "disable/identity/virtual-user/janitor",
"outcome": "success",
"initiator": {
"id": "janitor-task",
"typeURI": "data/system/task"
},
"target": {
"id": "virtual-user-uuid",
"typeURI": "data/security/virtual-user",
"name": "svc-decommissioned-daemon"
},
"attachments": [
{
"name": "reason",
"contentType": "application/json",
"content": {
"last_authenticated_days_ago": 124,
"is_system": false
}
}
]
}
Example: Maintenance Event (Archive Cleanup Deletion)
{
"id": "cadf-uuid-v4-event-id",
"typeURI": "http://schemas.dmtf.org/cloud/audit/1.0/event",
"eventType": "maintenance",
"eventTime": "2026-06-11T03:00:00Z",
"action": "delete/identity/virtual-user/archive-cleanup",
"outcome": "success",
"initiator": {
"id": "archive-cleanup-task",
"typeURI": "data/system/task"
},
"target": {
"id": "virtual-user-uuid",
"typeURI": "data/security/virtual-user",
"name": "svc-decommissioned-daemon"
},
"attachments": [
{
"name": "reason",
"contentType": "application/json",
"content": {
"deactivated_days_ago": 378,
"last_authenticated_at": 1718000000,
"is_system": false,
"mapping_id": "7c8d9e0f-1a2b-3c4d-5e6f-7a8b9c0d1e2f",
"resolved_user_name": "svc-decommissioned-daemon"
}
}
]
}
Example: Control Event (Virtual User Disable)
{
"id": "cadf-uuid-v4-event-id",
"typeURI": "http://schemas.dmtf.org/cloud/audit/1.0/event",
"eventType": "control",
"eventTime": "2026-06-11T15:00:00Z",
"action": "disable/identity/virtual-user",
"outcome": "success",
"initiator": {
"id": "usr_uuid_of_admin_initiator",
"typeURI": "data/security/user",
"name": "cloud-admin-operator",
"domain_id": "default"
},
"target": {
"id": "virtual-user-uuid",
"typeURI": "data/security/virtual-user",
"name": "svc-compromised-agent"
},
"attachments": [
{
"name": "reason",
"contentType": "application/json",
"content": {
"enabled": false,
"is_system": false,
"revocation_triggered": true
}
}
]
}
Example: Privileged Event (Admin Bypass)
{
"id": "cadf-uuid-v4-event-id",
"typeURI": "http://schemas.dmtf.org/cloud/audit/1.0/event",
"eventType": "privileged",
"eventTime": "2026-06-11T15:00:00Z",
"action": "create/identity/mapping",
"outcome": "success",
"initiator": {
"id": "usr_uuid_of_admin_initiator",
"typeURI": "data/security/user",
"name": "cloud-admin-operator",
"domain_id": "default"
},
"target": {
"id": "new-mapping-uuid",
"typeURI": "data/security/mapping",
"name": "emergency-service-binding"
},
"attachments": [
{
"name": "privileged_details",
"contentType": "application/json",
"content": {
"bypassed_tier": "authorization_bounds",
"is_system_granted": true,
"reason": "emergency_service_repair"
}
}
]
}
11. Migration Strategy
11.1. Federation Provider Field Translation
Legacy Mapping Field | New Model Location |
|---|---|
r#type (Oidc/Jwt) | Dropped. IdentitySource::Federation covers both lines. |
bound_audiences | ClaimCondition::AnyOf { claim: "aud", values: [...] } |
user_name_claim | identity.user_name = "${claims.<user_name_claim>}" |
user_id_claim | identity.user_id = Some("${claims.<user_id_claim>}") |
groups_claim | groups.push(GroupAssignment { group_id: compute_sha256_uuid(&claims.<groups_claim>), group_name: "fed_grp:<provider_id>:${claims.<groups_claim>}", strategy: CreateOrGet }) |
token_project_id | Authorization::Project — Project UUID is taken directly from legacy token_project_id. |
token_restriction_id | Obsolete. Whittled role targets migrate directly into Authorization::Project.roles. |
11.2. Claim Flattening Per Provider (Ingress Adapter Contract)
| Provider | Source Data | Flattening Convention | Unique Workload Key Invariant |
|---|---|---|---|
| OIDC / JWT | JWT ID token claims | Flat string mappings via dotted pathways (email, user.profile.id) | Value string of the sub claim element |
| Kubernetes | K8s TokenReview JWT | k8s.serviceaccount.name, k8s.serviceaccount.namespace, k8s.aud | Formatted invariant: <serviceaccount_name>:<serviceaccount_namespace> |
| SPIFFE | SPIFFE SVID cert | spiffe.id, spiffe.trust_domain | Full raw URI format asset string (e.g., spiffe://prod.keystone.internal/ns/openstack/sa/nova) |
Size constraints. All ingress adapters must enforce the following limits:
- Per-claim value: max 4096 bytes (excess silently dropped)
- Total claims map: max 64 KiB (excess rejected with
413 Payload Too Large)
12. Implementation Plan
Implementation proceeds in five sequential phases, each deliverable, testable, and independently verifiable before advancing.
Phase 1: Mapping Provider & Raft Driver
Foundational layer to store, retrieve, and replicate MappingRuleSet objects
and VirtualUser shadow records across the Raft cluster. The mapping provider
owns the keyspace prefix data:mapping:v1: and the index index:mapping_id:.
- Implement
MappingProvidertrait exposingcreate,get,update,delete,enable,disable, andlistoperations against FjallDB. - Implement the Raft driver for mapping mutations: serialize
MappingRuleSetpayloads, enforce Raft consensus ordering, and handle snapshot/compaction. - Implement write-time validation pipeline: regex ReDoS safety, rule name
uniqueness, template safety,
allowed_domainsintersection checks. - Implement content-aware
ruleset_version(SHA-256 first-16-bytes hasher). - Implement virtual user enable/disable with CAS-based toggle.
- Deliverable: Cluster-internal API for mapping CRUD and virtual user lifecycle, verifiable via unit and integration tests.
Phase 2: Mapping CRUD & Evaluation API
HTTP API and engine integration layer. Operators create and manage rulesets; the evaluation engine exposes match testing utilities.
- Implement
PUT,GET,DELETE, andPOSTendpoints under/v4/mappings/(§9). - Implement
POST /v4/mappings/{mapping_id}/rules/mutatefor imperative rule adjustments with relative anchoring. - Wire
MappingEngineinto the ingestion pipeline: ingest flattened claims, iterate ruleset, produceMatchResult. - Deliverable: Fully functional mapping API with engine evaluation, no upstream consumers yet.
Phase 3: SPIFFE Provider Migration
First upstream consumer migration. SPIFFE is lowest-risk: its rulesets map to
Fixed domain resolution, deterministic SPIFFE ID claims, and static identity
bindings.
- Rewrite
SpiffeTrustResourceauthenticator to emit flattened claims, invoke the mapping engine, and consumeMatchResult. - Create SPIFFE rulesets via the mapping provider for existing trust domain configurations.
- Enable shadow registry upsert flow for SPIFFE principals.
- Deprecate SPIFFE bindings concept; route all SPIFFE logins through the unified engine.
- Deliverable: SPIFFE SVID authentication fully mediated by mapping engine;
control-plane
is_systemprincipals issued via shadow registry.
Phase 4: Kubernetes Auth Provider Migration
Migrate the K8s TokenReview authenticator to the mapping engine.
- Rewrite
K8sClusterResourceauthenticator to flattenTokenReviewclaims and invoke the mapping engine. - Create K8s rulesets via the mapping provider, demonstrating nested match
criteria and
AllOfStrictguards. - Enable shadow registry upsert for K8s service account principals.
- Deprecate legacy
K8s_authrole. - Deliverable: Kubernetes TokenReview authentication fully mediated by mapping engine.
Phase 5: Federation Provider Migration
Status: Complete. All steps from Phase 5 delivered.
Final and broadest migration. Existing federation providers (OIDC, JWT) carry
the most complex claim profiles and legacy token_restriction patterns.
- Rewrite
OidcProviderResourceauthenticator to invoke the mapping engine. - DONE:
flatten_federation_claims()helper with per-claim (4096 bytes) and total map (64 KiB) size caps incrates/keystone/src/federation/api/common.rs. - DONE: OIDC callback handler rewritten to use
flatten_federation_claims→MappingAuthRequest→authenticate_by_mappingpattern incrates/keystone/src/federation/api/oidc.rs(replaced 145+ lines of manualFederationBuilder/UserCreateBuilder/list_groups/create_group/set_user_groups_expiringCRUD). - DONE: JWT login handler integrated with mapping engine: verifies JWT claims, flattens claims, delegates to
authenticate_by_mapping(crates/keystone/src/federation/api/jwt.rs). - DONE: Legacy
Mappingmigration utility (mapping_to_ruleset_create) incrates/core/src/mapping/migration.rsusing field translation table (§11.1). - DONE:
ClaimsOrMappingandClaimsOnlydomain resolution modes enabled for Federation scenarios (engine handles all modes uniformly for allIdentitySourcevariants). - DONE:
IdentityMode::Localpath inauthenticate_local(crates/core/src/mapping/service.rs) withfind_or_create_federated_userandsync_user_groups(group membership sync on every login). Removetoken_restrictionpayload generation for federated principals; allscoping is handled natively byAuthorizationfields inMatchResult.- DONE:
token_restriction_idfield removed fromMapping,MappingUpdate, and related SQL drivers (federation-driver-sql). The OIDC flow now usesAuthorization::Projectfrom the mapping engine for scope. Deprecate legacy federation mapping code path.- DONE: Legacy federation mapping CRUD removed. The
/v4/federation/mappings/API endpoints, controller, service methods, and policy files have been eliminated.Mapping,MappingListParameters,MappingUpdatetypes removed fromcore-typesandapi-types.mapping_idremoved fromAuthStatecore type andFederatedAuthStateDB entity.MappingNotFound,MappingTokenProjectDomainUnset,MappingTokenUserDomainUnseterror variants removed fromFederationProviderError.oidc_scopesmoved from legacyMappingintoIdentityProvidercore and API types. Auth flow now requiresdefault_mapping_nameto be set on the IDP; no longer acceptsmapping_idormapping_nameoverride inIdentityProviderAuthRequest. Database tablesfederated_mappingandmappingare retained for backward compatibility but no longer managed by active CRUD code. - DONE: Allowed redirect URIs migrated to
IdentityProvider. Theallowed_redirect_urisfield was moved from the legacyMappingto theIdentityProvidercore/API types, SQL driver, and migration. Redirect URI validation at auth-init rejects URIs not in the allowlist when set, providing OIDC redirect_uri authorization code interception protection. Empty or unset list means no restriction (backward compatible). Replaces the previousmapping.allowed_redirect_urispath. - Deliverable: All federation authentication fully mediated by the unified
mapping engine; legacy
token_restrictionpattern eliminated.
13. Implementation Deviations from ADR Spec
This section documents decisions made during implementation that deviate from the original specification.
D1. MappingRuleSet — provider_id field removed
The MappingRuleSet struct does not carry a separate provider_id field. The
ingress provider instance is identified through source: IdentitySource, which
contains the relevant anchor (idp_id, cluster_id, or trust_domain) as its
enum variant payload. The provider_id slug used in keyspace coordinates is
derived from the source field at storage time.
D2. DomainResolutionMode — allowed_domains consolidated into enum variants
The allowed_domains whitelist was moved from a separate field on
MappingRuleSet into the ClaimsOrMapping and ClaimsOnly enum variants of
DomainResolutionMode. This encodes the constraint “must be non-empty for
ClaimsOnly/ClaimsOrMapping, must be empty for Fixed” into Rust’s type system,
eliminating cross-field runtime validation.
D3. ResolvedGroupBinding replaced with GroupRef
The custom ResolvedGroupBinding struct was replaced with GroupRef (defined
in crate::identity::group), mirroring the existing RoleRef pattern. The
strategy field from the original ResolvedGroupBinding was dropped — group
resolution strategy (CreateOrGet/Get) is encoded in GroupAssignment within
the live ruleset, which the engine fetches during verification. The persisted
shadow record only needs the group anchor (id + domain_id + name).
D4. MappingRuleSetUpdate — mode variant is immutable
The MappingRuleSetUpdate type carries allowed_domains as a separate
Option<Vec<String>> field rather than replacing the entire
DomainResolutionMode. The service layer merges the new allowed_domains into
the existing variant, preventing an operator from changing Fixed →
ClaimsOrMapping (or vice versa) via update. The resolution mode variant itself
is immutable after creation.
D5. is_system: bool — defaults to false
The is_system field on IdentityBinding is typed as bool (not
Option<bool>) with a serde(default) attribute that resolves missing JSON to
false. This removes ambiguity — an omitted field means the operator did not
grant system privileges.
D6. GroupStrategy::CreateOrGet — default for GroupAssignment
The strategy field on GroupAssignment defaults to CreateOrGet rather than
requiring explicit specification, as it is the more permissive operator-friendly
default (fewer failures when groups are not pre-provisioned).
D7. MappingRule — provider_id not present
MappingRule does not carry provider_id. It is nested within
MappingRuleSet, which identifies the provider through
source: IdentitySource. All rule-level context is inherited from the parent
ruleset.
D8. Virtual user lifecycle — deactivation preferred over deletion
The janitor task sets enabled: false instead of deleting records. A separate
archive cleanup task permanently removes deactivated records after a
configurable retention period (default: 365 days, configurable via
[keystone] shadow_registry_archive_retention_days). This preserves forensic
evidence (identity bindings, authorization snapshots, activity timestamps) for
incident response and compliance auditing. The original spec specified immediate
deletion. The provider interface is extended with explicit enable_virtual_user
and disable_virtual_user methods. The mapping provider calls the revocation
provider upon virtual user deactivation to trigger the token revocation
pipeline.
D9. GroupAssignment.group_id — String changed to Option<String>
The group_id field on GroupAssignment is Option<String> rather than
String. For Local identity mode, groups are resolved by name at runtime via
group_name interpolation, and no pre-assigned group_id is required.
Operations like find_or_create_federated_user and sync_user_groups resolve
groups by GroupRef.name at authentication time. For Ephemeral identity mode,
GroupAssignment.group_id should be specified to prevent group naming
collisions.
D10. Legacy Mapping migration utility — mapping_to_ruleset_create
The migration utility (mapping_to_ruleset_create in
crates/core/src/mapping/migration.rs) performs the field translation described
in §11.1, with the following deviations:
bound_audiencesis converted to a singleClaimCondition::AnyOfon the"aud"claim, rather than individual equality checks per audience.groups_claimgenerates a singleGroupAssignmentwithgroup_id: Noneandstrategy: CreateOrGet, deferring group resolution to authentication time.token_project_idgenerates anAuthorization::Projectwith an emptyrolesvector — the operator must populate roles post-migration.bound_claimsentries are added as individualClaimCondition::Equalsconditions within anAllOfcriteria, preserving their original semantics.r#type(Oidc/Jwt) is dropped sinceIdentitySource::Federationcovers both flows.token_restriction_idis dropped as obsolete; scoping is now handled byAuthorizationfields.allowed_redirect_urisandoidc_scopesare dropped as provider-level configuration (not part of ruleset semantics).- The generated rule name is
"legacy-mapping-rule"with a description referencing the original mapping name and type.
D11. MatchResult.identity_mode — new field
The MatchResult struct carries identity_mode: Option<IdentityMode> (default
None), which propagates IdentityBinding.identity_mode from the matched rule.
When None, defaults to Ephemeral for all sources to maintain backward
compatibility with existing mapping rules. This field determines whether the
authentication flow should use real user CRUD (Local) or the virtual shadow
registry (Ephemeral).
21. Stateless API-Key Ingress & Ephemeral Security Contexts for SCIM
Date: 2026-06-12
Last-revised: 2026-07-02 (implementation-status review)
Status
Accepted
Implementation-status review 2026-07-02: implementation confirmed complete
against every §7 Security Invariant and §5/§6 requirement (see §8). Status
moved from Proposed to Accepted. §6.B’s subtle-crate wording corrected to
match what’s actually implemented (Argon2’s own constant-time comparison);
this was a doc/code drift, not a security gap. Added janitor and full-pipeline
integration test coverage (previously mock/unit-test only) per §8.
Security review 2026-06-24:
- F1 MEDIUM: empty authorization list now fails authentication instead of producing an unscoped context (§4 + Invariant 1);
- F2 MEDIUM: §3 Step 2 XFF algorithm aligned with §6.E to prevent IP-allowlist bypass via leftmost-take (Invariant 4);
- F3 LOW:
allowed_ips: Nonesemantics specified as “no restriction” (Invariant 5); - F4 LOW:
compute_deterministic_user_idinput contract specified asclient_id-derived (Invariant 6). New §7 Security Invariants section added.
Context
Machine-to-machine SCIM provisioning integrations utilizing static bearer tokens, bypassing standard Fernet token lifecycle requirements.
Reference
Extends ADR 0017 (Security Context) and ADR 0020 (Unified Mapping Engine).
1. Context & Motivation
Enterprise Identity Providers (IdPs) utilizing the System for Cross-domain
Identity Management (SCIM) protocol generally require a static, long-lived
“Secret Token” passed directly to target API endpoints via the
Authorization: Bearer <Token> HTTP header.
To support SCIM seamlessly without requiring a prior credential exchange at
/v3/auth/tokens, keystone-rs utilizes a specialized ingress adapter capable
of intercepting API keys, verifying them, and constructing a fully valid
SecurityContext in a zero-roundtrip, stateless execution path, compliant with
the Unified Mapping Engine (ADR 0020).
2. Ownership & Storage Model
API Keys are Domain-Owned Machine Identities, strictly decoupled from human user accounts.
A. Keyspace Configuration
| Functional Purpose | Key Namespace Pattern | Value Payload |
|---|---|---|
| API Client Crypto Resource | data:api_client:v1:<domain_id>:<lookup_hash> | ApiClientResource (Struct) |
B. Resource Data Structure
To prevent secret leakage in application error logs, the struct implements a
custom Debug trait that explicitly replaces secret_hash with [REDACTED].
All timestamps are stored as UTC Epoch seconds.
#![allow(unused)]
fn main() {
#[derive(Clone, Serialize, Deserialize)]
pub struct ApiClientResource {
pub domain_id: String,
pub provider_id: String,
pub client_id: String, // Public UUID for management API references
pub lookup_hash: String, // Fast SHA-256 hash of the token for O(1) DB index lookups
pub secret_hash: String, // PHC format Argon2id hash (e.g., $argon2id$v=19$m=65536$...)
pub allowed_ips: Option<Vec<String>>, // None = no IP restriction (any source IP accepted)
pub description: Option<String>,
pub enabled: bool,
pub created_at: i64, // UTC Epoch seconds
pub expires_at: i64, // Mandatory TTL (UTC Epoch seconds)
pub last_used_at: Option<i64>, // UTC Epoch seconds
pub revoked_at: Option<i64>, // Tombstone for audit retention (UTC Epoch seconds)
pub revoked_by: Option<String>, // User ID of the revoking operator
}
}
C. Token Generation & Opaque Formatting
The token consists of a prefix, high-entropy random data, and a CRC32 checksum
for fast format validation. Format:
kscim_{32_bytes_base62_entropy}_{crc32}
When the token is generated:
lookup_hashis computed asSHA-256(entropy)to serve as the database index.secret_hashis computed asArgon2id(entropy)for cryptographic verification.- The
client_id(a standard UUID) is returned to the administrator for CRUD operations but is never embedded in the token or HTTP headers.
3. Execution Flow: The Ingress Pipeline
The middleware processes incoming SCIM requests through a strict, short-circuiting pipeline.
Step 1: Format Check & Hash-Based Rate Limiting
- The middleware computes the CRC32 of the entropy. If it does not match the appended checksum, the request is dropped immediately. (Note: This is strictly a cheap format validity check to reject malformed data, not a cryptographic security boundary).
- The middleware computes the fast
SHA-256(entropy)to derive thelookup_hash. - Rate Limiting: A sliding-window token bucket enforces a strict rate limit
keyed on the
lookup_hash. If the request fails the CRC32 check (meaning no valid entropy exists), the rate limiter falls back to keying on the source IP to absorb brute-force garbage traffic. This ensures legitimate SCIM traffic originating from shared enterprise egress IPs (like Entra ID NAT gateways) is not inadvertently blocked by other tenants.
Step 2: Database Lookup & IP Whitelisting
- The middleware queries FjallDB for
data:api_client:v1:<domain_id>:<lookup_hash>. - It verifies
enabled: trueandcurrent_utc_seconds < expires_at. - It determines the effective client IP using the rightmost non-trusted-proxy
IP algorithm: append the raw TCP peer address to the right of the
configured
trusted_headerchain (X-Forwarded-Forby default, or RFC 7239Forwardedby explicit opt-in), then walk right-to-left, returning the first address that is not in the statically configuredtrusted_proxiesCIDR array. Exactly one header is trusted because each proxy must be configured to strip client-supplied values for that header. If the raw TCP peer is not intrusted_proxies, it is used directly (the headers are not consulted). This prevents leftmost-entry spoofing through untrusted intermediate hops. The resulting effective IP is then validated againstallowed_ipsCIDR blocks. Ifallowed_ipsisNone, the IP check is skipped (no restriction applies).
Step 3: Cryptographic Verification & Lazy Re-Hash
The entropy is verified against the PHC-formatted secret_hash using
tokio::task::spawn_blocking.
- Lazy Re-Hash: If the hash verifies successfully but the PHC string parameters (e.g., memory cost) are lower than the currently configured global minimums, the engine enqueues an asynchronous task to re-hash and update the database record.
last_used_atis updated asynchronously.
Step 4: Ephemeral Context Hydration (Anti-Bleed Scoping)
To prevent cross-domain privilege bleeding, an Ephemeral Security Context must
operate under exactly one scope. API Keys are domain-owned machine
identities (§2); by design only a domain-scoped authorization is accepted.
This is an allowlist – Authorization::Domain is the sole accepted variant
– rather than a denylist naming each forbidden authorization type, so it
also covers any authorization type added in the future.
#![allow(unused)]
fn main() {
pub async fn hydrate_ephemeral_context(...) -> Result<ValidatedSecurityContext, AuthenticationError> {
// Derived exclusively from client_id (the unique key UUID), never from
// provider_id, so each API key produces a distinct audit identity even
// during N:1 rotation periods (§5.D).
let user_id = compute_deterministic_user_id(resource.client_id);
// Initialize strictly as Unscoped.
let mut ctx = SecurityContext::new_ephemeral(IdentityInfo::Principal(PrincipalInfo { user_id }), ScopeInfo::Unscoped);
// Invariant: a key whose UME mapping resolves to zero authorizations MUST
// fail authentication. Returning an unscoped/role-less context would push
// the access decision entirely onto downstream OPA policy coverage.
if match_result.authorizations.is_empty() {
return Err(AuthenticationError::NoAuthorizationsFound);
}
// Enforce Single-Scope Constraint
if match_result.authorizations.len() > 1 {
return Err(AuthenticationError::MultipleScopesForbidden);
}
validate_target_entities_are_active(state, &match_result.authorizations)?;
let authorization = &match_result.authorizations[0];
// System scopes are strictly forbidden for API-Key ingress.
if matches!(authorization, Authorization::System { .. }) {
return Err(AuthenticationError::SystemScopeForbiddenForApiKey);
}
// Allowlist: only a domain-scoped authorization is accepted. API Keys
// are domain-owned machine identities (§2), so anything else --
// `Authorization::Project` included -- is rejected here rather than
// being enumerated as its own forbidden case.
let Authorization::Domain { domain_id, roles } = authorization else {
return Err(AuthenticationError::NonDomainScopeForbiddenForApiKey);
};
ctx.set_scope(ScopeInfo::Domain(domain_id.clone()));
let mut effective_roles = roles.clone();
effective_roles.sort();
effective_roles.dedup();
Ok(ValidatedSecurityContext::finalize(ctx, effective_roles))
}
}
4. Routing & Boundary Enforcement
TLS 1.3 Minimum Floor: Long-lived bearer tokens are functionally equivalent
to passwords. Enforcement is delegated to the infrastructure Reverse Proxy
(Nginx/HAProxy), which is strictly configured to require TLS 1.3 for all
traffic terminating at the /SCIM/v2 paths.
Sub-Router Isolation: The API-Key middleware is mounted exclusively on the SCIM sub-router. Core OpenStack infrastructure endpoints utilize the standard Fernet middleware and will reject API keys outright.
5. Administrative CRUD, Auditing & OPA Policies
A. Privilege Matrix & OPA Integration
Management of API keys relies strictly on defined OPA policies per ADR-0002.
identity:api_key:createidentity:api_key:listidentity:api_key:updateidentity:api_key:revokeidentity:api_key:simulate_access
The DomainManager Role: These policies require the DomainManager role
(or SystemAdmin). DomainManager represents an explicit administrative
capability scoped strictly to managing identities and integrations within a
domain. DomainAdmin must not be used, as it provides overarching
infrastructure privileges. The DomainManager role and its associated policy
mappings must be formally ratified in an upcoming revision to ADR 0002
(OpenStack Policy Engine Integration) to ensure central governance of the RBAC
hierarchy.
Implementation status: The five policies above are implemented under
policy/identity/api_key/ (create.rego, list.rego, update.rego,
revoke.rego, simulate_access.rego; a sixth, show.rego, gates a
GET /v4/api-keys/{client_id} endpoint not explicitly enumerated by this
section but added for consistency with every other v4 resource), each
requiring the pre-existing manager role scoped to the key’s own domain
(this codebase’s realization of DomainManager, used identically by
identity.user.* and identity.mapping.ruleset.*), or admin/is_admin
(SystemAdmin). Unlike identity.user.list, there is no reader carve-out
on identity:api_key:list — all actions sit at the same privilege bar.
ADR 0024’s SCIM resource-CRUD policies (identity/scim/user/*) reuse this same
manager/admin string convention, but resolve it through a materially
different path: those requests are authenticated by an API key, which carries
no Role/RoleAssignment at all, so the manager/admin/scim_provisioner
strings they check are produced entirely by the realm’s own MappingRuleSet
output (§3 Step 4 above), never by a RoleAssignment row. The admin CRUD
surface for API keys/realms documented in this section is the opposite case —
invoked by a Fernet-authenticated human operator, where manager/admin can
be a real RBAC grant.
This does not by itself constitute the formal ADR 0002 ratification called for above (see §8).
B. CRUD Endpoints
POST /v4/api-keys: Generates a new key.GET /v4/api-keys: Lists metadata.PUT /v4/api-keys/{client_id}: Updates configurations.
C. Revocation & Incident Response
POST /v4/api-keys/{client_id}/revoke: Emergency Revocation Path. Setsenabled: false, stampsrevoked_atandrevoked_by, and emits a CADF event (action: revoke). It does not perform a hard delete. This preserves the cryptographic footprint (lookup_hash) and metadata for incident response audits. Physical storage reclamation is deferred to the janitor after the organization’s audit retention period.- Revocation is irreversible via
PUT.ApiKeyApi::updateMUST reject (409 Conflict) any patch that setsenabled: trueon a key whoserevoked_atis set. Without this, an emergency revocation could be undone by an ordinary configuration update, defeating its purpose as an incident-response control (see Invariant 9, §7).
D. Zero-Downtime Key Rotation (N:1 Provider Mapping)
Because the ApiClientResource is indexed by its cryptographic lookup_hash
rather than the provider slug, the architecture natively supports an N:1
relationship between API keys and a provider_id. Multiple distinct keys can
safely declare the same provider_id without database collision.
To execute a zero-downtime rotation, operators generate a new Key B bound to the
existing provider_id. Both Key A and Key B will independently resolve against
the exact same ADR 0020 mapping ruleset
(data:mapping:v1:<domain_id>:<provider_id>). The IdP is updated with Key B,
traffic migrates seamlessly, and Key A is subsequently revoked.
E. The Dry-Run Auditing Endpoint
- Endpoint:
POST /v4/api-keys/simulate-access - Payload:
{"client_id": "<uuid>"}(Shifted to the body to preventclient_idleakage in proxy access logs). - Authentication Required: Strictly requires
DomainManagerorSystemAdmincredentials via a valid Fernet token. - Behavior: Performs a mock authentication pass, returning a fully resolved JSON matrix detailing the API key’s current authorization topology.
6. Threat Model & Required Mitigations
A. Targeted Credential Stuffing & DoS
- Measures: Rate limiting is enforced via a token bucket keyed primarily on
lookup_hashto protect legitimate shared IdP egress IPs. Argon2id verification is constrained to a boundedspawn_blockingpool that sheds load (503 Service Unavailable) if saturated.
B. Argon2id Parameters & Timing Side-Channels
- Measures: Argon2id parameters are globally defined in
keystone.confwith OWASP-compliant strict minimums (e.g., $m=65536, t=3, p=4$). Dummy hashes for invalid tokens utilize these exact parameters. Comparisons against the PHC strings are constant-time, performed internally by theargon2crate’sverify_password(not a separate manualsubtle::ConstantTimeEqstep –verify_passwordalready provides this property, so a second comparison would be redundant).
C. Write-Time is_system Prohibition
- Measures: Allowing an API Key to hold
ScopeInfo::Systemis highly dangerous. To prevent silent failures during auth-time, the prohibition is shifted to rule creation. If aDomainManagerattempts to create or update a mapping rule where theprovider_idbelongs to anIdentitySource::ApiClient, and any authorization grantsis_system: trueorAuthorization::System, the Mapping Engine CRUD API rejects it immediately with422 Unprocessable Entity. The same write-time guard also enforces a domain-scope-only allowlist forIdentitySource::ApiClientrulesets: onceis_system/Authorization::Systemis excluded, every remaining authorization MUST beAuthorization::Domain– API Keys are domain-owned machine identities (§2) – soAuthorization::Project(or any other non-domain authorization) is rejected the same way.
D. OPSEC Leakage & Log Injection
- Measures: 1. The API-key routing prefix allows explicit integration with DLP secret scanners (e.g., GitHub Advanced Security).
- The
client_idis excluded from the token format, neutralizing capability oracle attacks. - The Axum middleware actively scrubs the
Authorizationheader from all internal application traces.
E. Forwarding-Header Spoofing
- Measures: IP allowlisting uses the rightmost non-trusted-proxy IP
algorithm (§3 Step 2): the raw TCP peer is appended to the right of the
configured
trusted_headerchain (X-Forwarded-Forby default; RFC 7239Forwardedrequires explicit opt-in), then the chain is walked right-to-left and the first address not intrusted_proxiesis used as the effective client IP. If the TCP peer is untrusted, the header is not consulted at all. Trusting one explicitly selected header prevents a client-forged alternative header from overriding the chain actually sanitized by the proxy. This also prevents an attacker from bypassingallowed_ipsby routing through an untrusted intermediate proxy that prepends a spoofed originating IP to the forwarding chain before a trusted proxy.allowed_ips: Nonemeans no IP restriction is applied.
F. Janitor Disablement, Asynchronous Drift & Physical Reclamation
- Finding: Asynchronous
last_used_atwrites may occasionally drop under heavy system pressure, causing active integrations to drift toward the 90-day PCI-DSS janitor threshold. Furthermore, tombstoned records from revocations accumulate indefinitely. - Measures: 1. Drift Absorption: The system documents a maximum
acceptable async write staleness of 24 hours. The janitor operates with a
7-day grace period beyond the 90-day threshold, mathematically absorbing this
write-failure window. Before executing a disablement, the janitor emits a
CADF event (
action: disable_inactive) and pushes an administrative alert payload to the system notification bus.
-
Physical Reclamation: To prevent unbounded keyspace bloat, the janitor executes a secondary garbage-collection phase. Any
ApiClientResourcecontaining arevoked_attimestamp older than 365 days is permanently purged from FjallDB. -
Per-key fault isolation: a single key failing its disablement or purge (e.g. a storage CAS conflict with a concurrent admin update) MUST NOT prevent the rest of the sweep pass from running. Failures are counted and logged, and retried on the next pass.
7. Security Invariants
The following invariants MUST hold at all times. Any implementation deviation is a security defect.
-
No-authorizations → authentication failure.
hydrate_ephemeral_contextMUST returnErr(AuthenticationError::NoAuthorizationsFound)when the UME resolves zero authorizations for a key. It MUST NOT produce aValidatedSecurityContextwithScopeInfo::Unscopedand an empty role set. -
Single-scope enforcement. A key MUST NOT authenticate if its UME mapping resolves to more than one authorization entry (
MultipleScopesForbidden). -
System scope prohibited at ingress. An
Authorization::Systemmatch inhydrate_ephemeral_contextMUST returnSystemScopeForbiddenForApiKey. The companion write-time prohibition (§6.C) is defense-in-depth, not a substitute for this runtime check.
3a. Domain scope only, by allowlist. API Keys are domain-owned machine
identities (§2). Once the Invariant 3 system-scope check passes,
hydrate_ephemeral_context MUST accept only Authorization::Domain; any
other authorization (Authorization::Project included) MUST return
NonDomainScopeForbiddenForApiKey rather than resolving a non-domain
ScopeInfo. This is an allowlist keyed on the accepted variant, not a
denylist enumerating each forbidden one, so it also covers any
authorization type added in the future. The companion write-time
prohibition (§6.C) is defense-in-depth, not a substitute for this runtime
check.
-
XFF rightmost-non-trusted algorithm. Effective client IP MUST be the rightmost address in the XFF chain (with TCP peer appended) that is not in
trusted_proxies. Implementations MUST NOT use XFF[0] (leftmost) as the effective IP under any trusted-proxy configuration. -
allowed_ips: Nonemeans unrestricted, not deny-all. Whenallowed_ipsis absent from anApiClientResource, the IP check MUST be skipped entirely. Implementations MUST treat a missing field andSome([])identically (no restriction). -
compute_deterministic_user_idMUST be derived fromclient_id. The ephemeral user_id is computed from the key’s uniqueclient_idUUID, not fromprovider_id. Two distinct keys sharing aprovider_idMUST produce different user_ids so their audit records are not conflated. -
Dummy-hash timing parity. When no
ApiClientResourceis found for a givenlookup_hash, a full Argon2id dummy computation using current global parameters MUST be performed before returning a failure response, preventing timing-based enumeration of valid lookup hashes. -
Argon2id minimum parameters enforced. The parameters embedded in any stored PHC string MUST be validated against configured minimums before accepting a verification as sufficient. Parameters below the floor trigger a lazy re-hash regardless of verification outcome.
-
Revocation is irreversible via the update surface.
ApiKeyApi::updateMUST reject with a conflict error any patch that would setenabled: trueon anApiClientResourcewhoserevoked_atisSome. A revoked key MUST NOT become authenticatable again throughPUT /v4/api-keys/{client_id}; the only way back into service is administratively creating a new key (§5.D covers zero-downtime rotation for exactly this case).
8. Implementation Status
- Done:
-
The SCIM ingress authentication pipeline (§3), including all security invariants (§7).
-
The write-time
is_systemprohibition (§6.C), plus the domain-scope-only allowlist (Invariant 3a): bothhydrate_ephemeral_context(crates/core/src/api/api_key_auth.rs) and the write-time mapping validation (crates/core/src/mapping/validation.rs) accept onlyAuthorization::DomainforIdentitySource::ApiClientrulesets, since API Keys are domain-owned machine identities (§2).Authorization::Projectis rejected as a consequence of the allowlist, not as a named special case. Thesimulate-accessdry-run endpoint (§5.E) mirrors this and reportsmatched: falsefor any non-domain match. -
The storage layer and internal
ApiKeyApi/ApiKeyBackendtraits (§2, §5.D) with a Raft-backed implementation, including the janitor’s cross-domainlist_alland hard-deletepurgeoperations (§6.F). -
Rate limiting (§6.A).
-
The OPA policies for §5.A (
policy/identity/api_key/), plus ashowpolicy for theGET /v4/api-keys/{client_id}endpoint this section does not explicitly enumerate. -
The
/v4/api-keys*HTTP admin surface (§5.B): create, list, show, update.updaterejects (409 Conflict) re-enabling a revoked key (Invariant 9, §5.C), enforced inApiKeyService::update(crates/core/src/api_key/service.rs) so it holds for every caller, not just the HTTP layer. -
The revoke endpoint (§5.C), including a CADF audit event (
action: revoke). -
The dry-run
simulate-accessendpoint (§5.E). Deviates from the literal request shape in one way: the payload also carriesdomain_idalongsideclient_id, because this implementation’s storage partitionsApiClientResourceby domain (§2.A), making aclient_id-only lookup impossible without it. The same constraint applies to show/update/revoke, which takedomain_idas a query parameter rather than encoding it in the (flat, ADR-specified) URL path. It also does not callMappingApi::authenticate_by_mapping– that path may provision a real user row forIdentityMode::Localrules, an unacceptable side effect for a dry-run endpoint – and instead evaluates the ruleset and reads the matchedAuthorization’s roles directly. -
The janitor (§6.F): an in-process, leader-gated
tokio::time::intervalsweep (mirroring the storage crate’s existing emergency-rotation confirmation-timeout sweeper incrates/storage/src/app.rs) that disables keys inactive beyondjanitor_inactive_days+janitor_grace_days, purges tombstones older thanjanitor_tombstone_retention_days, and emits a CADF event (action: disable_inactive) per disablement. Per-key failures are isolated (§6.F.3): one key’s disablement/purge error is logged and counted inJanitorReport::errors, not propagated, so it cannot stall the rest of the pass.Action strings are intentionally more specific than this ADR’s earlier
control/maintenancecategory wording (revoke/disable_inactiverather than a repeated generic label) – more useful for audit filtering; the wording above has been reconciled to match the code rather than the other way around. -
Integration test coverage exercising the real Raft-backed provider and a live HTTP router, not just mocks: a janitor sweep suite (
tests/integration/src/api_key/janitor.rs) covering disablement, tombstone purge, and cross-domain sweeping against the real storage/CAS layer; and a full-pipeline suite (tests/integration/src/api_key/ingress.rs) driving real requests throughopenstack_keystone::scim::router()– successful end-to-end authentication, wrong-secret rejection, an XFF-spoof-through-a-trusted- proxy regression case (Invariant 4), and rate-limit tripping (§6.A).
-
- Known gap: the ADR’s “pushes an administrative alert payload to the
system notification bus” (§6.F) is not implemented – no pub/sub or webhook
dispatch infrastructure exists in this codebase yet. The janitor emits a
structured
warn!log and its CADF event (action: disable_inactive) as the closest existing substitutes; a real notification channel is unbuilt follow-up work, not something to improvise here. - Not yet done: the
DomainManagerrole’s formal ratification in ADR 0002. In the interim, the OPA policies enforce the equivalent scoped privilege using this codebase’s existingmanagerrole (§5.A).
ADR 0022: Handler-Level Rate Limiting via Governor
Date: 2026-06-15
Status
Proposed
Context
The keystone-rs project (Keystone-NG) requires robust rate limiting to protect
against brute-force attacks, credential stuffing, and general API resource
exhaustion. The project currently utilizes the axum web framework for its HTTP
routing and request handling.
While the Rust ecosystem offers several middleware-based rate limiting solutions
(e.g., tower layers or axum-specific wrapper crates), evaluating these
reveals significant drawbacks:
- Maintenance Decay: Many wrapper crates are poorly maintained, tightly coupling our core security infrastructure to abandoned dependencies.
- Inflexible Abstractions: Middleware layers operate before the request reaches the handler. They struggle to apply limits based on complex, request-specific business logic.
- Static Configuration Limitations: Middleware often expects statically compiled limits, whereas operational environments demand dynamically configurable and toggleable rate limits via external configuration files.
- Legacy Compatibility: Configuration must be parsed from the existing
keystone.confINI file to ensure seamless co-existence with the legacy Python Keystone service (oslo.config).
We need a rate-limiting solution that:
- Uses heavily vetted, actively maintained dependencies (
governordirectly). - Allows operators to define quotas or selectively disable limits entirely via the standard INI application configuration file.
- Is explicitly invoked within the
axumhandlers rather than globally. - Emits standardized, RFC-compliant HTTP 429 error responses with
Retry-Afterwithout leaking identifying information in headers.
This ADR complements ADR 0010 (account lockout). ADR 0010 protects against
brute-force by locking the user account after N failed attempts with a fixed
lockout duration governed by conf.security_compliance. Rate limiting per this
ADR operates independently: it throttles requests before they reach password
verification, protecting CPU resources and database load regardless of
authentication outcome. The two mechanisms are additive — rate limiting fires
first, then ADR 0010 lockout fires on repeated failures.
Decision
We will implement rate limiting by utilizing the governor crate directly
within our axum handlers. Rate limiter constraints will be parsed from the
standard keystone.conf INI configuration file at startup. Handlers will
manually evaluate limits, and if a limit is exceeded, they will construct a
standardized HTTP 429 Too Many Requests response containing a Retry-After
header.
1. Configuration-Driven Limits
Rate limit thresholds (burst capacity and replenishment rates) MUST be defined
in the application’s INI configuration file (e.g., keystone.conf). Hardcoding
limits in the application binary is strictly prohibited.
Operators must be able to disable specific governors independently to support varying deployment topologies (e.g., disabling the application-level IP limit if an upstream WAF or Load Balancer already enforces it).
Configuration Schema (INI)
To remain compatible with OpenStack’s oslo.config patterns, rate limits will
be grouped into distinct sections within the keystone.conf file:
[rate_limit_global_ip]
enabled = true
burst_size = 100
replenish_rate_per_second = 10
[rate_limit_user_auth]
# When false, the governor is not instantiated, and handlers bypass this check
enabled = false
burst_size = 5
replenish_rate_per_second = 1
[rate_limit_trusted_proxies]
# Exactly one header trusted proxies sanitize. The default is x_forwarded_for;
# use forwarded only when every trusted proxy strips client-supplied RFC 7239
# Forwarded values before writing its own.
trusted_header = x_forwarded_for
# Comma-separated list of trusted proxy CIDR ranges.
# Leave empty to use the direct peer address (suitable for non-proxied
# deployments). Example: 10.0.0.0/8,192.168.1.0/24
trusted_proxies =
Configuration Bounds
To prevent operators from silently misconfiguring the limiters into an ineffective state, the config parser MUST enforce the following bounds:
| Parameter | Minimum | Maximum |
|---|---|---|
burst_size | 1 | 100,000 |
replenish_rate_per_second | 1 | 100,000 |
Values outside these ranges MUST cause startup failure identically to the
fail-hard rule for replenish_rate_per_second = 0.
State Representation
To support disablement, the injected AppState will wrap the limiters in an
Option. If enabled = false is parsed from the INI config, the application
state stores None for that specific governor, and the handler safely ignores
the check.
If initialization fails (e.g., invalid parameter like
replenish_rate_per_second = 0) and the governor is marked enabled = true,
the application MUST fail to start. Silent fallback to no-limiting on
misconfiguration is a security regression.
#![allow(unused)]
fn main() {
pub type DefaultKeyedRateLimiter<K> = RateLimiter<K, DefaultKeyedStateStore<K>, governor::clock::MonotonicClock>;
pub struct RateLimitState {
pub global_ip_limiter: Option<Arc<DefaultKeyedRateLimiter<String>>>,
pub user_auth_limiter: Option<Arc<DefaultKeyedRateLimiter<String>>>,
}
}
2. Error Response Construction
When a rate limit is exceeded, the application must immediately halt request
processing and return an HTTP 429 Too Many Requests status code. To allow
clients to back off gracefully, the response MUST include specific HTTP headers
calculated from governor’s internal state.
Required Headers
Retry-After: Indicates how many seconds the client must wait before making a new request. This is extracted fromgovernor’sNotUntilerror payload, which calculates the difference between the next allowed cell and the current clock time. This is the only rate-limit-specific header required. No additional headers that might leak identifying information (e.g., the key that triggered the limit) should be included, to avoid user enumeration or PII exposure.
Standardized Evaluation Method
To ensure uniform error formatting across all handlers, limit evaluation is abstracted into a generic helper function.
#![allow(unused)]
fn main() {
use axum::{
http::{HeaderMap, HeaderValue, StatusCode},
response::{IntoResponse, Response},
};
use governor::{clock::Clock, state::keyed::DefaultKeyedStateStore, RateLimiter};
use std::sync::Arc;
pub type DefaultKeyedRateLimiter<K> =
RateLimiter<K, DefaultKeyedStateStore<K>, governor::clock::MonotonicClock>;
/// Evaluates a key against a given governor.
/// Constructs a standardized HTTP 429 Response if the limit is exceeded.
pub fn check_rate_limit<K: Clone>(
limiter: &Arc<DefaultKeyedRateLimiter<K>>,
key: &K,
) -> Result<(), Response> {
limiter.check_key(key).map_err(|not_until| {
// .as_secs() truncates sub-second waits to 0; Retry-After: 0 means
// "retry immediately" per RFC 7231 and causes a thundering herd.
let wait_secs = not_until
.wait_time_from(governor::clock::MonotonicClock::default().now())
.as_secs()
.max(1);
let mut headers = HeaderMap::new();
headers.insert(
"Retry-After",
HeaderValue::from_str(&wait_secs.to_string())
.unwrap_or_else(|_| HeaderValue::from_static("60")),
);
(
StatusCode::TOO_MANY_REQUESTS,
headers,
format!("Rate limit exceeded. Retry in {wait_secs}s"),
)
.into_response()
})
}
}
3. Handler Execution Flow
Handlers execute limit checks based on their specific business logic, gracefully handling cases where a governor has been disabled via the INI configuration.
For unauthenticated endpoints, per-IP rate limiting is the only throttle applied before user lookup. Per-username rate limiting is applied only after the user is confirmed to exist in the database, to prevent key-exhaustion bypass attacks where the attacker crafts an infinite supply of novel usernames, each with its own quota.
The client IP used for per-IP limiting MUST be the originating client address,
not the reverse proxy’s address. resolve_client_ip (see Invariant 9) extracts
the correct IP by consulting exactly the configured trusted_header only when
the direct peer appears in the configured trusted_proxies list.
#![allow(unused)]
fn main() {
pub async fn create_token(
State(state): State<Arc<AppState>>,
ConnectInfo(peer): ConnectInfo<SocketAddr>,
headers: HeaderMap,
Json(payload): Json<AuthPayload>,
) -> Result<Response, StatusCode> {
// 1. IP Check (if enabled in keystone.conf)
// resolve_client_ip checks trusted_proxies config; falls back to peer addr.
// IPv6 addresses are mapped to their /64 prefix before lookup.
if let Some(ip_limiter) = &state.rate_limits.global_ip_limiter {
let client_ip = resolve_client_ip(peer.ip(), &headers, &state.trusted_proxies);
let key = rate_limit_key_for_ip(client_ip);
if let Err(rejection_response) = check_rate_limit(ip_limiter, &key) {
return Ok(rejection_response);
}
}
// 2. Look up the user to verify existence BEFORE applying per-user limits.
// Using the raw username as a rate-limit key before confirming existence
// allows an attacker to craft novel usernames, each with independent quota.
let user = state
.identity_provider
.get_user_by_name(&payload.username, &payload.domain)
.await
.map_err(|_| StatusCode::UNAUTHORIZED)?;
// NOTE: In production this maps only to NotFound; other errors propagate as 500.
// The sample simplifies for brevity.
// 3. User Account Check (if enabled in keystone.conf)
// Applied only after user existence is confirmed.
if let Some(user_limiter) = &state.rate_limits.user_auth_limiter {
if let Err(rejection_response) = check_rate_limit(user_limiter, &user.id) {
return Ok(rejection_response);
}
}
// ... Proceed with heavy bcrypt/argon2 hashing and database verification ...
}
}
4. Security Invariants
Any code change violating the following is rejected at review:
- No Hardcoded Limits:
burst_sizeandreplenish_rateMUST originate from thekeystone.confINI configuration state. - Fail-Hard Initialization: If a governor is configured with
enabled = truebut cannot be initialized (e.g., invalid parameters such asreplenish_rate_per_second = 0), the application MUST fail to start. Silent fallback to no-limiting is a security regression. - Response Uniformity: The HTTP 429 response body and headers MUST be
identical regardless of which governor tripped, to prevent additional
disclosure channels beyond the status code itself. The 429 response MUST
include a correctly calculated
Retry-Afterheader. No additional headers that expose the rate-limit key or any identifying information should be present, to prevent user enumeration via rate-limit probing. - Pre-Hash Enforcement: Rate limit checks for authentication endpoints MUST be executed before any CPU-intensive operations (e.g., Argon2id/Bcrypt password verification) are triggered.
- Distinct Buckets: Different operational contexts MUST use physically
separate
RateLimiterinstances in the application state. Multiplexing different entity types into the same keyed store is prohibited. - Monotonic Clock: Rate limiters MUST use
governor::clock::MonotonicClockto prevent NTP backward shifts from resetting quota windows. - Key Normalization: Any key derived from user input (e.g., username) MUST
be normalized before being used as a rate-limit key. Normalization MUST
apply, in order: NFKC Unicode normalization, case-folding to lowercase, and
any additional canonicalization applied by the authentication logic (e.g.,
stripping a trailing
@). Omitting NFKC allows Unicode homoglyph variants (e.g., Cyrillicаvs. Latina) to create distinct buckets for the same logical identity. The normalization function MUST be a single shared utility used by both the rate limiter and the authentication pipeline. - Post-Lookup User Throttle: For unauthenticated endpoints, per-IP rate limiting is the primary throttle applied before any database lookup. Per-user rate limiting MUST only be applied after the user is confirmed to exist. This prevents key-exhaustion bypass attacks where an attacker crafts novel usernames, each with independent quota.
- Trusted Proxy Source IP: The IP address used as a rate-limit key MUST be
the originating client IP, not the address of any intermediate reverse proxy.
When
trusted_proxiesis non-empty, the client IP MUST be extracted from the first untrusted hop in exactly the configuredtrusted_header(X-Forwarded-Forby default; RFC 7239Forwardedby explicit opt-in), and only when the direct peer IP falls within a configured trusted CIDR range. Whentrusted_proxiesis empty, the direct peer address MUST be used. Accepting both headers implicitly, or accepting a header from an untrusted peer, allows a client to spoof its apparent IP and defeat per-IP limiting. - Minimum
Retry-After: TheRetry-Afterheader value MUST be at least 1 second. Sub-second wait durations MUST be rounded up to 1. A value of 0 is valid per RFC 7231 (“retry immediately”) and causes a thundering herd when many clients receive it simultaneously.
Consequences
- Per-node limits in scaled deployments:
governoruses in-memoryDefaultKeyedStateStore. Eachkeystone-rspod maintains its own counter. In a deployment with N instances behind a load balancer, the effective rate limit is N times the configured value. Operators should either configure limits per-node or migrate to a shared-state store (e.g., Redis-backed) for cluster-wide enforcement in the future. - IPv6 rate limiting via prefix aggregation: IPv6 privacy extensions
randomize source addresses per connection, making raw per-address limiting
ineffective. Per-IP rate limiting defaults to aggregating IPv6 addresses by
their
/64network prefix, which corresponds to the subnet allocated to a single host’s privacy extensions. Per-/128precision can be enabled by the operator but is not the default. IPv4 addresses are rate-limited per-/32. - Memory overhead and store eviction: The keyed state store retains an entry
per unique key. Under adversarial conditions (unique-key flooding), entries
must be aggressively pruned. The store MUST:
- Cap at a configurable maximum entry count (default: 10,000).
- Be trimmed every 60 seconds via a background task using LRU eviction: when the cap is reached, the least-recently-seen entry is evicted to make room for the new key. LRU is preferred over fail-open (dropping new keys) because fail-open degrades rate limiting for all users simultaneously once the store is full — including legitimate users who are not flooding — whereas LRU preserves quotas for actively-authenticating sessions.
- The global IP limiter remains effective as a backstop regardless of per-user store state.
- Clock source:
DefaultClockusesSystemTime, which can be adjusted backward by NTP drift, resetting quota windows. UsingMonotonicClock(required by Invariant 6) prevents this. The tradeoff is thatMonotonicClockhas no notion of wall-clock time, so quota replenishment cannot be aligned to calendar boundaries (e.g., daily resets). For security rate limiting this is desirable — an operator cannot accidentally reset attacker quotas by changing the system clock. - Trusted proxy configuration gap: Operators deploying behind a reverse
proxy who leave
trusted_proxiesempty will rate-limit by the proxy’s IP instead of the client’s, meaning all traffic shares a single bucket and the global IP limiter trips almost immediately under normal load. Operators MUST configuretrusted_proxiesto match their load balancer’s CIDR(s) before enabling the global IP limiter in proxied deployments. The startup log SHOULD emit a warning whenglobal_ip_limiteris enabled andtrusted_proxiesis empty, to surface the misconfiguration early. - Key length: Extremely long input values (e.g., a 64 KB username) would be inserted into the rate-limit HashMap as-is, consuming memory before any limit is applied. Input length MUST be validated at the API boundary (e.g., reject usernames longer than 255 bytes with HTTP 400) before the rate-limit key is derived. This check belongs to the authentication handler, not to the rate limiter itself.
- Key normalization: Per-user rate-limit keys MUST be normalized before
lookup. This means NFKC Unicode normalization, case-folding, stripping
trailing
@, and any other canonicalization applied by the authentication logic. Without NFKC, Unicode homoglyph variants produce distinct keys for the same logical identity, each with independent quotas. The normalization function MUST be a single shared utility to ensure consistency between the rate limiter and the authentication pipeline. - User enumeration trade-off: Per-user rate limiting inherently enables user
enumeration: if an attacker sends enough requests for
aliceto trip the limit (429), but requests foralice_nonexistentreturn401, the attacker learns thataliceis a valid account. This is mitigated by this ADR’s decision to apply per-user limiting only after user lookup (Invariant 8). Since the lookup already occurs, the enumeration risk is acceptable; the IP limit remains the primary throttle for unknown accounts. - HashMap timing side channel: The
DefaultKeyedStateStoreperforms hash-map lookups per key, which vary in execution time depending on whether the key exists (insert vs. update path). This timing difference can enable user enumeration independently of the 429 status code. This is inherent to the data structure and reinforces the decision to use IP-keyed limiting as the primary throttle for unauthenticated flows, where the timing channel is less actionable (IPs are not secrets). - Federation and application credential coverage: Rate limiting on
POST /v3/auth/tokensdoes not automatically cover federation endpoints (OS-FEDERATION/identity_providers/{id}/protocols/{protocol}/authenticate,OS-FEDERATION/protocols/{protocol}/authenticate), application credential flows, or token-based operations. These endpoints perform cryptographic verification and are CPU-intensive, making them denial-of-service targets. A follow-up ADR (tracking issue: TBD) will extend handler-level rate limiting to these endpoints with appropriate IP-based governance. - New dependency: The
governorcrate must be added to the workspaceCargo.toml. It is actively maintained with no known security advisories. - Relationship with ADR 0010 (account lockout): Rate limiting provides a first-layer defense by throttling all requests. ADR 0010 provides a second layer by locking the account after sustained failed authentication. Both mechanisms are independent and additive. A rate-limited request never reaches the lockout logic, reducing database writes for failed-auth counters.
23. CADF-Compliant Phased Auditing Architecture
Date: 2026-06-16
Status
Accepted
Security review 2026-06-24: Seven findings applied — HMAC canonicalization (RFC 8785/JCS),
boot_session_idCSPRNG requirement,initiator.hostsanitization rules,refresh_hmac_keyversion-collision fix, HMAC key retention policy, cross-node spool tamper detection, andmap_event_to_actiondangling-reference correction.
Context
For feature parity with OpenStack’s pycadf, our Axum/Tonic application
requires CADF-compliant auditing - capturing both perimeter ingress and
business-layer mutations. Our Rust architecture enforces that a security context
cannot be used for policy enforcement before it is fully resolved, using an
externally immutable ValidatedSecurityContext with read-only getters. This
design prevents PII leaks while maintaining zero-trust guarantees.
Decision
We implement a hybrid auditing architecture producing standardized CADF events, dispatched asynchronously to configurable sinks across three phases: Framework, Perimeter (Extractor + Middleware), and Provider (Hooks).
Phase 1: General Audit Framework & CADF Types
Strict Rust representation of the CADF standard and async dispatch machinery.
The CADF Payload: To ensure unsigned events cannot serialize, CadfEvent
wraps a private CadfEventPayload and signature via serde(flatten).
#![allow(unused)]
fn main() {
#[derive(Serialize, Deserialize, Clone)]
pub struct CadfEventPayload {
id: String,
seq: u64,
boot_session_id: String,
hmac_key_version: u64,
version: String,
domain: String,
correlation_id: String,
event_time: String,
action: String, outcome: String,
outcome_reason: Option<String>,
initiator: Initiator,
target: Target, observer: Observer,
}
impl CadfEventPayload {
fn sign(self, dispatcher: &AuditDispatcher) -> CadfEvent {
dispatcher.finalize_event(self)
}
/// Internal test-tooling only — NOT the SIEM verification path.
/// External SIEMs MUST implement verification by: (1) parse received JSON,
/// (2) remove the `signature` key, (3) serialize the remainder in JCS
/// canonical form (RFC 8785), (4) compute HMAC-SHA256 with the key
/// identified by `hmac_key_version`. Cross-language test vectors
/// (tests/audit/hmac_vectors.jsonl) cover this exact path.
fn from_cadf(evt: &CadfEvent) -> Self {
let e = evt.payload();
Self {
id: e.id.clone(), seq: e.seq, boot_session_id: e.boot_session_id.clone(),
hmac_key_version: e.hmac_key_version, version: e.version.clone(),
domain: e.domain.clone(), correlation_id: e.correlation_id.clone(),
event_time: e.event_time.clone(), action: e.action.clone(),
outcome: e.outcome.clone(), outcome_reason: e.outcome_reason.clone(),
initiator: e.initiator.clone(), target: e.target.clone(),
observer: e.observer.clone(), }
}
}
#[derive(Serialize, Deserialize, Clone)]
pub struct CadfEvent {
#[serde(flatten)]
event: CadfEventPayload,
signature: String,
}
impl CadfEvent {
pub fn payload(&self) -> &CadfEventPayload { &self.event }
pub fn signature(&self) -> &str { &self.signature }
pub fn correlation_id(&self) -> &str { &self.event.correlation_id }
pub fn id(&self) -> &str { &self.event.id }
pub fn seq(&self) -> u64 { self.event.seq }
pub fn boot_session_id(&self) -> &str { &self.event.boot_session_id }
}
#[derive(Serialize, Clone)]
pub struct Initiator {
id: String, project_id: Option<String>, domain_id: Option<String>,
/// Pre-auth signal (EC2 access key, federation idp_id). No PII.
/// Content arrives before authentication and is fully attacker-controlled.
/// Sanitization rules (enforced at construction, not by Initiator itself):
/// EC2 access key — must match /^AKIA[A-Z0-9]{16}$/; rejected otherwise.
/// Federation idp_id (UUID) — passed through sanitize_audit_id().
/// Federation idp_id (non-UUID) — filtered to [a-zA-Z0-9._-], max 64 chars.
/// Any other value — filtered to printable ASCII (0x20–0x7E), max 128 chars.
/// Field is omitted (None) if empty after filtering.
host: Option<String>,
}
impl Initiator {
fn new(id: String, project_id: Option<String>, domain_id: Option<String>,
host: Option<String>) -> Self
{ Self { id, project_id, domain_id, host } }
pub fn id(&self) -> &str { &self.id }
pub fn project_id(&self) -> Option<&str> { self.project_id.as_deref() }
pub fn domain_id(&self) -> Option<&str> { self.domain_id.as_deref() }
}
#[derive(Serialize, Clone)]
pub struct Target { pub id: String, pub type_uri: String }
#[derive(Serialize, Clone)]
pub struct Observer { pub node_id: String, pub id: String }
}
VerifiedFernetToken - Opaque wrapper only constructible post-verification.
The _score: NonZeroU32 constructor guard proves crypto validation passed.
#![allow(unused)]
fn main() {
pub struct VerifiedFernetToken(FernetToken);
impl VerifiedFernetToken {
pub(crate) fn from_verified(token: FernetToken,
_score: std::num::NonZeroU32) -> Self { Self(token) }
pub fn user_id(&self) -> &str { self.0.user_id() }
pub fn domain_id(&self) -> Option<&str> { self.0.domain_id() }
}
}
ID sanitization - Strips non-ASCII, caps at 64 chars:
#![allow(unused)]
fn main() {
fn sanitize_audit_id(id: &str) -> String {
if id.trim().is_empty() { return "unknown".to_string(); }
let cleaned: String = id.chars()
.filter(|c| c.is_ascii_hexdigit() || *c == '-')
.take(64).collect();
if cleaned.is_empty() { return "unknown".to_string(); }
// Strict UUID check: len 36, 4 hyphens at canonical positions, 32 hex digits.
// Reject any non-UUID format — prevents crafted ID bypass.
if cleaned.len() == 36
&& cleaned.chars().filter(|c| *c == '-').count() == 4
&& cleaned.chars().filter(|c| c.is_ascii_hexdigit()).count() == 32
&& cleaned.get(8..9) == Some("-")
&& cleaned.get(13..14) == Some("-")
&& cleaned.get(18..19) == Some("-")
&& cleaned.get(23..24) == Some("-") {
cleaned
} else { "unknown".to_string() }
}
}
The Audit Dispatcher - Dual-channel QoS with atomic HMAC rotation:
#![allow(unused)]
fn main() {
pub struct AuditDispatcher {
perimeter_sender: mpsc::Sender<CadfEvent>, // 4096, best-effort
critical_sender: mpsc::Sender<CadfEvent>, // 256, fail-closed
node_id: Arc<str>,
hmac_key_and_version: ArcSwap<(Arc<[u8]>, u64)>,
boot_session_id: String,
seq_counter: AtomicU64,
dropped_count: Arc<AtomicU64>,
last_drop_log_time: AtomicU64,
log_baseline: std::time::Instant,
postaudit_dropped_count: Arc<AtomicU64>,
events_total: Arc<AtomicU64>, // total events dispatched (perimeter + critical)
}
impl AuditDispatcher {
// Signs over unsigned payload. HMAC input is the JCS-canonical (RFC 8785)
// UTF-8 JSON of all payload fields with keys in lexicographic order, no
// extra whitespace. Most Option-typed fields serialize as `null` when
// absent. Exception: `Initiator.host` uses skip_serializing_if and is
// **omitted entirely** (not set to `null`) when absent. SIEMs MUST
// re-serialize the received JSON (minus `signature`) without inserting
// absent keys. This is the sole canonical form for HMAC verification.
fn finalize_event(&self, partial: CadfEventPayload) -> CadfEvent {
let (key, version) = self.hmac_key_and_version.load_full().as_ref();
let completed = CadfEventPayload {
seq: self.seq_counter.fetch_add(1, Ordering::SeqCst),
boot_session_id: self.boot_session_id.clone(),
hmac_key_version: **version, ..partial };
let sig = compute_hmac_sha256(&completed, &**key);
CadfEvent { event: completed, signature: sig }
}
/// Best-effort: drops if full. Floor-rate log: at least once/sec.
pub fn dispatch(&self, event: CadfEvent) {
self.events_total.fetch_add(1, Ordering::Relaxed);
let cid = event.correlation_id().to_string();
if self.perimeter_sender.try_send(event).is_err() {
let count = self.dropped_count.fetch_add(1, Ordering::Relaxed);
let now_us = self.log_baseline.elapsed().as_micros() as u64;
let should_log = (count % 1024) == 0
|| (self.last_drop_log_time.load(Ordering::Relaxed) + 1_000_000)
<= now_us;
if should_log {
self.last_drop_log_time.store(now_us, Ordering::Relaxed);
error!(dropped_count = count, correlation_id = %cid,
"audit channel full, event dropped (best-effort)");
}
}
}
/// Fail-closed: blocks until sent.
pub async fn dispatch_critical(&self, event: CadfEvent)
-> Result<(), AuditChannelDead>
{
self.events_total.fetch_add(1, Ordering::Relaxed);
self.critical_sender.send(event).await.map_err(|_| AuditChannelDead)
}
/// MUST be called from a single serialized context (dedicated key-rotation
/// task). Concurrent invocations produce version collisions (two different
/// keys share the same version), breaking SIEM verification.
pub(crate) fn refresh_hmac_key(&self, new_key: Arc<[u8]>, new_version: u64) {
self.hmac_key_and_version.store(Arc::new((new_key, new_version)));
}
}
}
Spooling & Replay: Workers drain channels to sinks. On shutdown, unsent critical events spool. On startup, HMAC-verified and replayed. Corrupted lines are skipped to recover adjacent valid events; file quarantined at end.
boot_session_id: MUST be a UUIDv4 generated from the OS CSPRNG at
process startup, before any request handling. MUST NOT be derived from a
wall-clock timestamp, PID, or any predictable value. It is never persisted;
its sole purpose is to namespace the seq counter within a single process
lifetime. SIEMs MUST partition seq-gap detection by
(node_id, boot_session_id) to avoid false gap alerts across restarts.
Phase 2: Perimeter Auditing (Ingress & Completion)
Captures access attempts at the boundary.
-
Ingress (
AuthExtractor): The Axum middleware already injects arequest-idheader (UUIDv4). If the client already sent arequest-idheader, the middleware must strictly ignore its value and overwrite it with a fresh UUIDv4 (prevent client-controlled correlation spoofing).correlation_idis derived from this header value, not from the request token. ExtractsInitiatorfrom fully resolvedValidatedSecurityContext, token parse failure:outcome: "failure",Initiatoris all"unknown"(no partial data from untrusted payload). On partial validation failure (token parsed but policy/scope failed): usesVerifiedFernetTokenfromvsc.verified_token()to extract sanitized initiator. For endpoints with pre-auth identity signals (EC2accesskey, federationidp_id), include non-PII identifiers asinitiator.hostor a custom attachment — these don’t require a validated context and don’t risk PII leakage. -
Completion (Middleware): Post-handler extracts
CorrelationIdandReadOnlyInitiator, emits event with HTTP status mapped to outcome.
Error sanitization - Exhaustive match prevents silent PII leakage:
#![allow(unused)]
fn main() {
fn error_variant_name(error: &KeystoneApiError) -> String {
match error {
KeystoneApiError::Unauthorized { source, .. }
| KeystoneApiError::Forbidden { source, .. } => {
source.downcast_ref::<AuthenticationError>()
.map(|e| sanitize_authentication_error(e).to_string())
.unwrap_or_else(|| "Unauthorized".to_string())
}
KeystoneApiError::NotFound { .. } => "NotFound".to_string(),
KeystoneApiError::Conflict { .. } => "Conflict".to_string(),
KeystoneApiError::BadRequest { .. } => "BadRequest".to_string(),
KeystoneApiError::RateLimited { .. } => "RateLimited".to_string(),
KeystoneApiError::Gone { .. } => "Gone".to_string(),
KeystoneApiError::InternalServerError => "InternalServerError".to_string(),
KeystoneApiError::ServiceUnavailable => "ServiceUnavailable".to_string(),
KeystoneApiError::GatewayTimeout => "GatewayTimeout".to_string(),
e => e.type_name().unwrap_or("UnknownError").to_string(),
}
}
fn sanitize_authentication_error(e: &AuthenticationError) -> &'static str {
match e {
AuthenticationError::DomainDisabled(_) => "DomainDisabled",
AuthenticationError::ProjectDisabled(_) => "ProjectDisabled",
AuthenticationError::TrustorUserDisabled(_) => "TrustorUserDisabled",
AuthenticationError::UserDisabled(_) => "UserDisabled",
AuthenticationError::UserLocked(_) => "UserLocked",
AuthenticationError::UserPasswordExpired(_) => "UserPasswordExpired",
AuthenticationError::Provider { source, .. } => {
extract_provider_name(source).unwrap_or("ProviderError")
}
AuthenticationError::Validation(_) => "ValidationError",
AuthenticationError::StructBuilder { .. } => "StructBuilderError",
AuthenticationError::TokenExpired(_) => "TokenExpired",
AuthenticationError::TokenRevoked(_) => "TokenRevoked",
AuthenticationError::AuthCredentialNotFound(_) => "AuthCredentialNotFound",
AuthenticationError::AuthCredentialExpired(_) => "AuthCredentialExpired",
AuthenticationError::AuthCredentialMalformed(_) => "AuthCredentialMalformed",
AuthenticationError::PrincipalNotUnique(_) => "PrincipalNotUnique",
AuthenticationError::InvalidAuthMethod(_) => "InvalidAuthMethod",
}
}
/// Type-only dispatch: no provider error string content is used. Guarantees
/// PII in third-party provider errors (emails, tokens) never reaches audit.
fn extract_provider_name(source: &Box<dyn std::error::Error>)
-> Option<&'static str>
{
if source.is::<identity::IdentityProviderError>() { Some("Identity") }
else if source.is::<catalog::CatalogProviderError>() { Some("Catalog") }
else if source.is::<role::RoleProviderError>() { Some("Role") }
else if source.is::<assignment::AssignmentProviderError>() { Some("Assignment") }
else { None }
}
}
Semantic action mapping - Hardcodes v3/v4 paths, sanitizes
Operation::Other:
#![allow(unused)]
fn main() {
fn map_event_to_action(event: &Event) -> String {
match &event.operation {
Operation::Create => "create".to_string(),
Operation::Update => "update".to_string(),
Operation::Delete => "delete".to_string(),
Operation::Disable => "disable".to_string(),
Operation::Enable => "enable".to_string(),
Operation::Authenticate => "authenticate".to_string(),
Operation::Revoke => "revoke".to_string(),
Operation::Other(action) => {
// Sanitize: ASCII alphanumeric + /, -, _; cap 64 chars; reject empty.
let s: String = action.chars()
.filter(|c| c.is_ascii_alphanumeric()
|| *c == '-' || *c == '_' || *c == '/')
.take(64)
.collect();
if s.is_empty() { "unknown".to_string() } else { s }
}
}
}
}
Phase 3: Provider Auditing via Context-Aware Hooks
ProviderHooks (on_event) is fire-and-forget without context. Instead,
AuditHook receives context and outcome, dispatched inline with fail-closed
semantics. Reentrancy prevented via tokio::task_local!.
#![allow(unused)]
fn main() {
pub trait AuditHook: Send + Sync {
async fn on_auditable_event(&self, ctx: &ValidatedSecurityContext,
event: &Event, outcome: &AuditOutcome) -> Result<(), AuditDispatchError>;
}
pub enum AuditOutcome { Attempt, Success, Failure { reason: String } }
/// Sanitize hook error to stable literal. Prevents {:?} debug formatting
/// from leaking type names or internal diagnostics into CADF outcomes.
/// Hook errors only abort the provider op; they never flow into outcome_reason.
pub enum AuditDispatchError {
DispatcherDead,
HookFailed { description: &'static str },
Reentered,
}
impl EventDispatcher {
/// Fail-closed pre-audit: any hook error aborts the provider operation.
/// Collects hook errors (except DispatcherDead short-circuits).
pub async fn emit_critical(&self, ctx: &ValidatedSecurityContext,
event: &Event, outcome: &AuditOutcome) -> Result<(), AuditDispatchError>
{
let is_reentered = EMIT_CRITICAL_RECURSION.try_with(|v| *v).unwrap_or(false);
if is_reentered { return Err(AuditDispatchError::Reentered); }
EMIT_CRITICAL_RECURSION.scope(true, async move {
let audit = self.audit_hooks.lock().await.values().cloned().collect::<Vec<_>>();
let mut error_count = 0u64;
for hook in &audit {
match hook.on_auditable_event(ctx, event, outcome).await {
Err(AuditDispatchError::DispatcherDead) =>
return Err(AuditDispatchError::DispatcherDead),
Err(AuditDispatchError::HookFailed { .. }) => error_count += 1,
Err(AuditDispatchError::Reentered) => error_count += 1,
Ok(()) => {}
}
}
if error_count > 0 {
return Err(AuditDispatchError::HookFailed {
description: "hook execution failed",
});
}
// ... fire-and-forget regular hooks ...
Ok(())
}).await
}
}
}
Audit-Before-Commit (Fail-Closed Transaction Safety):
#![allow(unused)]
fn main() {
macro_rules! audited_op {
( dispatcher: $dispatcher:expr, ctx: $ctx:expr, event: $event:expr,
operation: $op:expr, error_variant: $err_variant:path ) => {{
let event = $event;
// Pre-audit (Attempt): fails if dispatcher dead
$dispatcher.emit_critical($ctx, &event, &AuditOutcome::Attempt).await
.map_err(|e| $err_variant { source: e })?;
let result = $op.await;
// Post-audit: use emit_critical. If channel full, write compensating
// local JSONL log to guarantee dual-delivery path to SIEM.
let outcome = match &result {
Ok(_) => AuditOutcome::Success,
Err(e) => AuditOutcome::Failure {
reason: error_variant_name(e).to_string() }
};
if $dispatcher.emit_critical($ctx, &event, &outcome)
.await.is_err()
{
// Fallback: local compensating log (structured JSONL, independent ship)
// Includes operation and resource ID for forensic SIEM lookup.
error!(
correlation_id = %$ctx.correlation_id().to_string(),
outcome = ?outcome,
event_operation = ?$event.operation,
event_resource = ?$event.payload,
"post-audit channel full — compensating local log written"
);
$dispatcher.postaudit_dropped_count.fetch_add(1, Ordering::Relaxed);
}
result
}};
}
}
Provider usage:
audited_op! { dispatcher: ..., ctx: ..., event: ..., operation: ..., error_variant: ProviderError::AuditDispatchFailed }
CADF Hook: Single CadfAuditHook translates events to CADF, signs via
CadfEventPayload::sign(), dispatches via dispatch_critical(). Wired at
startup: state.event_dispatcher.subscribe_audit(CadfAuditHook).await.
Security Compliance vs. PII Requirements
- Data Minimization:
Initiatorhas only UUIDs. Human-readable fields (usernames, emails) are excluded by design. - PII Redaction:
username,display_name,email_address,project_name,domain_nameexcluded. Future fields require opaque wrappers. - Outcome Isolation:
outcome_reasonlimited to sanitized variant name. - HMAC Signing:
CadfEventwraps(CadfEventPayload, signature)viaserde(flatten). Private fields prevent unsigned construction. Per-node signing key derived via:
TheHKDF-Expand(KEK, info="keystone-audit-hmac-v1:{node_id_utf8}", L=32)node_idsuffix ensures each node holds a distinct signing key; a compromised node cannot forge audit records attributed to other nodes. This aligns with ADR 0016-v2 §3.1 (which usesnode_id_u64_befor Raft nodes; here we use the UTF-8 encoding of the string node ID). HKDF-Expand-only is used because the KEK is already uniformly random (Extract is a no-op security-wise). Key+version asArcSwap<(Arc<[u8]>, u64)>for atomic rotation (ADR 0016-v2 §6.2). HMAC input is the JCS-canonical (RFC 8785) serialization of the payload (all fields, lexicographically sorted keys, compact, null fields included). - HMAC Key Retention: The KEK store MUST retain all HMAC key versions for
at least
max(spool_drain_timeout + SIEM_lag_budget, 24h). Key versions are monotonically increasing and permanent — never reused. The version number is supplied by the key-rotation task (not derived byrefresh_hmac_keyitself) to prevent version collisions under concurrent rotation attempts. SIEMs MUST cache all key versions seen and MUST NOT delete them without operator confirmation. - Spool Integrity: Corrupted/tampered lines skipped (recovery-first).
Per-node spool path (
audit-spool-{node_id}.jsonl) eliminates shared-file races; advisory lock as secondary guard. Because the HMAC key is per-node (node_id is bound into the key derivation), the SIEM can reject events whoseobserver.node_iddoes not match the key used to verify their signature; mismatches MUST be quarantined as tamper indicators, not silently accepted. - Delivery Guarantee: At-least-once delivery. SIEMs must deduplicate on
CadfEvent.id(uniquenode_id:uuid). - Attempt Reconciliation: SIEM treats an
Attemptwith no correspondingSuccess/Failurewithin 300s asoutcome: unknownand triggers a warning alert. Loki query:sum_over_time({app="keystone"} | cadf_outcome="attempt" [10m]) - sum_over_time({app="keystone"} | cadf_outcome=~"success|failure" [10m]) > 0 - Verified Token Boundary:
build_initiator_from_error()accepts onlyVerifiedFernetToken(constructible post-verification via_score). Partial context failure = authorization issue, not crypto issue. - Provider Error Sanitization:
extract_provider_nameuses type-only dispatch (is::<T>()). No error string content used.
Observability
dropped_count / postaudit_dropped_count exported as Prometheus gauges:
groups:
- name: keystone_audit
rules:
- alert: KeystoneAuditDropsVolumetric
expr: |
rate(keystone_audit_dropped_total[5m]) > 100 and
rate(keystone_audit_dropped_total[5m]) /
rate(keystone_audit_events_total[5m]) > 0.05
for: 2m
labels: { severity: critical }
annotations:
summary: "Audit drops >100/s (>5% of perimeter events)"
description:
"Possible volumetric attack. Check rate limiting (ADR 0022)."
- alert: KeystoneAuditPostauditDrops
expr: |
increase(keystone_audit_postaudit_dropped_total[5m]) > 0 and
rate(keystone_audit_events_total[5m]) > 0
for: 1m
labels: { severity: critical }
annotations:
summary: "Post-audit outcome record lost after DB commit"
description:
"The outcome record (Success/Failure) for a high-criticality op
(disable_user, delete_credential) was dropped. The pre-audit Attempt
exists, but the final outcome is lost. Compensating local log
entries (structured JSONL) should be independently shipped to the
SIEM for dual-delivery."
Related ADRs
- 0016-v2: HMAC key from KEK. KEK rotation calls
refresh_hmac_key(). - 0017:
ValidatedSecurityContextin hooks.correlation_id(),verified_token()for partial context. - 0020: Mapping engine errors sanitized via
error_variant_name(). - 0022: Rate-limiting. 429 produces
outcome: "client_error". Audit drop alerts correlated with rate limiter health.
Alternatives
- Provider wrapper traits: Rejected — 30+ enum expansions.
AuditHookis a single subscription point. - Mutable context propagation: Rejected — Rust ownership enforces integrity.
- Single-channel dispatch: Rejected — dual channels provide QoS isolation.
- Post-serialization signing: Rejected — two-phase builder prevents unsigned-in-channel window.
Accepted Risk: Millisecond Durability Gap
dispatch_critical() returns Ok(()) after placing the signed event into the
in-memory mpsc channel (256 depth). If the node hard-crashes (power loss,
kernel panic) before the background worker drains the event to the spool file,
that signed event is lost from RAM. The DB transaction that triggered the event
has already committed, so the audit trail has a gap.
Why synchronous fsync is rejected: Adds ~10-50ms latency per critical provider operation. At Keystone scale (thousands of ops/sec), this causes SLO violation. A per-event WAL is equally expensive. The in-memory channel provides ordering without blocking the provider call path.
Mitigations: Graceful shutdown (SIGTERM) drains the full channel (10s budget) before exit — zero events lost. Spool replay covers process restarts via graceful shutdown handlers. Compensating local logs for post-audit drops provide dual delivery for high-criticality ops.
Risk acceptance: The millisecond crash window trades an extremely rare single-event loss for guaranteed sub-1ms provider latency. This aligns with OpenStack’s design philosophy: audit is advisory for SIEM compliance, not a hard transactional requirement.
Consequences
- Security: Two-event perimeter + fail-closed provider audit ensures
complete coverage. Post-audit uses
emit_criticalwith compensating local log fallback for dual-delivery.KeystoneAuditPostauditDropsalert is critical. - Performance: Dual channels isolate perimeter (4096, best-effort) from critical (256, fail-closed). Not a substitute for rate limiting (ADR 0022).
- Correctness: Correlation IDs link perimeter through provider events.
- Integrity: Two-phase builder, sanitized error names, monotonic seq.
- Shutdown: 10s drain timeout, disk spool. Up to 4096 perimeter events may be lost. Corrupted spools quarantined for forensic triage.
24. SCIM v2 Resource Provisioning: Multi-Realm Identity Lifecycle per Domain
Date: 2026-07-01
Last-revised: 2026-07-08 (PR1+PR2+PR3+PR4+PR5 implementation note)
Status
Proposed
Implementation note (2026-07-06): PR1 (Realm Foundation), PR2 (Users
vertical slice), PR3 (Groups + membership isolation), and PR4 (Protocol
Surface Completion — filter grammar, PATCH, ETags, discovery endpoints)
have landed. During implementation, the realm/user identity design was
refined beyond what this ADR originally specified: ScimRealmResource gained
a mandatory idp_id link to a federation IdentityProvider, and a
SCIM-provisioned User’s id is derived deterministically rather than
server-assigned, so it converges with a later federated JIT login for the
same person instead of producing a duplicate account. §2.A and §4 below have
been updated to match the as-built behavior; the “Rejected alternative”
callout in §4 is revised accordingly. Group, by contrast, keeps a normal
server-assigned id and an optional externalId — nothing federates in as
a Group, so there is no convergence hazard a deterministic id would solve.
§5’s filter/PATCH/ETag design matches the as-built PR4 behavior as written,
with one addition worth noting here: closing the ETag CAS guarantee (§5.E)
required fixing a latent bug in the storage driver’s compare-and-swap path
that predates PR4 (a concurrent-write violation was detected by the store but
never surfaced to the caller) — ScimResourceIndex.version now bumps on
every PUT/PATCH, not only ones that change externalId, so the ETag is
meaningful on every write.
Implementation note (2026-07-08): PR5 (Janitor Purge Phase, §6.C) has
landed, matching the as-written design with one naming deviation: the
retention config lives at [scim_resource] janitor_deprovisioned_retention_days
(default 365) rather than a top-level [keystone] scim_deprovisioned_retention_days
— this codebase nests janitor-tunable config on the owning provider’s own
config struct throughout (see [api_key] janitor_tombstone_retention_days,
ADR 0021 §6.F), and PR5 follows that existing convention rather than
introducing a new top-level config table. The sweep itself is a
leader-gated hourly background task
(crates/core/src/scim_resource/janitor.rs), mirroring the API Key
janitor’s structure exactly: for every tombstoned (deprovisioned_at set)
ScimResourceIndex older than the retention window, it hard-deletes the
underlying User/Group row via the existing IdentityApi::delete_user/
delete_group, purges the ScimResourceIndex anchor and its externalId
claim in one storage transaction, and emits a CADF delete event. Per-item
failures are isolated and retried on the next pass, exactly like the API
Key janitor. The operator-triggered purge-now erasure-request path (§6.C
last paragraph) is a new authenticated endpoint,
DELETE /v4/scim-realms/{domain_id}/{provider_id}/purge/{resource_type}/{keystone_id},
gated by a new identity/scim_realm/purge OPA policy (admin, or manager
scoped to the realm’s own domain — the same authorization boundary as
identity/scim_realm/disable, since purging a realm’s resource is at least
as sensitive as disabling the realm). It refuses to purge a resource that
is not already deprovisioned, since that would silently skip the role-
stripping and session-revocation steps DELETE /Users|Groups/{id}
performs — an operator must soft-delete first, then purge. This is the
final phase in this ADR’s implementation plan besides CLI parity (§12);
this ADR stays Proposed until that lands.
Reference
Extends ADR 0002 (OPA), ADR 0017 (Security Context), ADR 0020 (Unified Mapping Engine), ADR 0021 (Stateless API-Key Ingress), ADR 0023 (Audit). Amends ADR 0021 §3 Step 4 (see §2.C below).
1. Context & Motivation
Enterprise IdPs (Okta, Entra ID, Workday) push identity lifecycle events —
create, update, deactivate — for users and groups via SCIM, independently of
however those same users later authenticate (OIDC, SAML, or direct Keystone
credentials). Provisioning is therefore a distinct concern from authentication:
it manipulates persistent User/Group rows, not the ephemeral shadow
principals of ADR 0020’s Unified Mapping Engine (UME).
A single Keystone domain frequently represents an organization boundary that receives feeds from more than one authoritative source at once — for example, an Okta tenant provisioning full-time employees and a Workday-driven system provisioning contractors into the same domain. Both feeds must coexist without either one able to see, rename, or delete the other’s records, and without either clobbering a human administrator’s manually created accounts.
2. The Realm Model: Many Realms per Domain
A SCIM realm is the same tenant-local coordinate already used throughout ADR
0020/0021: the pair (domain_id, provider_id). A domain MAY register any number
of independent, concurrently active realms. Each realm owns its own externalId
and userName namespace, its own API keys (per ADR 0021 §5.D, N keys may still
rotate under one provider_id), and its own provisioned resources. Realms
within the same domain are fully isolated from one another (§7).
A. ScimRealmResource
Registering a realm is an explicit administrative act — creating an
ApiClientResource (ADR 0021) alone does not enable SCIM resource
provisioning for that provider_id. This separates “an API key that
authenticates” from “an API key permitted to provision identities,” so API keys
minted for unrelated ABAC/system-integration mapping rulesets can never
accidentally provision Users/Groups.
#![allow(unused)]
fn main() {
pub struct ScimRealmResource {
pub domain_id: String,
pub provider_id: String, // shared coordinate with ApiClientResource / MappingRuleSet
pub idp_id: String, // federation IdentityProvider this realm's users belong to
pub display_name: String,
pub enabled: bool,
pub created_at: i64,
pub updated_at: i64,
}
}
Keyspace: data:scim_realm:v1:<domain_id>:<provider_id>.
idp_id is mandatory and must resolve to an existing IdentityProvider
(checked at both realm create and update; an unresolvable idp_id is 404).
This exists because of the identity-convergence scheme in §4: a SCIM-provisioned
User’s id is derived from (domain_id, externalId), the same formula used
for a federation JIT shadow user’s id — so the realm has to know, up front,
which IdentityProvider’s sub claims its externalIds are expected to equal
for that convergence to actually line up. A realm not bound to a real IdP would
still create syntactically valid User rows, but they’d never converge with
anything, silently defeating the point of §4’s scheme.
Groups provisioned under a realm always inherit the realm’s own domain_id — no
separate target-domain override is offered, keeping “one realm, one domain” the
literal ownership boundary even though a domain may host many realms.
B. Realm Activation Gate
Every SCIM Users/Groups request first resolves the authenticated API key’s
provider_id (already known to the ingress layer — it is a field on
ApiClientResource, ADR 0021 §2.B) and looks up
data:scim_realm:v1:<domain_id>:<provider_id>. If absent or enabled: false,
the request is rejected with 403 Forbidden before touching any User/Group
storage.
C. Amendment to ADR 0021: Realm-Aware Context Hydration
hydrate_ephemeral_context (ADR 0021 §3 Step 4) currently discards
provider_id once the UME match resolves authorizations — it is never carried
onto the ValidatedSecurityContext. This ADR amends that step: the resolved
provider_id MUST be threaded through into a
ScimRealmContext { domain_id, provider_id } available to a new ScimRealmAuth
extractor (parallel to ApiKeyAuth), used exclusively by the /SCIM/v2
resource handlers introduced here. This is additive to ADR 0021’s payload and
does not change its authentication semantics.
Scope restriction. ScimRealmAuth requires ScopeInfo::Domain matching the
path’s {domain_id}. Project-scoped API keys receive 403 Forbidden on all
/SCIM/v2/{domain_id}/Users and /Groups routes — identity lifecycle
provisioning is a domain-level operation, never project-scoped. (The existing
diagnostic whoami route is unaffected and keeps accepting any scope.)
Write-time ruleset constraint. Because a realm’s provider_id shares its
MappingRuleSet coordinate (ADR 0020 §3) with the general UME, nothing
inherently stops an operator from adding a rule to that same ruleset that
resolves Authorization::Project for some other, unrelated claim match — which
would make ScopeInfo::Domain above pass or fail per-request unpredictably for
what is nominally “the same realm.” To close this structurally rather than leave
it as a runtime surprise, the Mapping Engine CRUD API (ADR 0020 §9.A) MUST
reject, with 422 Unprocessable Entity, any attempt to write a rule containing
Authorization::Project into a MappingRuleSet whose provider_id has an
active ScimRealmResource. This mirrors the existing write-time is_system
prohibition for ApiClient sources (ADR 0021 §6.C): a SCIM realm’s ruleset may
only ever resolve Authorization::Domain (matching its own domain_id) —
System is already forbidden for all API-key ingress.
3. Resource Ownership & the SCIM Index
A. ScimResourceIndex
Every SCIM-provisioned User or Group is anchored by an ownership record kept
separate from the mutable resource itself (mirroring how ADR 0020 keeps
VirtualUserMetadata distinct from live claims) so that provenance cannot be
altered by the SCIM PATCH surface itself.
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum ScimResourceType { User, Group }
pub struct ScimResourceIndex {
pub domain_id: String,
pub provider_id: String, // owning realm — the sole authority for §3.C
pub resource_type: ScimResourceType,
pub keystone_id: String, // User.id or Group.id; also the SCIM "id"
pub external_id: Option<String>,
pub version: u64, // monotonic; source of the SCIM ETag (§5.E)
pub deprovisioned_at: Option<i64>,
pub created_at: i64,
pub updated_at: i64,
}
}
B. Keyspace Summary
| Purpose | Key Pattern | Value |
|---|---|---|
| SCIM Realm | data:scim_realm:v1:<domain_id>:<provider_id> | ScimRealmResource |
| Resource Ownership Anchor | data:scim_resource:v1:<domain_id>:<provider_id>:<type>:<keystone_id> | ScimResourceIndex |
externalId Lookup (realm-scoped) | index:scim:external_id:<domain_id>:<provider_id>:<type>:<external_id> | keystone_id |
userName/displayName Lookup | index:scim:name:<domain_id>:<provider_id>:<type>:<lowercased_name> | keystone_id |
The realm-scoped index in the table above exists for fast, realm-owned lookup
and version resolution (§3.C, §5.E) — it is deliberately not the sole
uniqueness check. See §3.D: POST (create) additionally performs a domain-wide
collision check against core Identity, independent of realm ownership.
C. Ownership Fencing Algorithm
GET/PUT/PATCH/DELETE /Users/{id} (and /Groups/{id}) execute:
- Fetch
data:scim_resource:v1:<domain_id>:<provider_id>:<type>:<id>using the caller’s ownprovider_idfromScimRealmContext. - If absent, return
404 Not Found— indistinguishable from “the resource does not exist,” even when a same-ID or same-userNameresource exists under a different realm or was created manually. This prevents realm confusion / IDOR without leaking existence information across realms. - If
deprovisioned_atis set,GET/list treat it as absent (404); repeatDELETEis idempotent (404per RFC 7644 guidance for re-delete).
POST /Users and POST /Groups (create) check the realm-scoped externalId
index and the domain-wide userName/displayName uniqueness check defined
in §3.D; either collision returns 409 Conflict with scimType: "uniqueness"
(§10). Note the asymmetry with step 2 above: existence is hidden cross-realm for
ID-addressed reads/writes (IDOR protection), but is deliberately not hidden
for name collisions at create time (§3.D) — the two serve different goals.
D. Domain-Wide Create-Time Uniqueness
§1 states that concurrent realms — or a realm and a human administrator — must
never silently produce two identities that collide on userName within one
domain. A check scoped only to the calling realm’s own index (as in an earlier
draft of this ADR) cannot detect that: research into the existing schema
(crates/identity-driver-sql) found no pre-existing global unique constraint on
(name, domain_id) for users, so a second realm — or a manual POST /v3/users
— creating the same userName would otherwise succeed unnoticed, leaving two
User rows with identical name+domain_id and no way for a non-SCIM-aware
lookup (openstack user show, a UME rule matching on user_name) to
disambiguate them.
To close this, POST /Users and POST /Groups perform a domain-wide
existence check — a live query against core Identity for any existing
User/Group in domain_id whose name matches (case-insensitive),
regardless of which realm, or no realm, created it — in addition to the
realm-scoped externalId check. Any match, cross-realm or manual, rejects the
create with 409 Conflict (scimType: "uniqueness", §10). This uniqueness
check is deliberately not folded into the realm-scoped ScimResourceIndex
lookup used by §3.C for read/update/delete ownership fencing: the two checks
answer different questions — “does this name already exist anywhere in the
domain” (create-time, domain-wide) versus “do I own the resource at this ID”
(read/write-time, realm-scoped) — and conflating them would either leak
cross-realm existence on reads (weakening §3.C’s IDOR protection) or fail to
catch cross-realm collisions on create (the gap being closed here).
Race condition (TOCTOU). A read-then-write existence check by itself is not
sufficient: two POSTs for the same (domain_id, name) issued concurrently
(realistic under IdP-driven bulk onboarding, or two realms syncing the same
person independently) can both pass the check before either commits, producing
the exact duplicate this section exists to prevent. Since no unique constraint
on (name, domain_id) exists today (that’s the gap this section opened with),
this ADR requires the check-and-insert to be closed by one of the two mechanisms
below, not by the live query alone:
- Preferred: a
UNIQUE(domain_id, LOWER(name))constraint added to the coreUser/Grouptables inidentity-driver-sql, with the SCIM create path treating the resulting constraint-violation error as the409 Conflicttrigger instead of (or in addition to) the pre-flight query. This makes uniqueness correct under concurrency by construction, at the cost of a schema migration shared with non-SCIM user/group creation. - Fallback, if a schema-wide constraint is out of scope for this ADR’s first
cut: the existence check and the row insert execute inside a single
serializable database transaction scoped to
(domain_id, name), so a second concurrent transaction targeting the same name blocks or fails at commit rather than at the earlier read.
Either way, the pre-flight query described above remains as a fast-path rejection for the common (non-racing) case; it is the commit-time guarantee that actually closes the race, and this ADR is not satisfied by the pre-flight check alone.
4. Resource Schemas & Attribute Mapping
SCIM provisioning targets real, persistent Keystone User/Group rows — never
the ADR 0020 ephemeral shadow-registry path. A SCIM-provisioned user is expected
to authenticate later through an entirely separate channel (OIDC, password,
passkey); SCIM only manages the account’s existence and attributes.
Identity convergence with federation JIT (as-implemented). externalId is
mandatory on POST .../Users (400 if empty/absent), and the created
User.id is not server-assigned: it’s derived deterministically as
generate_public_id(domain_id, externalId, "user") — the identical sha256-based
formula this codebase’s ADR 0020 UME path already uses to derive a federation
JIT shadow user’s id. The user row is created as UserType::NonLocal (no
password, no local_user row). The practical effect: a person provisioned
ahead of time via SCIM (externalId == the IdP’s sub claim), who later
authenticates for the first time via that same realm’s idp_id (§2.A),
converges onto the same User row a JIT login would otherwise have created
from scratch — rather than ending up with two accounts for one person, one
SCIM-managed and one federation-managed. POST .../Users additionally probes
for a user already occupying that deterministic id (e.g. one a federated JIT
login already created before SCIM provisioning caught up) and returns 409 Conflict (scimType: "uniqueness") rather than surfacing a raw
primary-key-collision error from the Identity driver.
Rejected alternative: reusing User.federated: Option<Vec<Federation>> (on
UserResponse/UserCreate/UserUpdate;
Federation { idp_id, protocols, unique_id }) to carry the SCIM externalId
directly. Federation is scoped to authentication-protocol linkage (idp_id +
protocol_id) and is not realm-fenced or version-tracked; overloading it would
conflate two different provenance concepts and bypass the ownership fencing in
§3.C. ScimResourceIndex is kept as a dedicated, parallel structure for
provenance/ownership instead — the convergence above is achieved purely through
the shared id-derivation formula, not by writing into User.federated.
| SCIM Attribute (User) | Keystone User field |
|---|---|
id | id (generate_public_id(domain_id, externalId, "user"); deterministic, not server-random — see above) |
externalId | ScimResourceIndex.external_id (mandatory on create — see above) |
userName | name |
active | enabled |
name.givenName / name.familyName | extra["scim_given_name"] / extra["scim_family_name"] |
emails[primary eq true].value | extra["scim_primary_email"] |
displayName | extra["scim_display_name"] |
| SCIM Attribute (Group) | Keystone Group field |
|---|---|
id | id |
externalId | ScimResourceIndex.external_id |
displayName | name |
members | resolved via the existing user_group_membership store (crates/identity-driver-sql/src/user_group.rs) already backing core group-membership CRUD, keyed to member User.ids owned by the same realm (§7); capped at 1000 entries per resource per the §11 membership-graph-bomb limit |
Attributes without a first-class Keystone field are namespaced under
extra["scim_*"] rather than added as new top-level User columns — avoiding a
core-identity schema migration for display-only SCIM metadata.
5. Protocol Surface (Pragmatic Subset)
Full RFC 7644 compliance (arbitrary filter expressions, arbitrary PATCH path
expressions, /Bulk) is explicitly not targeted for v1. This mirrors the
DoS-hardening posture already established elsewhere in the codebase (regex ReDoS
bounds and per-claim size caps in ADR 0020 §5.1, token-bucket rate limiting in
ADR 0021 §6.A).
A. Endpoints
POST /SCIM/v2/{domain_id}/Users,GET .../Users,GET .../Users/{id},PUT .../Users/{id},PATCH .../Users/{id},DELETE .../Users/{id}POST /SCIM/v2/{domain_id}/Groups,GET .../Groups,GET .../Groups/{id},PUT .../Groups/{id},PATCH .../Groups/{id},DELETE .../Groups/{id}GET /SCIM/v2/{domain_id}/ServiceProviderConfig,GET .../Schemas,GET .../ResourceTypes— static discovery documents, honestly advertisingbulk.supported: false,sort.supported: false, and describing the restricted filter grammar below (Okta/Entra ID both tolerate afilter.supported: truewith a narrower attribute set than the spec’s maximum).
B. Filter Grammar
filter := term (LOGICAL_OP term)* // homogeneous chain only — "and" and "or" MUST NOT be mixed in one filter string
term := ATTR OP value
LOGICAL_OP := "and" | "or"
OP := "eq" | "ne" | "co" | "sw" | "pr"
| Attribute (User) | Allowed operators |
|---|---|
userName | eq, ne, co, sw, pr |
externalId | eq, ne, pr |
id | eq, pr |
active | eq, pr |
| Attribute (Group) | Allowed operators |
|---|---|
displayName | eq, ne, co, sw, pr |
externalId | eq, ne, pr |
id | eq, pr |
Any attribute or operator outside these tables, a mixed and/or chain,
nested/parenthesized expressions, or a filter string exceeding 512 bytes / 8
terms is rejected with 400 Bad Request (scimType: "invalidFilter").
co/sw are only evaluated against attributes that already carry a realm index
(§3.B), bounding worst-case cost to that index’s range, never a full table scan.
C. PATCH Operation Support
Operations: [{op, path, value}] is accepted only for these top-level, scalar
path targets: active, userName/displayName, externalId,
name.givenName, name.familyName, plus members (Group, add/remove only
— the common “push group” pattern). Any other path (complex filter expressions
like emails[type eq "work"].value, array-index paths) returns
400 Bad Request (scimType: "invalidPath"). PUT performs a full declarative
replace of all mapped attributes, including a full membership resync for Groups
(remove-then-add against the target member set).
D. Pagination
startIndex (1-based, default 1), count (default 20, max 200). Listing and
totalResults are computed via a bounded prefix range-scan over the realm’s own
data:scim_resource:v1:<domain_id>:<provider_id>:<type>:* keyspace, excluding
deprovisioned_at-set entries. This is a linear scan bounded to one realm’s
resource count (consistent with existing janitor range-scans elsewhere); a
maintained counter is deferred as a future optimization if realm sizes prove
large enough to matter.
Group listing cost. The bound above covers the scim_resource index scan
itself, not members hydration. A GET /Groups page fans out into one
user_group_membership lookup per group on the page — bounded per-group by the
same 1000-member cap as §11, so a full page’s worst case is count × 1000
membership rows (e.g. 200 × 1000 for a max-size page), not unbounded, but
materially larger than the resource-index scan alone. Clients needing cheaper
listing should page with a smaller count when membership detail isn’t
required, or use GET /Groups/{id} for individual membership detail.
E. ETags / Concurrency
ScimResourceIndex.version is a monotonic counter incremented on every
SCIM-driven PUT/PATCH, serialized as a weak ETag: W/"<version>". PUT and
PATCH requests carrying If-Match are rejected with 412 Precondition Failed
if the header value doesn’t match the current version — closing the lost-update
race inherent to concurrent push-group syncs from a single IdP.
This guarantee only holds if the If-Match compare, the field write, and the
version increment happen as one atomic operation against the backing store — a
read-compare-then-write done as three separate calls reintroduces the same race
it’s meant to close, just moved into the version counter itself. The handler
MUST perform this as a single compare-and-swap against the
data:scim_resource:v1:... row (or an equivalent single transaction against the
underlying User/Group table when the write also touches core Identity
fields), rejecting with 412 if the stored version has moved between the
initial read and the write. A high-frequency IdP sync burst is exactly the case
this is meant to survive, not merely the common case.
F. Explicitly Out of Scope for v1
/Bulk, arbitrary filter path expressions, sortBy/sortOrder, and
multi-valued complex attribute PATCH addressing (emails[type eq "work"]).
Extending any of these later requires a ratifying revision to this ADR given
their DoS/complexity surface.
6. Deprovisioning Semantics
A. DELETE /Users/{id} → Soft-Disable Only
Consistent with the retention pattern already used for API keys (ADR 0021 §5.C)
and the UME shadow registry (ADR 0020 §4.A), DELETE on a User never
hard-deletes. It:
- Sets
User.enabled = false. - Stamps
ScimResourceIndex.deprovisioned_at. - Triggers the existing token revocation pipeline
(
revocation:v1:user:<user_id>, ADR 0020 §9.F) so live sessions die immediately. - Emits a CADF
disableevent (§9).
Subsequent GET/PATCH/PUT against the same id from the owning realm
return 404 Not Found (tombstoned), matching RFC 7644’s expectation that a
deleted resource is inaccessible, while the underlying row and its audit trail
survive for incident response.
B. DELETE /Groups/{id} → Neutralize + Tombstone
Group has no enabled field in the current schema (unlike User), and RFC
7644’s Group schema defines no active attribute either. Rather than adding a
new field to core Group for this single caller, or hard-deleting (which would
silently leave any inherited role grants dangling and does not match the
“preserve audit trail” rationale used everywhere else in this codebase), Group
deletion:
- Immediately clears the group’s role assignments (closing the live authorization surface — this is the security-relevant action, since a “deleted-looking” group that still grants roles would be a silent escalation path). A role-stripped group grants nothing regardless of who remains listed as a member, so this alone is sufficient to neutralize the group.
- Stamps
ScimResourceIndex.deprovisioned_atand hides the group from all SCIMGET/Listresponses (404), identically to a User tombstone. Membership is deliberately left intact at this point — clearing it would destroy exactly the forensic snapshot (who belonged to the group at the moment of deletion) that the “preserve audit trail” rationale for not hard-deleting is meant to protect, and retaining it poses no live authorization risk once step 1 has stripped the group’s roles. - Retains the
Groupshell and its membership snapshot for the same retention window as (C) below, then the janitor hard-deletes the row (and its membership records) together.
C. Janitor Purge
A background janitor (extending the pattern of ADR 0020 §4.A’s archive cleanup
and ADR 0021 §6.F’s physical reclamation) permanently deletes User and Group
rows whose ScimResourceIndex.deprovisioned_at is older than
[keystone] scim_deprovisioned_retention_days (default: 365 days), removing the
ScimResourceIndex anchor and its external_id/name index entries in the same
transaction.
Regulatory retention risk. A fixed 365-day default of PII (extra["scim_*"]
fields, external_id) held in a soft-deleted-but-readable-by-operators state
purely to preserve an audit snapshot is a data-minimization / right-to-erasure
tension for deployments under GDPR or comparable regimes — a deployer cannot
justify a full year of retention against an erasure request just because this
ADR’s default says so. This ADR treats scim_deprovisioned_retention_days as
deployer-controlled specifically so regulated deployments can set it far below
365 days (including near-zero, trading away most of the forensic window for
compliance), and additionally requires an operator-triggered purge-now path —
a janitor invocation scoped to a single keystone_id that ignores the retention
window — so a verified erasure request does not have to wait for the configured
period to elapse. Choosing the right default retention for a given jurisdiction
is a deployment/compliance decision this ADR deliberately leaves to the operator
rather than prescribing centrally.
7. Cross-Realm & Membership Isolation
Beyond the per-resource ownership fencing in §3.C, group membership writes are
fenced transitively: a Group members entry (add, via PUT or PATCH) MUST
reference a User owned by the same realm (same provider_id) as the group
itself. A membership reference to a user owned by a different realm, or to a
manually-created user with no ScimResourceIndex entry at all, is rejected with
400 Bad Request (scimType: "invalidValue"). This prevents one IdP
integration from reaching across realm boundaries — or into human-managed
accounts — merely by guessing or enumerating a Keystone user ID.
8. Authorization & OPA Policies
Realm CRUD (POST/GET/PATCH /v4/scim-realms) is invoked by a
Fernet-authenticated human operator, not a SCIM API key, so its authorization
reuses the actual, pre-existing manager role (this codebase’s realization of
“DomainManager” — see ADR 0021 §5.A) or admin/is_admin (never
DomainAdmin), under new policies named per the slash-separated convention
actually used by every implemented policy call site in
crates/keystone/src/api/v4/** and the corresponding .rego packages (e.g.
identity/user/create) — not the colon-separated form
identity:api_key:create that ADR 0021 §5.A used only in prose and was never
implemented:
identity/scim_realm/create/identity/scim_realm/list/identity/scim_realm/show/identity/scim_realm/disable
SCIM resource CRUD authorization is enforced exactly like any other v4 endpoint
per ADR 0002, but with one important distinction from realm CRUD above: SCIM
resource requests are authenticated exclusively via API-key ingress (ADR
0021), and ApiClientResource carries no Role field and no
RoleAssignment at all. The roles evaluated by these policies are therefore
never RBAC-assigned — they are entirely the Authorization::Domain{roles}
value produced by evaluating the realm’s own MappingRuleSet (ADR 0020 UME /
ADR 0021 §3 Step 4 hydrate_ephemeral_context) at request time. An operator
grants access by authoring a mapping rule whose output includes the role
string manager, admin, or scim_provisioner onto the realm’s
provider_id — not by assigning a Keystone Role to anything, since no such
assignment surface exists for API keys. These policies are evaluated against:
identity/scim/user/create/identity/scim/user/list/identity/scim/user/show/identity/scim/user/update/identity/scim/user/deleteidentity/scim/group/create/identity/scim/group/list/identity/scim/group/show/identity/scim/group/update/identity/scim/group/delete
Note for ADR 0021: its §5.A policy names should be corrected to the same slash convention in a future revision of that ADR; this ADR does not attempt to fix 0021 retroactively, only avoids repeating its naming inconsistency.
Role-existence enforcement (ADR 0020 §7.3): the manager/admin/
scim_provisioner role strings above are only meaningful if a Role with
that exact name actually exists — the naming-drift bug this ADR originally
shipped with (invented SystemAdmin/DomainManager literals with no
backing Role, silently producing an unreachable authorization) is now
caught structurally: mapping rule create/update rejects any RoleRef
whose id doesn’t resolve against the Role store with 422 Unprocessable Entity (MappingProviderError::RoleNotFound), rather than
relying on this ADR’s prose staying in sync with the rego by hand.
The §3.C ownership-fencing check happens before OPA evaluation and is not a substitute for it — a realm’s own credential may still lack a role authorizing a given operation even against its own resources.
9. Auditing
Every SCIM write emits a CADF event per ADR 0023’s actually-implemented
CadfEventPayload, which carries a single action: String (no separate
category field exists in crates/audit/src/types.rs — ADR 0021 §5.C’s mention
of a control category is unimplemented prose, not a real field, and this ADR
does not repeat it). The action is drawn from the existing Operation enum
(crates/core-types/src/events.rs): Create/Update for writes, Disable for
the deprovisioning paths in §6.
target.type_uri is data/security/account (User) or data/security/group
(Group). realm_provider_id and external_id are captured in the event
attachment for cross-referencing against the IdP’s own provisioning logs.
initiator.id caveat. Per ADR 0021 §3 Step 4 / §5.D, initiator.id is
derived from the authenticating API key’s client_id, not from provider_id —
this is per-key, not per-realm, identity, chosen precisely so distinct keys
sharing a provider_id produce distinct audit identities. Consequently
initiator.id on SCIM CADF events changes across a zero-downtime key
rotation (ADR 0021 §5.D) even though the realm performing the action has not
changed. Consumers correlating “who acted on behalf of this realm” across a
rotation window MUST group by the realm_provider_id attachment field, not by
initiator.id.
This is an operational gap, not just a documentation note: a SIEM/SOC pipeline
built against initiator.id alone (a reasonable default, since that’s the field
ADR 0021 already calls the actor identity) will silently split one realm’s
activity into two apparently-unrelated actors across a rotation, which is
precisely the window where a leaked pre-rotation key is most likely to be
abused. This ADR does not itself ship a fix for downstream SIEM configuration,
but requires that operational runbooks for SCIM ingress (a deliverable of the
rollout, not of this ADR) explicitly call out realm_provider_id as the
correlation key, and that alerting rules keyed purely on initiator.id
stability are flagged as insufficient for SCIM traffic during review.
10. Error Mapping (RFC 7644 §3.12)
SCIM error responses use the standard envelope:
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"status": "409",
"scimType": "uniqueness",
"detail": "userName already exists within this domain"
}
| Internal Condition | HTTP Status | scimType |
|---|---|---|
| Realm not registered / disabled (§2.B) | 403 | (no body — generic) |
| Resource not owned by caller’s realm (§3.C) | 404 | (no body) |
userName/displayName/externalId collision | 409 | uniqueness |
| Disallowed filter attribute/operator/mixed chain | 400 | invalidFilter |
Disallowed PATCH path | 400 | invalidPath |
| Cross-realm/manual-user membership reference | 400 | invalidValue |
If-Match version mismatch | 412 | (no body — standard precondition failure) |
11. Threat Model
- Realm confusion / IDOR: mitigated structurally by §3.C — ownership is checked before any read/write, independent of role authorization.
- Cross-realm membership injection: mitigated by §7.
- Provisioning DoS: SCIM resource writes ride the same per-
lookup_hashtoken-bucket rate limiter as authentication (ADR 0021 §6.A); a second, write-specific limiter keyed onprovider_id([keystone] scim_realm_write_rate_limit, default 500/min, mirroring ADR 0020 §7.2’s shadow-registry limiter) bounds bulk provisioning bursts from a single compromised or misconfigured realm. - Membership-graph bombs: a single
PATCH/PUTis capped at 1000membersentries; larger syncs must paginate across multiple requests. - Filter/PATCH complexity: bounded to the tables in §5.B/§5.C — eliminates the arbitrary-expression parsing surface that would otherwise require its own ReDoS-style hardening.
- Silent group privilege retention on delete: addressed by §6.B’s immediate role-assignment clearing, rather than treating “deleted-looking” as sufficient.
- Name-collision race (TOCTOU): a naive read-then-write uniqueness check (§3.D) is racy under concurrent creates for the same name; closed by requiring a DB-level unique constraint or an equivalent serializable check-and-insert transaction, not the pre-flight query alone.
- Audit-trail fragmentation across key rotation:
initiator.idchanges across a zero-downtime SCIM key rotation (§9); mitigated by requiring downstream correlation onrealm_provider_id, and flagged as an operational requirement for SIEM/SOC configuration, not something this ADR can enforce in-band.
12. Consequences
- New keyspaces:
scim_realm,scim_resource, plus two lookup indices (§3.B). No migration of existingUser/Groupschemas for the fallback path in §3.D; the preferred path (aUNIQUE(domain_id, LOWER(name))constraint) does require a schema migration shared with non-SCIM create paths. - ADR 0021’s ingress hydration gains an additive
provider_idfield on the ephemeral context (§2.C) — existing non-SCIM consumers are unaffected. - ADR 0020’s Mapping Engine CRUD API gains a new write-time validation rule
(§2.C): rulesets whose
provider_idis bound to an activeScimRealmResourcemay not containAuthorization::Projectentries. POST /Users/Groupsnow performs a domain-wideuserName/displayNameexistence check (§3.D) against core Identity, in addition to the realm-scopedexternalIdcheck — the first piece of cross-realm uniqueness enforcement introduced for user/group names, though still not a general schema-level constraint outside the SCIM create path.- New CRUD API family
/v4/scim-realmsand OPA policies (§8, slash-separated naming) require CLI support, per the standing convention from ADR 0006 (“New APIs must be implemented in the CLI”). /Bulk, full filter grammar, and full PATCH path expressions are deliberately deferred; broader RFC 7644 compliance requires a follow-up ADR revision once real-world IdP integration experience justifies the added complexity.- The janitor gains a new purge phase (§6.C) alongside its existing API-key and
shadow-registry archive phases, plus an operator-triggered
purge-nowpath for erasure requests (§6.C) that bypasses the configured retention window. ScimResourceIndex.versionwrites (§5.E) and the §3.D uniqueness check-and-insert both require compare-and-swap / transactional semantics from the backing store rather than separate read-then-write calls — a correctness requirement on the implementation, not just documentation.
25. Dynamic Auth Plugins via WebAssembly
Date: 2026-07-02
Status
Proposed
Reference
Contrasts with ADR 0018 (Plugin Linking - static, compile-time linking). Extends
ADR 0017 (Security Context), ADR 0023 (Audit). Reuses the resource-bound
patterns established in ADR 0020 §5.1 (regex evaluation timeout/size caps).
Amends ADR 0020 §2/§3 (IdentitySource gains a WasmPlugin variant;
MappingContext gains an optional plugin-hash field - §4 “mapping Mode”).
Reuses ADR 0024 §3.B (externalId lookup index) for SCIM-aware identity linking
- §4 “Admin-Authorized External Identity Linking.”
1. Context & Motivation
All extensibility in keystone-rs today is resolved at compile time:
- Backend drivers (SQL, Raft, etc.) are separate crates registered through
inventory::submit!and forced into the link graph by theanchor()/build.rsconvention (ADR 0018). Adding or changing a driver requires a new crate, a Cargo dependency edit, and a full rebuild + redeploy of the binary across the cluster. - Authentication methods are a closed Rust enum,
AuthenticationContext(crates/core-types/src/auth.rs:1169), with variantsPassword,Admin,Token,ApplicationCredential,Oidc,K8s,Trust,WebauthN,Mapping.crates/core/src/api/auth.rsmatches this enum exhaustively to build aSecurityContext. The[auth] methodsconfig value (crates/config/src/auth.rs:22-27) is aVec<String>allowlist, but every string in it must already correspond to a variant compiled into the binary.
This is the correct model for first-party backends (ADR 0018’s stated goal: zero
manual maintenance, compile-time enforcement). It is the wrong model for
operator- or customer-specific authentication logic: a proprietary SSO
quirk, a legacy directory bridge, a step-up/MFA policy tied to an internal risk
service, or a one-off migration bridge that a specific deployment needs but that
has no place in the upstream keystone-rs tree. Today, satisfying any of these
requires either forking keystone-rs or waiting on a release that adds a
bespoke enum variant - in both cases a full recompile and coordinated
cluster-wide redeploy.
This ADR introduces a second, orthogonal extensibility mechanism: dynamic auth
plugins, compiled to WebAssembly, loaded from disk at process startup, and
invoked as first-class authentication methods - without touching keystone-rs
source or its build.
Requirements
A plugin must be able to:
- Act as a full authentication method: receive the raw login request and either accept it (producing an identity + claims) or reject it - not merely observe a decision made elsewhere.
- Perform outbound HTTP calls (e.g. call a third-party risk API or legacy directory as part of the decision).
- Call a curated set of internal Keystone operations when necessary - most importantly, provision a local user on first login.
- Authenticate a user who already exists through some other channel (most notably SCIM provisioning, ADR 0024) - not only identities the plugin provisions itself. See §4 “Three Operating Modes” for how this is satisfied without weakening requirement 1’s guarantees.
- Redirect a request to a different, already-registered auth method based on
inspecting the raw credential, for clients that cannot be made to request a
custom method name themselves (
application_credential-shaped auth for Terraform is the motivating case) - without that routing decision itself being able to authenticate anyone. See §4 “Guest Contract -routeMode.”
Non-goals
- Replacing the ADR 0018 static-driver model for first-party backends.
- Per-domain/multi-tenant plugin scoping (a domain admin installing their own plugin). See §8 (Open Questions) - this ADR restricts plugins to cluster-global, system-admin-installed only.
- Hot reload / upload-via-API. Plugins are loaded once at process startup from local disk (§5). A future ADR may revisit this (§8).
- General-purpose scripting for non-auth extension points (policy, mapping rules, etc.). Those already have dedicated mechanisms (OPA - ADR 0002; the Unified Mapping Engine - ADR 0020).
Threat Model
Installing a plugin already requires filesystem and keystone.conf write access
on every node - operationally equivalent to root on the Keystone host. This ADR
therefore does not attempt to defend against an operator who deliberately
installs a plugin they wrote to be malicious; that is out of scope, the same way
a malicious [database] connection value is out of scope for any other ADR.
What it does defend against, because both are realistic even for a plugin the operator trusts and reviewed:
- A buggy or exploited plugin - third-party code, however well-reviewed,
can have logic errors or be compromised via a supply-chain issue in its own
dependencies (the plugin author’s build, not
keystone-rs’s). The blast radius of such a bug or compromise must be bounded to “this one auth method behaves incorrectly,” not “arbitrary account takeover” or “arbitrary internal API access.” - An anonymous network attacker - every plugin invocation is reachable
pre-authentication (it is the authentication step), so the design must
assume a remote, unauthenticated party can trigger plugin execution and
http_fetchcalls at will, and bound the damage that’s possible from that alone (resource exhaustion, SSRF, credential exposure). - Widened credential exposure from
routemode specifically - afull_auth/mappingplugin is only invoked for requests already addressed to it by name, so a buggy or exploited one is exposed to at most the traffic an operator explicitly opted into routing there. Aroute-mode plugin breaks that property: it must run on every request whoseidentity.methodsmatches itsinspect_methodslist (§4 “Guest Contract -routeMode”), including ones it ultimately passes through unmodified, so it sees raw credential material (headers, method payloads) for a strictly larger slice of login traffic than any other plugin in this design - for example, everyapplication_credentialattempt in the cluster, not just ones actually meant for a custom handler. A bug or compromise here doesn’t grant broader authentication power (§4’s structural constraints - target-method allowlist, no identity resolution, noscopeaccess - still bound that), but it does mean the plugin is a wider observation surface: it is positioned to see, and potentially exfiltrate via a compromisedhttp_fetchcall, credential material belonging to logins it has no legitimate reason to act on.inspect_methodsscoping (§4) and theheaders/payloadsallowlists are the load-bearing controls for this actor - narrowing what triggers invocation and what a triggered invocation can see is the only mitigation available, since the plugin must structurally be able to inspect something about every matching request to decide whether to route it.
Every control in §4–§7 is sized against these three actors, not against a deliberately hostile plugin author.
2. Decision Summary
| Axis | Decision |
|---|---|
| WASM runtime | Extism (host runtime built on wasmtime) |
| Hook point | Full custom auth method - a plugin is a peer of password/openid/k8s |
| Tenancy / trust scope | Cluster-global; installed only by system admins via keystone.conf |
| Distribution | Local filesystem path, pinned by a SHA-256 checksum in keystone.conf |
| Failure handling | Fail closed - any plugin error rejects the login attempt |
| Internal-call capability | Curated, per-plugin host-function allowlist (no generic RPC) |
| Resource limits | Fuel metering + wall-clock deadline + linear-memory cap, all configurable |
| Identity binding | Host-issued handles into a plugin-owned (plugin_name, external_id) namespace - never a raw user_id, never a lookup over existing accounts |
| Provisioning domain scope | Config-declared provision_domain_id; create_user rejects any other domain |
| Claims | Reserved-key denylist + size/count caps, enforced by the host |
| Sensitive headers | Hard denylist (Authorization, Cookie, …) - never exposable via config |
| Invocation rate limiting | Per-source-IP token bucket fronting a per-plugin token bucket + global concurrency cap (mirrors ADR 0020 §7.2) |
| Load-time checksum mismatch | That plugin only is disabled + a critical alert is raised; the node and every other configured auth method still start |
| Plugin-compromise cleanup | Bulk admin revoke_all endpoint, scoped per plugin_name - disables provisioned users, revokes granted roles, deletes identity links, revokes tokens |
http_fetch SSRF policy | Connect-time IP re-validation against allowed_hosts; no redirects by default |
| Outbound secrets | Host-injected from config/env; never placed in guest memory |
assign_role scope | Config-declared role allowlist; system-scope grants always forbidden |
| WASI imports | None registered - only the curated host functions in §6 |
| Token/plugin-version binding | Per-plugin valid_since cutoff in config; verification rejects any token whose issued_at predates it (the fixed FernetToken payload cannot carry a per-plugin SHA-256). full_auth only - a mapping-mode token carries no plugin-recoverable field, see §4 caveat and §8. |
| Auth-method name collisions | Plugin names reserved-word-checked against builtins at load time |
| Pre-existing (e.g. SCIM) users, low-privilege path | mapping mode - plugin transforms claims; Mapping Engine (ADR 0020) remains the terminal identity authority, no binding needed |
| Pre-existing (e.g. SCIM) users, full-authority path | full_auth mode + admin-authorized external identity linking - never plugin-self-service |
| Coarser “any user in my domain” resolution | Rejected - always requires explicit per-identity or per-SCIM-realm admin authorization (§4) |
Routing ahead of method dispatch (e.g. a client that only ever sends a fixed method name, such as application_credential-shaped auth, but the real handler must vary by credential shape) | route mode - plugin sees the raw, pre-dispatch request and may relabel identity.methods + hand a payload to exactly one allowlisted target method; single-shot, never touches scope, target method still independently verifies the credential (§4) |
3. Runtime: Extism on wasmtime
No WASM runtime exists anywhere in the current dependency tree (Cargo.lock
only contains wasm-bindgen/wasm-streams, transitive browser-target deps of
an unrelated crate) - this is greenfield.
Extism is chosen over raw wasmtime or wasmer because it already solves
the three hardest parts of this problem as a framework, rather than requiring
keystone-rs to invent them:
- A stable, versioned Plugin-Development-Kit (PDK) ABI with official SDKs for the languages a third-party plugin author is likely to use (Rust, Go, JS/TS, Python, C, Zig, …) - we do not have to design or document our own guest-side calling convention.
- A built-in host-function registration model (
extism::Function) that maps directly onto the “curated allowlist” capability model this ADR requires (§6)- each host function is registered per-
Plugininstance, so a plugin simply cannot see a host function it wasn’t given.
- each host function is registered per-
- A built-in HTTP allow-list (
allowed_hostsonextism::Manifest) for the guest-initiated HTTP use case (§6.A), instead ofkeystone-rshaving to hand-roll a WASI-sockets bridge. - It already wraps
wasmtimefor fuel metering, wall-clock timeouts (Plugin::newbuilder timeout), and linear-memory limits (§7) - the resource-bounding primitives this ADR needs are present, not something to build from scratch.
The trade-off is an additional framework dependency with its own release
cadence, on top of wasmtime. Given no existing runtime is present to reconcile
with, and the PDK ergonomics directly serve requirement 3 (guest-language
diversity for third-party plugin authors), this is judged worth it.
4. Hook Point: Plugins as a Full Auth Method
A plugin is registered under a plugin name that is used verbatim as an entry
in [auth] methods (crates/config/src/auth.rs:22-27 already accepts arbitrary
strings - no config-schema change needed there). At authentication time, if the
requested method name does not match a builtin (password, token, openid,
…), it is looked up in a new WasmPluginRegistry, following the same “resolve
backend by string name” pattern already used by
PluginManagerApi::get_x_backend (crates/core/src/plugin_manager.rs:56-120).
[auth]
methods = password,token,openid,application_credential,acme_risk_sso
Three Operating Modes: full_auth vs mapping vs route
The namespace-scoped identity binding in this ADR (below) is necessary to stop a plugin from asserting an arbitrary pre-existing identity, but it has a direct consequence: a plugin can, by construction, only ever authenticate users it itself provisioned. That’s correct for genuinely new external identities, but it leaves no path for a plugin to serve as an auth method for users who already exist through some other channel - most importantly, users provisioned via SCIM (ADR 0024), which is an explicit, realistic requirement, not an edge case.
A separate, unrelated gap: some clients never let the operator choose which auth
method name is requested at all. Terraform’s OpenStack provider, for example,
always use built-in auth method names - it has no concept of a custom method
name. A real-world pattern built around this constraint (and the direct
inspiration for route mode below) is keystonemiddleware-style request
rewriting: a component ahead of the auth logic inspects the incoming
application_credential_id and, based on its shape, rewrites the request so it
is dispatched to a different handler entirely - the routing decision and the
authentication decision are two separate concerns, made by two different
pieces of code, neither of which is the client. Neither full_auth nor
mapping mode can express this: both require the client to already know which
method name to ask for.
Rather than loosening the identity-binding namespace to cover the first gap
(which would reopen the account-takeover class this ADR exists to prevent - see
“Identity Binding” below), or collapsing the routing concern into an
authentication concern (which would let a request-shaping decision double as a
credential-verification decision - see “route” below), each plugin declares one
of three operating modes at config time (mode = full_auth | mapping | route,
§5; defaults to full_auth for everything already described in this ADR):
full_auth(default, as designed above) - the plugin is the terminal identity authority for its method name. It callsprovision_user/find_userand returnsAllow/Denyitself. Can reach pre-existing users only via an admin-authorized link (see “Admin-Authorized External Identity Linking” below)- never by unscoped lookup.
mapping- the plugin has no authority to terminate authentication at all. It only transforms/normalizes the incoming request into a claims map, which is handed to the existing, already-reviewed Unified Mapping Engine (ADR 0020) to make the actual identity and authorization decision - exactly the same engine already trusted to resolve OIDC/K8s/SPIFFE claims to real or ephemeral users, including real, pre-existing local users via itsIdentityMode::Localpath. Because the plugin never asserts an identity itself, noResolvedIdentityHandle, no namespace, no binding of any kind is needed for this mode - it’s the direct, safe way to get the “simple auth request rewrite” behavior for users the plugin didn’t provision, including SCIM-provisioned ones, without touching the account-takeover defense at all.route- the plugin has no authority to terminate authentication and no authority to decide identity at all; it only sees the raw, pre-dispatch request and decides which already-registered auth method should actually handle it, optionally relabeling that method’s payload. This is a request-routing decision, not a credential-verification decision - the target method (builtin or another plugin) still performs its own full, unweakened verification. See “Guest Contract -routeMode” below.
mapping mode is the recommended default for plugins whose job is fundamentally
“translate a proprietary/legacy assertion into claims” (the SCIM-adjacent case).
full_auth mode remains available for plugins that must make a genuinely
custom, non-claims-matching judgment call - a real-time risk score, for instance
- that can’t be expressed as a static mapping rule.
routemode is for the narrower case where the client cannot be made to ask for a different method by name, but the credential it always sends is self-describing enough for a router to redirect it to the handler that actually knows how to verify it.
Guest Contract - full_auth Mode
Each plugin exports a single Extism entry point:
authenticate(request: AuthPluginRequest) -> AuthPluginResponse
#![allow(unused)]
fn main() {
// Host <-> guest wire types (JSON over the Extism call boundary)
struct AuthPluginRequest {
/// Raw credential payload from the identity.<method> block of the
/// v3/v4 auth request, exactly as received.
payload: serde_json::Value,
/// Allowlisted subset of inbound HTTP headers - only headers the
/// plugin's config explicitly opts into (`exposed_headers = ...`,
/// §5) are forwarded. Everything else is never handed to the guest.
/// This is an allowlist, not a denylist: a header added to Keystone in
/// the future is excluded by default rather than silently exposed. A
/// fixed set - `Authorization`, `Cookie`, `X-Auth-Token`,
/// `X-Service-Token`, `X-Subject-Token`, `Proxy-Authorization` -
/// additionally can **never** appear in
/// `exposed_headers` regardless of what an operator configures; the
/// config loader rejects any plugin config that lists one of them,
/// the same fail-loud posture as §5. This is deliberate
/// defense-in-depth: `exposed_headers` being operator-controlled makes
/// it easy for a copy-pasted example config to silently re-expose
/// exactly the headers this mechanism exists to protect.
headers: HashMap<String, String>,
/// The trusted transport peer address only. This is the socket peer
/// Keystone actually accepted the connection from, **not** a value parsed
/// from a forwarding header unless the public TCP peer is an explicitly
/// configured trusted proxy. `[auth_plugins] trusted_header` selects
/// exactly one header that those proxies must sanitize (`x_forwarded_for`
/// by default, or `forwarded` by explicit opt-in). A plugin will predictably
/// build IP
/// allowlisting, geo, or step-up logic on this field; handing it a
/// client-spoofable `X-Forwarded-For` value would let an anonymous caller
/// (§1 Threat Model, actor 2) forge whatever source address defeats that
/// logic. `None` when no trusted address can be established, rather than an
/// untrusted guess.
///
/// **Implementation note on a degenerate configuration.** The resolver
/// (`crate::net::resolve_client_ip`) walks the configured header chain
/// right-to-left looking for the first entry *not* in `trusted_proxies`,
/// which is the actual, spoof-resistant client address. If every entry in
/// the chain - including the raw TCP peer - is itself a configured
/// trusted proxy (an operator misconfiguration: a trusted-proxy CIDR that
/// also matches real client addresses, or a chain with no genuine client
/// hop at all), the resolver falls back to the trusted peer's own
/// address rather than `None`. This is still a real, non-spoofable,
/// trusted address (never an unverified guess from the request itself),
/// just not necessarily the true originating client - a narrower
/// deviation from "`None` when no trusted address can be established"
/// than it may first appear, but one worth operators being aware of if
/// `trusted_proxies` is scoped too broadly.
remote_addr: Option<String>,
}
/// Opaque, single-invocation, host-issued handle. Returned by the
/// `provision_user`/`find_user` host functions (§6.B, §6.C) and the only
/// thing a plugin can present back to the host to claim an identity - see
/// "Identity Binding" below. Not a `user_id`; has no meaning outside the
/// `Store` instance that issued it and expires with that invocation.
struct ResolvedIdentityHandle(String);
enum AuthPluginResponse {
Allow {
/// Must be a handle this exact invocation received from a
/// `provision_user` or `find_user` call - never a plugin-supplied
/// user_id. See "Identity Binding" below for why.
resolved_identity: ResolvedIdentityHandle,
/// Extra claims to attach to AuthenticationContext for downstream
/// policy (OPA) visibility - analogous to OidcContext claims.
/// Bounded and reserved-key-checked by the host; see §6.F.
claims: HashMap<String, serde_json::Value>,
},
Deny {
/// Operator-facing reason, CADF-audited; never shown to the client.
reason: String,
},
}
}
This makes the plugin a peer of the existing Oidc/K8s variants rather than a
request-mutation filter in front of them: it owns the full
credential-verification decision for its method name. AuthenticationContext
gains one variant:
#![allow(unused)]
fn main() {
WasmPlugin {
plugin_name: String,
claims: HashMap<String, serde_json::Value>,
token: Option<FernetToken>,
},
}
The variant carries no plugin_sha256: the FernetToken payload is a fixed
enum with no plugin-bearing variant (a WasmPlugin login mints an ordinary
scoped token), so there is nowhere to embed and later re-compare a module hash.
Version binding is instead keyed on plugin_name at verification time against
the plugin’s configured valid_since cutoff - see “Plugin Version Binding”
below.
which flows through the existing ValidatedSecurityContext::new_for_scope()
pipeline (crates/core/src/auth.rs:79-181) unchanged - a plugin-authenticated
principal is validated (enabled checks, expiry, effective-role calculation)
exactly like any other IdentityInfo::User.
Guest Contract - mapping Mode
A mapping-mode plugin exports a different entry point, with no power to name
an identity:
#![allow(unused)]
fn main() {
mapping(request: AuthPluginRequest) -> MappingResponse
}
#![allow(unused)]
fn main() {
enum MappingResponse {
/// Flattened claims map, handed verbatim to the Mapping Engine's rule
/// evaluator (ADR 0020 §5) as if it came from an OIDC/K8s/SPIFFE
/// ingress adapter. There is no Allow variant here - the plugin cannot
/// terminate authentication, only feed the engine that does.
Claims(HashMap<String, serde_json::Value>),
Deny {
reason: String,
},
}
}
AuthenticationContext is not extended for this mode - a successful
mapping-mode login produces the existing Mapping(MappingContext) variant
(crates/core-types/src/auth.rs:1200), because the Mapping Engine, not the
plugin, made the decision. Mechanically:
- ADR 0020’s
IdentitySourceenum (crates/core-types/src/mapping/resolution.rs, per ADR 0020 §3) gains a variant:WasmPlugin { plugin_name: String }, alongside the existingFederation,K8s,Spiffe. - Each
mapping-mode plugin is automatically assignedprovider_id = "wasm:<plugin_name>"in the Mapping Engine’s coordinate space. An operator writes ordinaryMappingRuleSetrules under thatprovider_id(POST /v4/mappings, ADR 0020 §9.A) exactly as they would for a real OIDC provider - includingIdentityMode::Localrules that resolve to real, pre-existing users (SCIM-provisioned or otherwise) by matching claims the plugin produced against whatever attributes identify that user. - The plugin’s
Claims(...)response is passed to the Mapping Engine’s existing evaluator unmodified - the same engine, the same domain whitelist (0020 §3allowed_domains), the same regex/size bounds (0020 §5.1), the same TOCTOUruleset_versioncheck (0020 §5.5–§5.6). Nothing new is built; the plugin is just a new kind of ingress adapter feeding into infrastructure this codebase already trusts. - If no
MappingRuleSetexists underwasm:<plugin_name>for the target domain, evaluation returnsMappingNotFound(0020 §5.5) and the login is rejected - fail-closed by construction: a plugin can authenticate no one until an admin has explicitly authored rules for it.
Why this needs no identity binding at all. Two properties of the Mapping
Engine, both pre-existing and unmodified, do the work ResolvedIdentityHandle
does for full_auth mode:
- Claims only ever drive rule matching, never become privilege directly.
ClaimCondition/MatchCriteria(0020 §5.1–§5.2) read claim values to decide which admin-authored rule fires; the rule’s own hardcodedidentity,authorizations, andis_systemfields - not the raw claims - determine the outcome. A plugin cannot inject a claim that becomesis_system: truethe way an unbounded claims map could infull_authmode (§7 “Response Payload Bounds” reserved-key denylist exists precisely because that mode lacks this property) - there is no rule-independent path from “plugin said X” to “principal has privilege Y.” - Ruleset lookup is
provider_id-isolated. Amapping-mode plugin’s claims are only ever evaluated against rules filed under its ownwasm:<plugin_name>coordinate (0020 §8 keyspace:data:mapping:v1:<domain_id>:wasm:<plugin_name>) - never against the ruleset backing a real SPIFFE trust domain or OIDC IdP. A compromised or buggy plugin cannot forge claims that get matched against a different source’s rules; the worst it can do is cause a mismatch/no-match against rules an admin wrote specifically expecting its own output.
Plugin-version binding for mapping mode - not enforceable at verification
today (implementation deviation, recorded here rather than left implicit). The
original intent was the same valid_since cutoff as full_auth, recovering the
plugin name at verification time from the matched ruleset’s
IdentitySource::WasmPlugin { plugin_name } via the token’s mapping_id. That
requires the minted token to actually carry a mapping_id (or equivalent
plugin-recoverable linkage) - it does not: a successful mapping-mode login
mints an ordinary DomainScope/ProjectScope/… token
(FernetToken::from_security_context’s AuthenticationContext::Mapping(_) arm,
crates/core-types/src/token.rs) whose payload carries only
methods = ["mapped"], the same as any OIDC/K8s/SPIFFE-sourced mapped login.
There is no mapping_id field anywhere in a FernetToken payload, so
verification (TokenService::validate_to_context_impl,
crates/core/src/token/service.rs) has nothing to recover a plugin name from
- widening the payload to carry one is exactly the kind of per-record bookkeeping this ADR otherwise avoids, and was judged not worth it for this first iteration.
Consequence: a token minted through a mapping-mode plugin is not
invalidated by bumping that plugin’s valid_since, unlike a full_auth token
(whose methods does carry the plugin name and is checked at verification, see
“Plugin Version Binding” below). An operator responding to a compromised
mapping-mode plugin must fall back to the mechanisms every other token-holder
relies on: issue revocation events for the affected users, or rely on a short
token TTL. Closing this gap - by adding a plugin-recoverable field to the
relevant token payloads - is left as future work (§8), not required for this
ADR’s threat model, since mapping mode’s own structural constraint (the plugin
never asserts an identity - §4 “Why this needs no identity binding at all”)
already bounds what a compromised mapping-mode plugin can do to “produce
claims the Mapping Engine’s already-authored rules for it will match or reject,”
not “assert an identity directly.”
Capability restriction. provision_user, find_user, and assign_role
(§6.B–D) are meaningless in mapping mode - the Mapping Engine owns
provisioning and role resolution instead. Granting any of them to a
mode = mapping plugin is a config-load-time error, the same fail-loud posture
used throughout §5, rather than a silent no-op. Only http_fetch (§6.A) applies
to both modes.
Guest Contract - route Mode
route mode answers a narrower question than either mode above: not “who is
this?” but “which already-registered method should even attempt to answer that?”
It runs before method dispatch, on the raw v3/v4 auth request, and its only
power is to relabel which method handles the request and what payload that
method sees - it never resolves an identity, never returns Allow, and is not
itself subject to the namespace-scoped identity binding below (there is no
identity for it to bind).
#![allow(unused)]
fn main() {
route(request: RouteRequest) -> RouteResponse
}
#![allow(unused)]
fn main() {
struct RouteRequest {
/// The `identity.methods` list exactly as the client sent it, before
/// any method resolution has happened.
requested_methods: Vec<String>,
/// Allowlisted subset of inbound HTTP headers - same `exposed_headers`
/// mechanism and same hard denylist (`Authorization`, `Cookie`, ...) as
/// `full_auth`/`mapping` (§4 "Guest Contract - `full_auth` Mode").
headers: HashMap<String, String>,
/// Raw JSON payload for each `identity.<method>` block the plugin's
/// config has declared it needs to inspect (`inspect_methods`, §5).
/// Blocks for methods not in `inspect_methods` are never included -
/// a router configured to look at `application_credential` never sees
/// the body of an unrelated `password` block, even on a request that
/// carries both.
payloads: HashMap<String, serde_json::Value>,
/// Trusted transport peer only - identical provenance rule and
/// spoofing rationale as `AuthPluginRequest.remote_addr` above.
remote_addr: Option<String>,
}
enum RouteResponse {
/// Leave the request exactly as received; ordinary method resolution
/// proceeds as if no router plugin were installed.
Passthrough,
/// Reroute to `target_method`, replacing the `identity.<target_method>`
/// block with `payload` verbatim. `target_method` MUST be a member of
/// this plugin's configured `route_targets` allowlist (§5) - a
/// response naming any other method is rejected by the host as a
/// malformed response (§7), not corrected or silently dropped.
Route {
target_method: String,
payload: serde_json::Value,
},
Deny {
reason: String,
},
}
}
Structural constraints, enforced host-side, not by convention:
- Target-method allowlist. A
route_targetslist (§5) is required at config load; aRouteresponse naming a method outside it is treated as a malformed response under §7’s failure semantics - the login is rejected, not redirected to an unintended handler.route_targetsitself is subject to the same reserved-name check as plugin registration (§4 “Reserved Auth-Method Names”) - a router can never be configured to targetadmin,trust, or any other method capable of reaching system scope. scopeis immutable to the router. The host constructs the re-dispatched request by replacing only the namedidentity.<target_method>block;RouteResponsecarries noscopefield at all, so there is no code path by which a router can widen or redirect the requested project/domain/system scope. Scope resolution remains entirely owned by whichever method (or the Mapping Engine, for amapping-mode target) ultimately handles the rerouted request.- Single-shot. A request that has already been through one
route-mode dispatch is not eligible for another - the host does not re-invoke any router (the same one or a different one) on a request it has already rewritten. This is enforced structurally (the rerouted request carries an internal flag no guest can set or clear), not left as an operator convention, and is what keeps this mechanism from degenerating into unbounded recursive rewriting. - No identity, no claims, no credential synthesis. The target method still
performs its own complete, unweakened verification against whatever payload
it receives - a router deciding “this looks like a credential for user X” is
never treated as proof of that.
payloadmay only carry values the plugin was actually able to read out ofpayloads/headersin this same invocation (the host does not prevent a plugin from fabricating an arbitrary JSON payload, since it cannot distinguish “reshaped” from “synthesized” at the type level - but the target method’s own secret/ signature verification is exactly the backstop that makes fabrication harmless: a router can relabel which handler sees a credential, and can restructure the bytes it was given, but cannot manufacture a passing verification result for a credential it never actually possessed). - Trigger scoping.
inspect_methods(§5) bounds not just what the plugin sees but whether it is invoked at all: a request whoseidentity.methodscontains none of the configuredinspect_methodsentries never reaches this plugin. This is the main lever for containing the router’s blast radius - without it, a router would need to run on every login attempt for every method, including ones (password,token) it has no legitimate reason to ever see. - Fail-closed, independent budget. A
route-mode failure (trap, timeout, fuel exhaustion, malformed response, off-allowlist target) rejects the login exactly like any other plugin failure (§7) - it never falls through to dispatching the original, un-routed request. Because a router is reachable by a strictly larger slice of traffic than afull_auth/mappingplugin (every request matchinginspect_methods, not just requests already addressed to this plugin by name), itsinvocation_rate_limit_per_minute/max_concurrent_invocationsbudget (§7) is tracked independently of the target method’s own budget - a saturated router degrades only routing decisions for itsinspect_methods, never the target method’s headroom for requests that reach it directly.
Capability restriction. provision_user, find_user, and assign_role
(§6.B–D) are config-load-time errors for mode = route, identically to
mapping - a router does not resolve or grant anything, only redirects.
http_fetch (§6.A) is permitted (a router may need to consult an external
service to decide a route) but its cost is paid on every matching request,
including ones that end up as Passthrough; operators should budget
timeout_ms accordingly given route mode sits ahead of, not instead of, the
target method’s own processing time.
Audit. The mandatory audit wrapping (§6.E) records, for every route-mode
invocation, the client’s originally-requested method list, the plugin’s decision
(Passthrough/Route/Deny), and - for Route - the resulting
target_method. This is deliberate: without it, a CADF event for the
eventually-authenticated request would show only the routed-to method, making it
look as though the client had requested that method directly, which is exactly
the wrong picture for an operator investigating a routing plugin gone wrong.
No router version binding on the issued token - by design. A route-mode
plugin does not mint a token; the target method it routes to does, and that
token is subject to the target’s version binding (the target plugin_name’s
valid_since cutoff, for a full_auth or mapping target), not the router’s.
This is intentional, not an oversight: the token-version-binding defense (§4
“Plugin Version Binding”) exists to invalidate credentials a vulnerable
authenticator minted, and a router never authenticates - it cannot mint a
credential for anyone the target method didn’t independently verify (constraint
4 above). Patching a buggy router therefore has nothing to retroactively
invalidate: any token that exists was authorized by a target method’s own
still-bound verification, and the router’s fix takes effect on the next request
the instant its new module loads at startup (§5). The one thing a router bug
does get bound into is the audit trail, which is where a routing bug actually
needs to be reconstructable.
Identity Binding (full_auth Mode): Handles Into a Plugin-Owned Namespace, Not Raw user_id
A plugin is never given a channel to assert “bind this token to user_id
<arbitrary-uuid>.” If it were, a single logic bug or malicious response from a
plugin would be a full authentication bypass - it could name any existing
account, including a cloud-admin’s, without ever checking a credential for it.
It is not enough, however, to merely require the plugin to obtain a handle
before asserting an identity - a handle-issuing find_user that performs a
general, unscoped lookup (“does any user named <x> exist?”) reopens exactly
the same bypass one function call later: a plugin (buggy, or simply never
implementing the credential check it’s supposed to) could call
find_user("admin"), receive a valid handle for the real admin account, and
return Allow - never having verified anything. The host would then
correctly-but-uselessly confirm “yes, this handle was legitimately issued,”
because the lookup itself was the hole.
The actual control is namespace scoping, not merely indirection through a
handle. provision_user and find_user do not perform a general Keystone user
search at all - they operate exclusively against a federated-identity mapping
private to that plugin, keyed by (plugin_name, external_id), where
external_id is a string the plugin’s own guest logic derives (e.g. from a
verified external SSO subject or signed assertion) and has no relationship to a
Keystone username. This is the same pattern this codebase already uses for
OIDC/K8s/SPIFFE federation -
find_federated_user(ctx, idp_id, unique_workload_id)
(crates/core/src/mapping/service.rs:374-433) never searches local accounts by
name either; it only ever resolves (idp_id, external_id) pairs a prior
provisioning step created under that same source.
#![allow(unused)]
fn main() {
// Host functions §6.B / §6.C - signatures pinned here specifically so an
// implementer cannot accidentally build the unscoped version above.
fn provision_user(external_id: String, user: UserCreate) -> ResolvedIdentityHandle;
fn find_user(external_id: String) -> Option<ResolvedIdentityHandle>;
}
Mechanically:
-
provision_user(external_id, user)creates (or, on a repeat call with the sameexternal_id, returns the existing) realUserrow viaIdentityBackend::create_user, and records a mapping(plugin_name, external_id) -> user_id- separate storage from, and invisible to, any other plugin or auth method.find_user(external_id)looks up only within that same(plugin_name, external_id)mapping. Neither function will ever resolve auser_idthat this plugin did not itself provision - a password-authenticated admin account, a user created via the API, or an identity provisioned by a different plugin are all structurally unreachable, not merely policy-forbidden. -
Both return an opaque
ResolvedIdentityHandleunrelated to the realuser_id, from which the host can recover(user_id, domain_id)on theAllow.resolved_identityecho below.Implementation deviation: a signed token, not a per-
Storemap. The original intent was an in-memoryhandle -> (user_id, domain_id)map scoped to the singleStorecreated for that invocation - random, unguessable, and structurally impossible to reuse across invocations because the map itself doesn’t outlive one. The actual implementation (CoreHostFunctionsincrates/core/src/auth_plugin.rs) instead HMAC-signs{plugin_name, user_id, domain_id, expires_at}with a process-lifetime random key and hands the signed bytes back as the “handle.” This is a consequence of how host functions are registered in this implementation (§6 deviation, above): theextism::Functionclosures are shared across every concurrent invocation of a plugin’s compiled module, not instantiated fresh perStore, so there is no per-invocation-scoped place left to keep an in-memory map safely isolated between concurrent requests. A signed, expiring token gives an equivalent security property - unforgeable without the key, and resolves only to exactly what a priorprovision_user/find_usercall in this plugin’s namespace actually returned - without relying on state scoped to a single invocation. Theexpires_atis set generously past a plugin’s owntimeout_msbudget (default 60 seconds) rather than truly one-shot: the trade-off is a small window in which a captured handle from one invocation could in principle be replayed into a later one, versus the ADR’s original “expires with that invocation” property. This is bounded, not open-ended -find_user’s live-domain_idre-check (step 3, “Admin-Authorized External Identity Linking”) still applies to a replayed handle exactly as it would to a fresh one - but it is a real, if narrow, deviation from a strictly per-invocation-scoped handle. -
If the guest’s
Allow.resolved_identityverifies against the signing key (or, in the originally-intended design, matches an entry in that map), the host substitutes the real(user_id, domain_id)when constructing the token. If it does not verify - a fabricated handle, a tampered handle, one issued for a different plugin, an expired one, or noprovision_user/find_usercall was ever made - the request is rejected exactly like any other malformed response (§7), and the mismatch itself is audited as a suspicious event. -
A plugin granted neither
provision_usernorfind_user(§6) has no way to produce a valid handle at all, and can therefore only everDeny- this is enforced at config-load time: registering a plugin as an[auth] methodsentry without at least one of those two capabilities is a startup configuration error, not a silent no-op.
This makes “which real accounts can this plugin authenticate as” a direct,
auditable, structurally bounded function of what that specific plugin has
itself provisioned - never a lookup against the wider pool of existing Keystone
accounts, and never a free-form claim. What the host still cannot do - because
it’s inherent to letting arbitrary code define an auth method - is verify that
the plugin performed a correct credential check before calling
provision_user/find_user with a given external_id. Namespace scoping
bounds the failure mode to “this plugin’s own provisioned identities might be
mis-authenticated,” never “any account in the system might be.”
Admin-Authorized External Identity Linking (full_auth Mode)
For the case mapping mode doesn’t cover - a full_auth plugin that must
remain the terminal identity authority, but needs to authenticate a pre-existing
user it did not itself provision (a SCIM-provisioned user, for instance, where
the login decision genuinely can’t be expressed as a static mapping rule) - the
(plugin_name, external_id) -> user_id table find_user reads from (above) can
also be populated by an administrator, out of band, instead of only by the
plugin’s own provision_user calls. The plugin side is unchanged:
find_user(external_id) still just resolves whatever is in that table today,
whether the entry came from the plugin provisioning it or from an admin linking
it.
Domain restriction is re-checked at resolve time, not only at link time. The
provision_domain_id/allowed_provision_domains bound (§6.B) is enforced both
when the link is created (below) and every time find_user resolves a
handle: the host re-reads the target user’s current domain_id and rejects the
resolution (auditing it as a mismatch, §6.E) if that user has since moved
outside the plugin’s configured domain set. A link-time-only check would leave a
stale window - an identity linked while in-domain, then administratively moved
to another domain, would otherwise remain authenticatable by a plugin that was
never granted reach into the user’s new domain. Because the check rides on the
user’s live domain_id, a domain move closes that reach immediately without
needing a separate link-cleanup step.
Why admin-authorized rather than plugin-self-service. This is the one
mechanism in this ADR that intentionally lets a full_auth plugin reach an
identity it didn’t create - so it has to be gated by something the plugin itself
cannot trigger. An admin action, taken once, out of band, requiring ordinary
Keystone RBAC (not the plugin’s own runtime logic), is that gate. A coarser
alternative - letting a plugin resolve any user within its configured domain
without a per-identity link - was considered and rejected: it would trade the
“only explicitly-authorized identities are reachable” guarantee for zero admin
overhead, and a buggy or exploited plugin (§1 Threat Model, actor 1) could then
authenticate as anyone in that domain, not just identities someone deliberately
opted in. The per-identity (or per-realm, below) authorization step is the
load-bearing control; nothing here is meant to be bypassable for convenience.
API. POST /v4/auth_plugins/{plugin_name}/identity_links with body
{external_id, user_id}. RBAC-tiered the same way ADR 0020 §9.A gates mapping
writes: system-admin authorization is required to link a user who holds any
system-scope role assignment; domain-admin authorization, scoped to the target
user’s own domain, suffices otherwise. The endpoint additionally enforces the
plugin’s provision_domain_id/allowed_provision_domains restriction (§6.B)
against the target user’s domain - a link can never place a user outside the
domain(s) the plugin was already configured to reach, keeping that invariant
uniform regardless of whether an identity arrived via self-provisioning or
admin-linking. Re-linking an external_id that already has an entry is rejected
(409 Conflict); an admin must explicitly
DELETE .../identity_links/{external_id} first - no silent overwrite, so an
external_id can’t be quietly reassigned to a different user by mistake or by a
compromised admin session skating past a diff review. Both create and delete are
CADF-audited via the same mandatory infrastructure as §6.E, and DELETE
additionally triggers the existing token-revocation pipeline for the unlinked
user_id (the same mechanism ADR 0020 §9.F uses when a virtual user is
disabled) - an unlinked identity can’t keep using tokens issued while the link
was live.
SCIM convenience. Rather than requiring an admin to hand-copy internal
user_id UUIDs, the same endpoint accepts
{scim_provider_id, scim_external_id} in place of {external_id, user_id}: the
host resolves user_id via the existing
index:scim:external_id:<domain_id>:<provider_id>:<type>:<external_id> index
(ADR 0024 §3.B) instead of introducing parallel storage, and sets the plugin’s
external_id to the SCIM externalId the IdP already assigned that user. This
is the direct answer to “users provisioned by SCIM need to be authenticatable
via a custom plugin”: an admin pairs the plugin with the SCIM realm’s
already-tracked identities, one link (or a small bulk batch) at a time, rather
than the plugin ever being able to decide that for itself.
Plugin Version Binding & Token Invalidation (full_auth Mode)
Mirroring the ruleset_version TOCTOU defense in ADR 0020 §5.5–§5.6 (a token
issued under one mapping ruleset is rejected if the live ruleset has since
changed), a token minted via WasmPlugin is invalidated when the plugin behind
its plugin_name is patched. The mechanism is a timestamp cutoff, not an
embedded hash: the FernetToken payload is a fixed variant set with no
plugin-bearing case (a WasmPlugin login mints an ordinary scoped token that
already records its own issued_at), so there is no room to embed a
plugin_sha256 in the token and re-compare it later. Instead, each plugin’s
config carries an optional valid_since timestamp (§5). On every verification
of a WasmPlugin-authenticated token, the host compares the token’s issued_at
against the valid_since configured for that plugin_name: if issued_at
predates valid_since, the token is rejected with a dedicated
PluginVersionMismatch error, forcing re-authentication against the current
plugin logic. An operator patching a plugin to fix a security bug bumps
valid_since (normally alongside the pinned sha256) to the cutover instant,
which invalidates every token the vulnerable version minted while leaving the
rest of the process running. This is verification-time only: a brand-new login
has no token yet (issued_at is set as the token is minted), so a past
valid_since never blocks fresh authentication - it only invalidates
already-outstanding tokens.
The trade-off relative to an automatic hash-drift check is that invalidation is
driven by an operator action (bumping valid_since) rather than falling out of
the sha256 change itself: an operator who swaps the .wasm and updates
sha256 but forgets to advance valid_since leaves the old version’s tokens
valid until they expire naturally. Treating valid_since as a mandatory
companion to any sha256 change - the same “plugin config change is a staged
rollout, not an ordinary edit” discipline §5 already calls for - is the
load-bearing operator convention here.
Bulk Revocation on Plugin Compromise (full_auth Mode)
Version binding above stops a token minted under a since-patched plugin from
being used, but does nothing about the persistent state a plugin already
wrote while it was live and trusted - accounts it provisioned via
provision_user, role assignments it granted via assign_role, and identity
links an admin created for it (“Admin-Authorized External Identity Linking”,
above). If an operator’s response to a compromise is “patch the plugin and move
on,” that state is exactly what an attacker who exploited it would want left
behind. Cleaning it up one DELETE .../identity_links/{external_id} at a time,
or by hand-querying the CADF audit trail (§6.E, which records plugin_name on
every such write) for everything to walk back, is only manual, error-prone
rollback under incident-response time pressure.
API. POST /v4/auth_plugins/{plugin_name}/revoke_all. System-admin only
- this is a cross-domain action by construction, since a plugin’s
provision_domain_id/allowed_provision_domains(§6.B) can span multiple domains and its role grants (§6.D) can land on any project within them, so no narrower RBAC tier is meaningful. Scoped entirely to the named plugin, in one call it:
- Disables (does not delete) every
user_idthe plugin provisioned viaprovision_user, and everyuser_idreachable only through an admin-authorized identity link to it (above) - reusing the same disable path ADR 0020 §9.F already uses for a disabled virtual user, not a new deletion code path. - Deletes every remaining
identity_linksentry for that plugin - the batched equivalent of the existing per-external_idDELETE, above. - Triggers the existing token-revocation pipeline for every affected
user_id, so a token minted before the operation ran cannot keep working on a since-disabled account - the same window the per-identityDELETEalready closes for one identity at a time, now closed for all of them at once.
It deliberately does not revoke the role assignments the plugin granted via
assign_role. Attributing a stored assignment to the plugin that created it
would require every grant to carry a per-record origin marker - exactly the kind
of per-write bookkeeping this ADR rejects for version scoping (below), and which
the assignment store does not otherwise need. Because disabling the account
already denies all access, a leftover grant is inert unless an operator later
re-enables that user; at that point it is the operator’s responsibility to
review the re-enabled user’s assignments against the CADF audit trail (§6.E
records plugin_name on every assign_role) and revoke any they deem
compromised via the existing per-grant revocation API. This keeps “get
everything shut off fast” free of schema additions, and leaves selective
assignment cleanup - like the selective account reinstatement discussed below -
a deliberate manual step rather than an automatic one.
Each of the above is individually CADF-audited exactly as its single-record
equivalent already is (§6.E) - this endpoint is a bulk driver of existing,
already-reviewed disable/revoke/unlink operations, not a new privileged code
path with its own semantics. It responds with a per-category count (users
disabled, links deleted) so the operator gets confirmation of blast radius
covered without a separate audit-trail query, and re-running it against a plugin
with no remaining state is a no-op (200, all-zero counts) - safe to include in
a standard incident-response runbook without first checking whether a prior run
already covered it.
Why plugin-name-scoped, not version-scoped. The action targets everything
attributable to plugin_name, not only writes made while one specific plugin
binary (sha256) was loaded. Scoping to a single vulnerable version would
require every provisioning/grant/link record to carry the sha256 active at
write time - bookkeeping this ADR does not otherwise need (version binding,
above, is a single per-plugin valid_since timestamp compared to a token’s
issued_at, and needs no per-record hash at all) - and would leave state from
any other version of the same plugin untouched, the wrong default the moment
an operator’s trust in a plugin binary has been broken. An operator confident
only one version is implicated can still hand-verify individual accounts against
the audit trail’s plugin_name + timestamp before re-enabling them; this
endpoint optimizes for “get everything shut off fast,” not selective
reinstatement.
Reserved Auth-Method Names
At config load, a plugin’s name is checked against the fixed set of builtin
method names (password, token, openid, application_credential, trust,
webauthn, mapped, k8s, admin, totp). A collision is a startup
configuration error - a plugin can never be registered under a name that would
shadow or be confused with a compiled-in auth method.
A route-mode plugin’s route_targets (§5) list is checked against the same
set, minus the strictly reachable subset: admin and trust may never appear
in a route_targets list regardless of plugin, since neither is a method a
router’s blast radius should ever be able to reach - this is a startup
configuration error, identical in posture to the name-collision check above.
5. Distribution & Loading
Plugin bytecode lives on the local filesystem. A new config section, one
sub-entry per plugin, follows the existing per-subsystem [section] key = value
convention (e.g. crates/config/src/k8s_auth.rs):
[auth_plugins]
plugins = acme_risk_sso,tf_appcred_router
# Header every trusted proxy sanitizes. Defaults to x_forwarded_for.
trusted_header = x_forwarded_for
# Comma-separated proxy CIDRs; empty trusts no forwarding header.
trusted_proxies =
[auth_plugin.acme_risk_sso]
path = /etc/keystone/plugins/acme_risk_sso.wasm
sha256 = 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
# Plugin version binding (§4 "Plugin Version Binding"): any token whose
# issued_at predates this instant is rejected with PluginVersionMismatch,
# forcing re-auth against the current module. Bump this to "now" whenever
# `sha256` changes so tokens minted by the previous binary stop verifying.
# Optional - omit it and no token is ever rejected on version grounds.
valid_since = 2026-07-02T00:00:00Z
# full_auth (default): plugin is the terminal identity authority, may call
# provision_user/find_user/assign_role, can reach pre-existing users only
# via an admin-created identity_link (§4). mapping: plugin only produces
# claims for the Mapping Engine (ADR 0020) to evaluate; provision_user/
# find_user/assign_role are config-load errors in this mode (§4). route:
# plugin runs pre-dispatch and may only relabel identity.methods + hand a
# payload to one allowlisted target method - provision_user/find_user/
# assign_role are also config-load errors in this mode (§4).
mode = full_auth
# Capabilities are host functions this plugin may call (§6 A-D only -
# auditing is mandatory host-side infrastructure, not a capability; see §6.E).
capabilities = http_fetch,provision_user,find_user
exposed_headers = X-Acme-Session-Id
allowed_hosts = risk.acme.example.com
# Host injects this header on every http_fetch call; the value itself is
# read from the referenced environment variable and never enters guest
# memory (§6.A).
http_fetch_auth_header = Authorization
http_fetch_auth_secret_env = ACME_RISK_API_KEY
# provision_user (§6.B) may only create users in this domain; a UserCreate
# targeting any other domain_id is rejected before reaching IdentityBackend.
provision_domain_id = domain_acme_sso
assign_role_allowed = reader,member
timeout_ms = 750
fuel_limit = 50000000
memory_limit_mb = 32
# Per-(plugin, source-IP) bucket, checked before the plugin-wide bucket below
# (§7 "Invocation Rate Limiting & Concurrency") - keeps one anonymous caller
# from exhausting this method's shared budget for everyone else.
invocation_rate_limit_per_source_per_minute = 20
invocation_rate_limit_per_minute = 300
max_concurrent_invocations = 16
# route-mode example: Terraform's OpenStack provider always sends
# identity.methods = [application_credential]. This plugin inspects the
# application_credential_id shape and, for IDs matching its own convention,
# reroutes the request to a separately-registered full_auth plugin
# (hacked_appcred_handler, not shown) that performs the real verification;
# every other application_credential request passes through unmodified to
# the builtin handler.
[auth_plugin.tf_appcred_router]
path = /etc/keystone/plugins/tf_appcred_router.wasm
sha256 = 3b5d5c3712955042212316173ccf37be9de53d6c84a5c7c8e6e0e5e7f5f8a1b
mode = route
# Only requests whose identity.methods includes one of these entries ever
# invoke this plugin at all - a plain `password` login never reaches it.
inspect_methods = application_credential
# Methods this plugin is permitted to reroute a request to. A Route
# response naming anything else is rejected as malformed (§4 "Guest
# Contract - route Mode"), never dispatched.
route_targets = hacked_appcred_handler
capabilities =
timeout_ms = 200
fuel_limit = 5000000
memory_limit_mb = 8
invocation_rate_limit_per_minute = 6000
max_concurrent_invocations = 64
Loading. All configured plugins are loaded and their checksums verified
independently, once, at process startup. If a given plugin’s file is missing or
its SHA-256 does not match the pinned value, that plugin is disabled - not
registered as an auth method, not reachable by any identity.methods entry -
and the host emits a CRITICAL-level structured log line plus a dedicated
metric/counter (e.g. keystone_auth_plugin_load_failure{plugin_name}) naming
the plugin and the mismatch, wired to whatever alerting an operator already has
on Keystone process health. The node itself, every other correctly verified
plugin, and every builtin auth method (password, openid, k8s, …) start
and serve normally. This is a deliberate change from an earlier draft of this
ADR, which made a checksum mismatch a hard process-startup failure for the whole
node - rejected here because a plugin failing to load degrades availability of
exactly one auth method (fail-closed at the request level, identical to any
other plugin runtime failure, §7), and blocking every builtin method
cluster-wide over one misconfigured plugin was a disproportionate availability
cost for what is very often a copy-paste typo in keystone.conf rather than a
genuine tamper/corruption event.
Cross-node divergence is the trade-off this creates, and it is accepted
explicitly rather than left implicit. Because each node verifies its own copy
of the .wasm file independently, a node with a correct hash keeps a plugin
live while a sibling node with a typo’d or stale hash disables it - the cluster
can run with that auth method inconsistently available across nodes until an
operator acts on the alert. This is judged preferable to the previous
whole-cluster-down failure mode precisely because it is loud rather than silent:
the CRITICAL alert exists specifically to convert what would otherwise be an
unnoticed divergence into a paged one. Operators who need the stronger
all-or-nothing guarantee this ADR previously provided can still get it
operationally - verify the pinned hash against every node’s .wasm file as a
pre-deploy gate in their rollout pipeline - and should continue to treat a
plugin config change with the same care as a schema migration, not an ordinary
config edit.
Why filesystem, not Raft/FjallDB. Given the cluster-global-only trust scope (§8), there is no per-domain data to replicate, so the main advantage of storing bytecode in the replicated log - automatic cross-node consistency for tenant-authored content - does not apply. Filesystem distribution defers that machinery until a per-tenant plugin model is actually needed.
6. Host Functions: Curated Capability Allowlist
A plugin can only call the host functions listed in its capabilities config
entry (§5). The security property is that an unlisted call can never be
successfully exercised, structurally or otherwise - not merely a permission
check that could be bypassed by a bug in the check itself.
Implementation deviation from “not registered at all.” The original intent
was that an ungranted function is absent from the guest’s import table entirely.
The actual implementation registers all four host functions (§6 A-D) into every
plugin’s compiled extism::Plugin module whenever any HostFunctions
provider is configured, and each function’s closure independently rejects a call
its plugin’s capabilities didn’t grant, before doing anything else. This is a
deliberate, documented substitution
(crates/auth-plugin-runtime/src/host_functions.rs), not an oversight:
wasmtime requires every guest-declared import to resolve at instantiation time
regardless of whether the compiled module actually calls it - an unresolved
import fails every invocation of that module, not just the specific call that
would have used it - so selectively omitting a function’s registration per
plugin is not viable for a single compiled module shared across
differently-configured invocations. From the guest’s perspective the result is
identical either way: an ungranted capability structurally cannot be exercised,
since the closure’s gate check runs before any host-side effect (DB write,
outbound HTTP call, etc.) and is not something plugin-supplied input can
influence or bypass.
A. http_fetch
Backed by Extism’s built-in HTTP bridge, restricted to the plugin’s
allowed_hosts list (§5) - the same allow-list-of-hosts posture already
implicit in how keystone-rs talks to fixed external services (OPA base URL,
K8s API server). Unlike the existing reqwest::Client in
crates/keystone/src/k8s_auth_client.rs:73-75, which sets no per-request
timeout, the host-side bridge for http_fetch must enforce both a connect
and a total-request timeout derived from the plugin’s timeout_ms budget -
closing a gap noted during this ADR’s research rather than propagating it into a
new, more exposed (third-party-triggered) call path.
Because every http_fetch call is ultimately triggered by an anonymous,
pre-authentication caller (§1 Threat Model, actor 2), the bridge is an SSRF
surface and is hardened accordingly, as a hard requirement of this ADR rather
than an implementation detail:
- Connect-time IP validation, not just hostname matching.
allowed_hostsis a hostname allowlist, but DNS is attacker-adjacent (a plugin’s configured third-party host is not attacker-controlled, but its DNS resolution path is outside Keystone’s control). The bridge re-resolves the hostname at connect time on every call (no long-lived resolution cache) and rejects the connection if the resolved address falls in a private, loopback, link-local, multicast, or cloud-metadata range (RFC 1918, RFC 4193,127.0.0.0/8,169.254.0.0/16including169.254.169.254, etc.) - regardless of what the configured hostname was. The socket then connects to the exactIpAddrthat was validated - the host does not hand the hostname back to the HTTP client to resolve a second time. This distinction is load-bearing, not pedantic: a validate-then-re-resolve implementation reopens the exact DNS-rebinding TOCTOU this control exists to close, because the second lookup can return a private address the first one didn’t. Validation and connection must observe the same resolved address. This closes the standard DNS-rebinding bypass of a hostname-only allowlist. - No automatic redirect following. The bridge does not follow HTTP redirects
by default; a 3xx response is returned to the guest as-is. An operator who
needs redirect support opts in per-plugin
(
http_fetch_follow_redirects = true), and even then each redirect hop is re-validated against bothallowed_hostsand the IP-range check above before being followed - a redirect is not permitted to silently escape the allowlist. The whole chain shares a singletimeout_mswall-clock budget, not one budget per hop: each hop’s own request timeout is the remaining time before that shared deadline, so a redirect chain cannot cost up toMAX_REDIRECTS + 1times the plugin’s configured budget - the total wall-clock cost of a (possibly multi-hop)http_fetchcall stays bounded to the sametimeout_ms§7’s per-invocation deadline is sized against. - Outbound secrets are host-injected, never guest-visible. If a plugin needs
to authenticate to its external service, the secret value (API key, bearer
token) is not embedded in the
.wasmbinary and is not passed into guest memory. Instead,http_fetch_auth_header+http_fetch_auth_secret_env(§5) tell the host which header to attach and which host-side environment variable (or, in a future iteration, secret store reference) to read the value from; the host attaches it to the outbound request after the guest has already specified the rest of the request, so the plugin’s WASM code and the.wasmfile distributed to every node never contain the credential in any form.
This is a stricter posture than the existing reqwest-based clients elsewhere
in keystone-rs (OPA, K8s TokenReview) because those call fixed,
operator-configured single endpoints that are never influenced by an anonymous
caller’s request; http_fetch is triggered by exactly such a caller on every
invocation.
B. provision_user
provision_user(external_id: String, user: UserCreate) -> ResolvedIdentityHandle
(§4 “Identity Binding”). Wraps
IdentityBackend::create_user(state: &ServiceState, user: UserCreate) -> Result<UserResponse, IdentityProviderError>
(crates/core/src/identity/backend.rs:119-123) - the same call the Unified
Mapping Engine’s Local identity mode uses today
(find_or_create_federated_user,
crates/core/src/mapping/service.rs:374-433) - but, critically, is namespaced
by (plugin_name, external_id) rather than performing a general
create-or-lookup by username: a repeat call with the same external_id returns
the same provisioned user rather than erroring or creating a duplicate, and no
other plugin or auth method can resolve a user_id through this mapping. This
idempotency must be atomic on the (plugin_name, external_id) mapping
(upsert / unique-constraint-backed insert-or-get), not a check-then-create: with
max_concurrent_invocations > 1 two first-login requests for the same
external_id run in parallel Stores, and a non-atomic implementation races
into either a duplicate User row or a unique-constraint error surfaced as a
spurious auth failure. The mapping’s own uniqueness on
(plugin_name, external_id) is the serialization point. This directly satisfies
requirement 3 (“provision user on first login”): the guest calls
provision_user with the external identifier and fields it has already verified
(federated attributes, username), and the host performs the actual DB-backed
creation under the existing identity backend - the plugin never gets raw DB or
ServiceState access, only this single narrow, namespace-scoped entry point.
The host does not return the real user_id to the guest - it returns a
ResolvedIdentityHandle that the guest can only use by echoing it back in
Allow.resolved_identity.
Field sanitization. The UserCreate handed to the host is not written
through verbatim. Identity-critical fields are host-controlled, not
guest-controlled:
id- always host-generated. A guest-suppliedidis rejected (not ignored), because a plugin that could name the new user’s UUID could target an existing account’sid- colliding with, or attempting to overwrite/alias, a password-authenticated admin - which is exactly the arbitrary-user_idclaim the identity-namespace design (§4) exists to prevent, smuggled in one layer down through the create path.domain_id- see domain restriction below.- Any privilege- or auth-relevant option field (e.g. federated-vs-local flags,
password material, admin-ish user options) is either host-fixed or rejected;
the guest may only supply the narrow set of descriptive attributes it has
actually verified (external username, display attributes). The accepted-field
set is an allowlist, so a field added to
UserCreatein the future is excluded by default rather than silently passed through from the guest.
Domain restriction. user.domain_id is not accepted verbatim from the
guest. Each plugin’s config declares provision_domain_id (§5) - a single fixed
domain (or, if genuinely needed, allowed_provision_domains, a small explicit
list) - and any UserCreate targeting a domain outside that set is rejected
before reaching IdentityBackend. This mirrors ADR 0020’s allowed_domains
whitelist for federated provisioning (0020 §3, §7.3 AllowedDomainsRequired)
and bounds a buggy plugin (§1 Threat Model actor 1) to the domain(s) an operator
explicitly intended to hand it write access to, rather than “any domain in the
cluster.”
C. find_user
find_user(external_id: String) -> Option<ResolvedIdentityHandle> (§4 “Identity
Binding”). Read-only lookup within the same (plugin_name, external_id)
namespace provision_user writes to - used for idempotent “does this identity
already exist” checks before provisioning. This is not a general
username/attribute search over the Keystone user table: find_user structurally
cannot resolve a handle for any account it (or a prior invocation of the same
plugin) did not itself create via provision_user. This is the load-bearing
restriction that makes handle-based identity binding (§4) an actual
authentication-bypass defense rather than an extra function call in front of the
same bypass. For entries populated by admin-authorized linking rather than the
plugin’s own provisioning, find_user additionally re-validates the resolved
user’s live domain_id against the plugin’s provision_domain_id /
allowed_provision_domains on every call (§4 “Admin-Authorized External
Identity Linking”), so a post-link domain move revokes reach immediately.
D. assign_role
Grants a role assignment for the newly resolved principal (identified by a
ResolvedIdentityHandle from B/C - the same anti-impersonation constraint
applies here: a plugin cannot assign a role to an arbitrary user_id it merely
names). Kept as a separate, individually-grantable capability from
provision_user so a plugin can be scoped to “create users but never touch role
assignments” if an operator wants that split.
Scope restriction. A plugin’s assign_role grant is bounded on three axes,
all enforced host-side:
- Which role -
assign_role_allowed(§5): an explicit, per-plugin allowlist of role names it may assign. - Which target project/domain - the assignment may only land on a project or
domain within the plugin’s own
provision_domain_id/allowed_provision_domainsset (§6.B). The role name allowlist alone does not bound where the grant applies; without this, a plugin holdingmemberinassign_role_allowedcould grant its self-provisioned usermemberon an arbitrary project - including a sensitive one in another domain (e.g. theadminproject). A grant whose target project/domain falls outside the configured provisioning domain(s) is rejected before reaching the assignment backend, the same fail-loud posture as theprovision_userdomain check. - Which scope type - the load-bearing control against privilege escalation:
assign_roleonly ever targets project/domain scope on the identity the same invocation resolved, and there is no code path by which a WASM plugin invocation can reach system scope - mirroring ADR 0020 §9.A’s rule thatAuthorization::Systemrequiresis_system: trueplus admin-level authorization, neither of which a plugin invocation ever carries. (An earlier draft of this ADR additionally proposed rejectingassign_role_allowedentries for roles that “carry admin privileges” - cut here because Keystone roles have no intrinsic, statically-inspectable privilege flag at config-load time; role semantics are policy-interpreted, e.g.role:adminin Rego, not a DB property queryable before OPA/the database are even necessarily reachable. Claiming that check existed would have been a control on paper only. The scope-type restriction above is the real, implementable defense and is sufficient given plugins never carry system scope regardless of which role name is assigned.)
E. Mandatory Audit Wrapping (not a capability)
Unlike A–D, auditing is not something a plugin opts into or calls - it is
host-side infrastructure that unconditionally wraps every invocation of A–D and
the top-level authenticate outcome, regardless of what a plugin’s
capabilities list contains. Each wrapped call emits a CADF-compatible event
via the existing AuditHook (crates/core/src/events.rs:107-115)
infrastructure (ADR 0023) - a dedicated EventPayload variant recording
plugin_name, the host function called (if any), and outcome. It is registered
as an inline, fail-closed hook (matching AuditHook’s existing semantics: if
the audit hook itself fails, the triggering call fails closed too), not the
fire-and-forget ProviderHooks pattern. Because this is infrastructure rather
than a capability, there is no capabilities entry that enables or disables it,
and no way for a plugin - buggy or otherwise - to provision a user or assign a
role without that action being recorded.
F. Sandbox Baseline: No WASI
No WASI (preview1 or preview2) imports are registered into any plugin’s
extism::Plugin instance - no ambient filesystem, clock, random, or environment
access beyond what A–D above explicitly provide. This is a hard requirement of
the host-function registration step (§3), not a follow-up item: a plugin’s only
way to reach outside its own linear memory is through the capabilities in A–D
that its config explicitly grants.
Capabilities not listed above (arbitrary internal method invocation by name, raw storage access, token minting) are intentionally not exposed. If a future use case needs one, it is added to this fixed list explicitly, in a follow-up ADR amendment - not opened up generically.
7. Resource Limits & Failure Semantics
Every authenticate invocation runs under three independent bounds, mirroring
the existing precedent of the mapping engine’s regex evaluator (2-second
deadline, 4 KiB per-value cap - ADR 0020 §5.1), each configurable per-plugin
with cluster-wide defaults:
- Fuel metering (
fuel_limit) - bounds total instruction count, independent of wall-clock (protects against a plugin that spins without making forward progress on I/O, where a timer alone might not fire predictably under load). - Wall-clock deadline (
timeout_ms) - bounds total invocation time including anyhttp_fetchcalls the guest makes. - Linear memory cap (
memory_limit_mb) - bounds guest heap growth.
On any failure - a WASM trap, fuel exhaustion, timeout, an attempted call to
an undeclared host function, or a response that fails to deserialize as
AuthPluginResponse - the login attempt is rejected (generic
401 Unauthorized; no internal detail leaked to the client) and a CADF
Failure audit event is emitted with a sanitized reason (§6.E). There is no
automatic fallback to another [auth] methods entry: exactly as today, the
client selects which method(s) it is attempting via the identity.methods field
of the auth request, and a plugin failing its own method is not silently retried
as password. This fail-closed posture was chosen because an auth method is
exactly the kind of fail-closed-only inline hook AuditHook already models
(crates/core/src/events.rs:102-106) - a partially-failed authentication
decision must never be treated as an implicit “try something weaker instead.”
Isolation between requests. One wasmtime::Engine and one compiled
extism::Plugin module are shared per process (compilation is expensive;
correctness of the compiled module is immutable), but each authenticate call
gets a fresh Store/plugin instance state - no mutable state persists across
invocations. This prevents one request’s execution from leaking data into or
influencing another’s, and means a fuel/memory exhaustion in one invocation
cannot degrade a concurrent one.
Invocation Rate Limiting & Concurrency
Per-invocation resource bounds (fuel, wall-clock, memory) limit the cost of a
single call, but every plugin invocation is reachable by an anonymous,
pre-authentication caller (§1 Threat Model, actor 2) - nothing above bounds how
many invocations happen concurrently or per unit time. Left unbounded, this is
both a direct resource-exhaustion DoS against Keystone (unbounded parallel
Store/wasm instances) and an SSRF/DDoS amplification vector against whatever
host is in allowed_hosts (§6.A), since each anonymous login attempt can
trigger an outbound HTTP call.
Three bounds apply per plugin, mirroring the two-tier rate limiter the Unified
Mapping Engine already uses for its own externally-triggered shadow-registry
writes (shadow_registry_creation_rate_limit, shadow_registry_auth_rate_limit
- ADR 0020 §7.2), reusing the
governorcrate already a workspace dependency (Cargo.toml:82):
- Per-source rate limit (
invocation_rate_limit_per_source_per_minute, §5) - a sliding-window token bucket keyed on(plugin_name, remote_addr), using the same trusted, non-spoofableremote_addrAuthPluginRequestcarries (§4) - never a rawX-Forwarded-Forvalue unless it arrived over a configured trusted-proxy hop. Checked first, in front of bound 2: a caller from a single source exceeding it is rejected with429 Too Many Requestsbefore it can touch the plugin-wide budget at all, audited as aRateLimitedoutcome scoped to that source. This is what actually stops one anonymous attacker (§1 Threat Model, actor 2) from being the party who burns bound 2’s shared budget for everyone else on that method. - Invocation rate limit (
invocation_rate_limit_per_minute, §5) - a sliding-window token bucket per plugin, shared across all sources. Exceeding it rejects furtherauthenticatecalls for that plugin with429 Too Many Requestsuntil the window clears, audited as aRateLimitedoutcome. - Concurrency cap (
max_concurrent_invocations, §5) - a semaphore bounding how manyStoreinstances (and therefore how many in-flighthttp_fetchcalls) may execute simultaneously for that plugin. A request arriving when the cap is saturated is rejected the same way as (1)/(2) rather than queued, to avoid building an unbounded backlog of pending authentications under load.
Defaults are conservative and layered (§5 example: 20/min per source, 300/min per plugin, 16 concurrent) and all three are per-plugin, not global, so one plugin’s traffic cannot starve another’s budget.
Bound 1’s keyed store is shrunk on a periodic tick, not per-request. Every distinct source address bound 1 sees - necessarily including anonymous, pre-authentication callers (§1 Threat Model, actor 2) - allocates an entry in the underlying keyed rate-limit store, which is never freed on its own; left unaddressed, a long-running process accumulates one entry per distinct source address it has ever seen. The process’s existing minute-scale background maintenance tick evicts entries whose bucket has fully recovered (i.e. is indistinguishable from a source never seen before) for every loaded plugin’s bound-1 store - a straightforward memory-bookkeeping fix, not a rate-limiting behavior change: an evicted, truly-idle source’s next request is treated exactly as a first-ever request would be, which is already correct.
Internal/admin interface behavior. Bound 1 applies only to public ingress.
An unproxied public request is keyed by its raw TCP peer; a proxied public
request is keyed by the client resolved through trusted_header. Internal mTLS
and admin requests deliberately pass remote_addr = None, even though the mTLS
listener records its shared mesh peer for audit logging, so unrelated internal
services cannot exhaust one another’s per-source bucket. Those requests retain
bounds 2 and 3.
Response Payload Bounds
Fuel/memory/timeout bound what a plugin can do internally; they do not bound
the size or shape of the AuthPluginResponse JSON the host must deserialize
from the guest’s output. Mirroring the caps ADR 0020 already applies to its own
claim-derived data (4 KiB per claim value, 256-char interpolation limit - 0020
§5.1, §5.4), the host enforces, before attempting to deserialize the response:
- A hard cap on total response size (default 64 KiB) - an oversized response is rejected without being parsed.
- A cap on the
claimsmap: at most 64 entries, keys at most 256 bytes, values at most 4 KiB each (matching ADR 0020’s existing claim-value limit). - Structural namespacing, not a denylist. Every claim a plugin emits is
carried under a single reserved envelope key and surfaced to downstream OPA /
SecurityContextconstruction only asplugin_claims.<plugin_name>.<key>- never merged into the top-level claim namespace. A plugin therefore cannot set, shadow, or collide with any privilege-relevant top-level key regardless of what it names its own claims, because its keys structurally live in a sub-object no policy reads as authoritative identity/authorization input. This is deliberately a structural containment (like the(plugin_name, external_id)identity namespace in §4) rather than a blocklist: a denylist of forbidden keys (is_system,is_admin,roles,effective_roles, …) can only ever enumerate the privilege-relevant keys known today - the moment an operator writes a Rego policy or a futureSecurityContextfield that trusts a top-level claim not on the list (e.g.groupsdriving group→role mapping,project_id,trust_id), the denylist is silently incomplete and the plugin can inject it. Namespacing closes that entire class instead of chasing it key by key. As defense-in-depth the host additionally rejects (fails the wholeAllowclosed) any attempt by a plugin to emit the reserved envelope key itself or a claim key prefixed__keystone, the same fail-loud posture as ADR 0020’sSystemTokenShadowingwrite-time check (0020 §7.3).
Any violation is treated as a malformed response under the failure semantics above: the login is rejected, and the specific bound that was exceeded is recorded in the audit event (§6.E) for operator diagnosis, without echoing attacker-influenced content back into logs.
8. Open Questions / Future Work
mapping-mode version binding at verification - not implemented. See §4 “Plugin-version binding formappingmode.” Amapping-mode token has no plugin-recoverable field in itsFernetTokenpayload, so bumping amapping-mode plugin’svalid_sincedoes not invalidate outstanding tokens the way it does forfull_auth. Closing this requires either widening a token payload to carry a plugin-recoverable linkage (the per-record bookkeeping this ADR otherwise avoids) or a different invalidation mechanism entirely. Until then, incident response for a compromisedmapping-mode plugin relies on revocation events or short token TTLs, notvalid_since.- Per-domain plugin scoping. This ADR deliberately restricts plugins to
cluster-global, system-admin-installed, to keep the trust model simple for a
first iteration. Extending this to a
(domain_id, provider_id)-scoped model - parallel to how OIDC/K8s/SPIFFE providers work today (ADR 0020 §2) - is plausible future work, but raises open questions this ADR does not answer: could a domain admin install a plugin that callsprovision_useroutside their own domain? Shouldallowed_hostsbe domain-restricted too? A follow-up ADR should address this once there is a concrete multi-tenant use case. - Hot reload / upload API. Startup-only loading (§5) is the simplest correct starting point. An admin API to upload new plugin versions without a process restart - likely backed by Raft/FjallDB replication once plugins are no longer purely cluster-global-static - is deferred.
- Signing beyond a pinned checksum. SHA-256 pinning (§5) catches corruption and accidental drift but is not a substitute for a real code-signing chain if plugins are ever sourced from outside the operator’s own build pipeline. Not needed while distribution is “operator places the file themselves,” but worth revisiting if plugins become installable from a registry.
- Secret rotation.
http_fetch_auth_secret_env(§6.A) reads a secret from a host-side environment variable at call time, which is enough to keep the value out of guest memory and out of the distributed.wasmfile, but rotating it still requires a process restart today, same as any other env-var-sourced Keystone secret. A dedicated secret-store integration (rotation without restart) is future work, not required for this ADR’s threat model (§1). - Remediation after a plugin is pulled for a security bug - addressed.
Resolved by “Bulk Revocation on Plugin Compromise” (§4): a single
POST /v4/auth_plugins/{plugin_name}/revoke_alldisables everything the plugin provisioned/granted/was linked to and revokes affected tokens, on top of the token-level protection version binding (§4 “Plugin Version Binding”) already provides. What remains genuinely open: that endpoint is deliberatelyplugin_name-scoped, not scoped to a single plugin binary version (see its “Why plugin-name-scoped” rationale) - an operator who wants to reinstate only the state attributable to a different, non-vulnerable version of the same plugin must still identify and re-enable that subset by hand against the audit trail. - Coarser domain-scoped resolution (considered, rejected). An earlier draft
of this ADR considered letting a
full_authplugin resolve any user within itsprovision_domain_idwithout a per-identity admin link, purely to reduce admin overhead for operators with many pre-existing users to onboard. Rejected: it would mean a buggy or exploited plugin (§1 Threat Model, actor 1) could authenticate as anyone in that domain, not just identities someone deliberately opted in - trading away the one guarantee (“only explicitly-authorized identities are reachable”) this design is built around, for convenience. The SCIM bulk-linking convenience above (resolving viascim_provider_id/scim_external_id) covers the “many users to onboard” case without that trade-off - it’s still one explicit link per identity, just without requiring the admin to already know the internaluser_id.
9. Consequences
Positive
- Operators can add custom authentication logic without forking
keystone-rsor waiting on an upstream release - the stated requirement. - The curated host-function allowlist (§6) means the security review surface for “what can a plugin actually do” is a fixed, auditable list per plugin, not “whatever Rust code can reach.”
- Namespace-scoped identity binding (§4) structurally prevents a plugin from
authenticating as an arbitrary existing account - not just by blocking a raw
user_idclaim, but by makingfind_user/provision_userincapable of resolving any identity outside that plugin’s own(plugin_name, external_id)mappings. This is the strongest guarantee in this design: it bounds the blast radius of a buggy or exploited plugin (§1 Threat Model, actor 1) to “can authenticate as identities it itself provisioned,” never “can authenticate as anyone already in the system.” - Fail-closed failure handling (§7) and mandatory, non-optional CADF auditing (§6.E) mean a misbehaving or exploited plugin cannot silently degrade into weaker-than-expected authentication or provision/grant without a trace.
- Per-source, per-plugin rate limiting and concurrency caps (§7) bound the damage an anonymous caller (§1 Threat Model, actor 2) can do simply by hitting the login endpoint, and specifically prevent a single bad actor from exhausting one auth method’s shared budget for every legitimate user of it - extending the same two-tier pattern ADR 0020 already established for its own externally-triggered writes with a source-scoped front tier.
- A bulk
revoke_alladmin endpoint (§4 “Bulk Revocation on Plugin Compromise”) turns “a plugin was compromised” from a manual, per-record cleanup exercise under incident-response pressure into a single audited call that disables everything the plugin ever provisioned, granted, or was linked to. http_fetch’s connect-time IP re-validation and host-injected secrets (§6.A) close the standard SSRF and credential-exposure pitfalls of an “allowed-hosts HTTP proxy” feature up front, rather than as a follow-up hardening pass.mappingmode (§4) lets a plugin serve pre-existing users - including SCIM-provisioned ones (ADR 0024) - without any identity-binding machinery at all, by delegating the actual decision to the already-reviewed Mapping Engine (ADR 0020). This is a strictly additive safety property: the plugin structurally cannot terminate authentication in this mode, so it inherits the Mapping Engine’s existing guarantees rather than needing new ones.- Admin-authorized external identity linking (§4) gives
full_authplugins a path to pre-existing users too, for casesmappingmode can’t express, without ever letting the plugin itself decide who it can authenticate as - the gate is an ordinary RBAC-checked, audited, revocable admin action, not new plugin-facing trust. - Reuses Extism’s existing HTTP allow-list and resource-limiting primitives rather than hand-rolling a WASI-sockets bridge and a custom fuel/timeout system.
- Closes an existing gap (missing per-request HTTP timeout in
k8s_auth_client.rs) for the new, more exposed call path, without needing to touch the existing K8s client itself. routemode (§4) lets a client that can only ever send a fixed method name - Terraform’sapplication_credential-shaped auth is the motivating case - be transparently redirected to the handler that actually knows how to verify its credential, without collapsing the routing decision and the authentication decision into one piece of code. Because the target method still performs its own full verification and the router itself can never resolve or grant anything, this reuses the same “narrow, structurally-bounded capability” posture as the rest of this ADR rather than introducing a new trust model.
Negative
- A new runtime dependency (
extism+wasmtime) with its own release cadence, security-patch surface, and binary-size cost, orthogonal to the existinginventory-based plugin model (ADR 0018) - the codebase now has two distinct extensibility mechanisms, which must be kept clearly documented as serving different purposes (first-party static vs. third-party dynamic). - Filesystem-based distribution (§5) means the operator, not
keystone-rs, is responsible for keeping the.wasmfile and its pinned hash consistent across every node; a mismatch on a given node - including a plain typo in the pinned hash, not just a genuine tamper/corruption case - disables only that plugin on that node (§5), which can leave one auth method inconsistently available across the cluster until an operator acts on the accompanyingCRITICALalert. This trades the previous design’s stronger (whole-cluster-blocking) consistency guarantee for availability, and still raises the operational cost of a plugin update to that of a coordinated, carefully-staged rollout if an operator wants to avoid the divergence window entirely. - Cluster-global-only scoping (§8) means this does not yet serve a multi-tenant “let domain admins bring their own auth plugin” use case - only system admins can install plugins.
- Guest-language plugin authors must target Extism’s PDK ABI, which is a new
toolchain requirement distinct from ordinary
keystone-rsRust development. - The namespace-scoped identity model (§4) and mandatory audit wrapping (§6.E)
add host-side bookkeeping (per-
Storehandle maps, a dedicated(plugin_name, external_id)mapping table, non-bypassable hook dispatch) beyond what a naive “trust the plugin’s JSON” implementation would need - a deliberate complexity/safety trade-off given this is an authentication surface. - Persistent state a plugin creates or is linked to (provisioned users, granted roles, admin-created identity links) is not automatically undone when the plugin is later patched or removed for a security issue - only future tokens are blocked (§4 “Plugin Version Binding”). Cleanup today is a manual, audit-log-driven operator task (§8).
- Three operating modes, a new admin API (
identity_links), and an amendment to ADR 0020’sIdentitySourceenum andMappingContextpayload (§4) widen this ADR’s surface area beyond a single, self-contained mechanism - an operator now has to understand which mode a given plugin runs in to reason about what it can reach, andmapping-mode plugins depend on an admin having separately authored Mapping Engine rules for them (§4 step 4) or they authenticate no one. routemode is reachable by a strictly larger slice of traffic thanfull_auth/mappingplugins - every request whoseidentity.methodsmatches itsinspect_methodslist, not just requests already addressed to it by name- so it sees raw credential material (headers, payload fields) for logins it
may ultimately have no involvement in beyond
Passthrough. This is a real increase in what third-party WASM code is exposed to compared to the rest of this ADR’s “opt-in by name” model, contained only byinspect_methodsscoping and the payload/header allowlists already used elsewhere (§4).
- so it sees raw credential material (headers, payload fields) for logins it
may ultimately have no involvement in beyond
See Also
doc/src/adr/0018-plugin-linking.md- the static/compile-time counterpart this ADR deliberately does not replace.doc/src/adr/0017-security-context.md- the validation pipeline a plugin-authenticated principal flows through unchanged.doc/src/adr/0020-mapping-engine.md§5.1 - precedent for bounded, timeout/size-capped evaluation of untrusted-shaped input.doc/src/adr/0020-mapping-engine.md§2, §3, §5.3, §9.A -IdentitySource,MappingRuleSet/allowed_domains,MappingContext, and admin-write RBAC tiering, all extended or reused bymappingmode and identity linking (§4).- [
openstack_keystone_core_types::mapping::resolution::IdentitySource] enum, gains theWasmPluginvariant formappingmode. - [
MappingContext] -mapping-mode token invalidation reuses its existingmapping_idto recover the plugin’svalid_sincefrom the matched ruleset’sIdentitySource::WasmPlugin; no new field is added. doc/src/adr/0023-audit.md- CADF audit event model reused for plugin invocation auditing.doc/src/adr/0024-scim-v2-provisioning.md§3.A–B -ScimResourceIndexand theexternalIdlookup index reused by the SCIM identity-linking convenience (§4).- [
AuthenticationContext] enum. - [
AuthenticationContext::Mapping], the variant a successfulmapping-mode login produces. - [
openstack_keystone_core::identity::backend::IdentityBackend::create_user], the target of theprovision_userhost function.
26. Native Stateless OAuth2 / OpenID Connect Provider
Date: 2026-07-06
Status
Proposed
Reference
Extends ADR 0006 (Federation IDP), ADR 0016-v2 (Distributed Secure Storage), ADR 0017 (SecurityContext Architecture), ADR 0020 (Unified Mapping Engine), ADR 0022 (Handler Rate Limiting), and ADR 0023 (CADF Auditing Architecture). This document completely supersedes and replaces the legacy draft of ADR 0026, which isolated JSON Web Tokens (JWTs) exclusively to external third-party consumers. This record formalizes the structural, cryptographic, and architectural pipeline necessary to elevate the signed JWT access token to a primary citizen natively ingested across the internal OpenStack control plane.
1. Context & Motivation
Traditional iterations of OpenStack authentication rely entirely on the manual
exchange of symmetric Fernet tokens or blocking back-channel API validation
calls. While previous architectural blueprints for keystone-rs introduced a
centralized Unified Mapping Engine (ADR 0020) to ingest external
cryptographic assertions, the outbound token tracks remained bound to the local
cluster. Restricting the outbound OAuth2 capability to third-party integrations
(e.g., “Login with OpenStack” for Grafana) introduced a severe Circular Token
Exchange Trap. External cloud-native operators or containerized workloads
authenticating via OIDC had to immediately execute an RFC 8693 Token Exchange
round trip to trade their JWT for a legacy Fernet token before they could make a
single call to Nova or Neutron.
To scale to the performance requirements of modern public cloud hyperscalers
(AWS, GCP, Azure), keystone-rs must act as an authoritative OAuth2
Authorization Server / OpenID Connect Provider (OP) whose tokens are directly
consumed by OpenStack infrastructure components.
By building an Egress Token Minting Pipeline that mirrors the security
configurations of our Inbound Ingress Validation pipeline, keystone-rs can
issue compact, cryptographically signed, stateless access tokens. These tokens
encapsulate the entire user identity, project scopes, and effective roles inside
the cryptographic claims payload. This configuration empowers downstream Python
services to authorize requests completely offline via memory-bound signature
verification, cutting the central database and network lookup bottleneck to
absolute zero.
Primary Use Cases
Being an authoritative OAuth2/OIDC Provider is not an abstract compliance checkbox; it unlocks concrete, high-leverage scenarios that neither Fernet nor the Python JWS token provider can serve:
- Cloud-native workloads calling OpenStack APIs directly. Kubernetes
operators (Cluster API, Crossplane), CI/CD pipelines (GitHub Actions /
GitLab OIDC-style workload federation), and Terraform controllers hold
short-lived OIDC credentials natively. Today they must trade them for a
Fernet token first (the Circular Token Exchange Trap, above). With the OP in
place, a
client_credentialsgrant yields a JWT that Nova/Neutron accept directly — no long-lived application credentials embedded in cluster secrets. - Offline, per-request validation at hyperscaler request rates. Every
Fernet validation is a back-channel keystonemiddleware round trip to
Keystone. Signed JWTs are verified in-memory by the downstream middleware
(§6), by Envoy/API gateways at the edge, and by service meshes (e.g. Istio
RequestAuthentication) — removing Keystone from the data path of every OpenStack API call. This is the single largest scalability win in this ADR. - “Login with OpenStack” for the surrounding ecosystem. Grafana, Harbor, ArgoCD, internal developer portals, and any standard OIDC RP can authenticate users against Keystone with stock OIDC libraries — Keystone becomes the identity anchor for the whole cloud’s tooling, not just for OpenStack services.
- Replacing long-lived machine secrets with short-lived tokens. Application credentials and EC2 keys are long-lived bearer secrets stored client-side. OAuth2 clients with 15-minute access tokens plus rotating refresh tokens (with family-tree breach detection, §9) shrink the credential theft window from months to minutes.
- Modern CLI login. The Device Authorization Grant (§7.C) gives
openstack/oscCLI users browser-based login with MFA/passkey support on headless machines — the flow every major cloud CLI (aws sso, gcloud, az) already uses, impossible with password-in-clouds-yaml Fernet flows. - Standards-based delegation (v2, §12). Trusts, application credentials, and EC2 delegation re-expressed as RFC 8693 Token Exchange, plus on-behalf-of downscoping for service-to-service hops (Nova → Neutron with a narrowed, short-TTL token instead of forwarding the user’s full bearer token).
Use cases 1 and 2 are the strategic drivers: they shed load and unblock cloud-native adoption. Use cases 3-5 are adoption accelerators that fall out of the same machinery nearly for free.
Threat Model & Defensive Boundaries
- Malicious or Compromised Relying Parties (RPs): An external application
consuming an
id_tokenoraccess_tokenmust be structurally barred from leveraging that credential to access native OpenStack core APIs. This is achieved by enforcing strict, segregated audience (aud) targeting: anauthorization_code/refresh_tokengrant only ever produces an OpenStack-capable access token (aud: "openstack-apis:{domain_id}", carryingopenstack_context/roles) when the client explicitly requested and was granted theopenstack:apiscope (§4, “Scope Validation”). Every other RP - including every client that only wants “Login with OpenStack” identity display - receives a minimalOidcAccessTokenClaims(§4) whoseaudis its ownclient_id, structurally incapable of passing downstreamaudverification (§6) against any OpenStack service. - Perimeter Network Interception at Ingress Handlers: Authorization code
hijacking, Cross-Site Request Forgery (CSRF) on
/authorize, and open-redirector phishing vectors are closed by design through mandatory PKCE verification (S256only), exact-match redirect allowlists, and persistent runtimestatebindings. - Cryptographic Signing Key Exposure: Because issued JWTs cross the
enterprise trust boundary, a signing-key compromise cannot be handled within
internal clusters. This requires an immutable,
kid-addressed JSON Web Key Set (JWKS) with multi-generational public key publishing windows.
2. Decision Summary
| Architectural Axis | Formal Decision |
|---|---|
| Cluster Operational Role | Authoritative OAuth2 Authorization Server & OpenID Connect Provider (OP). |
| Token Architecture | Stateless JWTs for id_token and access_token, default 15-minute exp for both. Stateful rotating refresh tokens with family tracking in Raft + FjallDB (OAuth 2.1 §4.1.4), default 30-day idle lifetime ([oauth2] refresh_token_lifetime_days), reset on each successful rotation. |
| Cryptographic Track | ES256 (default) or RS256 configurable via [oauth2] signing_algorithm. |
| Discovery & JWKS | Per-domain issuer, per ADR 0006. GET /v4/oauth2/{domain_id}/jwks + /.well-known/openid-configuration. |
| Scope Model | Standard OIDC scopes (openid, profile, email) for identity display. openstack:api is a distinct, explicit resource scope gating whether authorization_code/refresh_token grants ever receive OpenStack authorization data (§4); role resolution itself runs via the mapping engine/claims template, not OAuth2 scope. |
| Key Synchronization | Distributed via Raft + FjallDB log replication. |
Target Audience (aud) | Domain-bound service identifier (aud: "openstack-apis:{domain_id}") for internal control plane verification; not a single cluster-wide value (see §4 threat note). |
| Downstream Acceptance | Executed via a lightweight, custom Python WSGI middleware injected into legacy Paste Deploy pipelines. |
| Machine Workspace Storage | Typically resolves via IdentityMode::Ephemeral shadow registration mappings to bypass SQL table bloating; identity_mode is a property of the matched MappingRule (ADR 0020 §3), not a fixed property of OAuth2Client (§5). |
| Rate Limiting Engine | Handled natively at the handler layer using the governor crate with pre-hash enforcement. |
| Audit Verification | JCS-canonicalized (RFC 8785) payloads signed using the same per-node KEK-derived HMAC engine. |
3. Cryptographic Token Pipeline & Key Architecture
To ensure broad compatibility across HSM backends, keystone-rs defaults to
ES256 (ECDSA over P-256, SHA-256) but allows operators to configure
RS256 (RSA-2048, SHA-256) via [oauth2] signing_algorithm = RS256.
Asymmetric signing keys are generated, synchronized, and rotated across the
infrastructure utilizing the core mechanics of KeyRepository, backed by the
distributed Raft + FjallDB storage stack.
This same signing_algorithm configuration governs inbound JWT verification in
keystone-rs token handlers. The algorithm for outbound signing and inbound
verification must always match the operator-selected value from
[oauth2] signing_algorithm, preventing cross-algorithm signature exploits
where an attacker presents a token signed with a weaker or unconfigured
algorithm.
Key Lifecycle & The Cache Invalidation Window
Each keypair file is assigned a stable Key ID (kid) computed deterministically
as the first 32 hex characters of the SHA-256 hash of its DER-encoded public key
(128 bits, negligible collision probability under rapid rotation). This
eliminates the need for an external key tracking table. Public keys are exposed
via the unauthenticated endpoint GET /v4/oauth2/{domain_id}/jwks, which
carries Cache-Control: public, max-age=300 so intermediate proxies do not
cache JWKS indefinitely.
The Python middleware (§6) aligns its local JWKS memory cache TTL to this same 300-second boundary. Phase 1 verification must include integration tests that simulate a sudden, active key retirement and confirm that edge nodes update their cached JWKS precisely at the 300-second boundary without human intervention, ensuring zero validation dropouts during the cache refresh window.
To prevent external caching clients (like Envoy edge proxies, Kubernetes API gateways, or the local Python middleware) from suffering validation dropouts when keys rotate, the system configures a strict multi-generational key publishing pool:
- Primary/Active Key: Used exclusively by the Raft leader to sign newly minted outbound JWTs.
- Previous Key: No longer used for token generation, but permanently
retained on the JWKS public endpoint for at least one full token max-lifetime
(
exp) after retirement. This ensures that outstanding tokens remain valid while external clients flush their local cache TTLs.
Key Lifecycle Operations
Rotation is triggered either by time (configurable via
[oauth2] signing_key_rotation_days, default 90 days) or manually via
keystone-manage oauth2 rotate-signing-key --domain <domain_id>. Since each
domain owns an independent keypair (§5), rotation always targets a single
domain_id - there is no cluster-wide rotation operation.
Normal Rotation Flow:
- Generate a fresh asymmetric keypair in memory, per the configured
[oauth2] signing_algorithm. - Commit it via a Raft proposal to
_meta:oauth2:signing_key:<domain_id>:pending, computing itskid(§3). - On commit, the pending key is atomically promoted to Primary/Active and the prior Primary is demoted to Previous in the same Raft proposal - no intermediate state is observable to readers.
- The Previous key remains published on JWKS for one full token max-lifetime
after demotion (§3, point 2); a background janitor (mirroring ADR 0020 §4.A’s
shadow-registry sweep) then removes it from
_meta:oauth2:signing_key:<domain_id>:previousand the JWKS response. - The rotation event is recorded as a CADF audit event (ADR 0023) with
domain_id, the newkid, and the retiringkid.
Raft leader relationship. Key material is Raft-replicated cluster state, not leader-local: any node can verify signatures against it, and only the current Raft leader signs newly issued JWTs with it. On leader failover, the new leader signs with the same replicated Active key - failover never generates a new keypair. This clarifies point 1 above, which read ambiguously on its own.
Domain creation. A domain’s initial signing keypair is generated
synchronously as part of domain creation, not lazily on first token request.
GET /v4/oauth2/{domain_id}/jwks and /.well-known/openid-configuration are
populated immediately for a newly created, enabled domain - they never return an
empty key set.
Emergency Rotation and Signing Key Compromise
When a domain’s Active signing key is suspected or confirmed compromised, the operator triggers emergency rotation, which skips the normal Previous-retention grace window above in favor of immediate containment - mirroring ADR 0016 §6.2’s DEK emergency rotation:
- Trigger:
keystone-manage oauth2 rotate-signing-key --domain <domain_id> --emergency, requiringSystemAdminand dual-control confirmation (ConfirmRotateSigningKeyfrom a second operator within 15 minutes). The confirmation window is 15 min (not 5) to accommodate after-hours incident response. Unconfirmed requests auto-abort at the window’s expiry; the abort is recorded in the audit log with the initiating operator’s identity. As a fallback, an out-of-band emergency rotation can be triggered locally on any node via UDS + loopback, without Raft quorum coordination, when the cluster is compromised and dual-control is impossible. - Immediate replacement: A fresh keypair is generated and committed via Raft, promoted directly to Primary/Active.
- JTI revocation list: Instead of removing the compromised key from JWKS
(which would invalidate ALL outstanding domain tokens — a domain-wide DoS —
see §11), emergency rotation marks the compromised key as
revokedand publishes a jti-based revocation list alongside JWKS atGET /v4/oauth2/{domain_id}/jwks/revocation. The list initially includes thejtiof any tokens issued within the compromise window (derived from the audit log). The middleware (§6) checks this list on every token verification. Tokens withoutjti(notablyIdTokenClaims) are unaffected — they carry no downstream authorization authority and are rejected by the middleware absentopenstack_context(Finding 1.4).OpenStackAccessTokenClaimsalways carriesjti, so they are covered. The revocation list TTL mirrors the one-max-lifetime retention window of normal rotation. - Incident logging: Recorded as a distinct CADF event type
(
OAUTH2_EMERGENCY_KEY_ROTATION) withdomain_id, revokedkid, newkid, operator identity, and the fullrevoked_jtislist appended to the event attachment. Including the jti revocation entries at event time provides an instant cryptographic baseline for security teams to reconcile which outstanding tokens were actively blacklisted during the incident window without cross-referencing the revocation endpoint separately.
Normal rotation cadence resumes once the emergency rotation completes; the
signing_key_rotation_days timer resets to account for the forced rotation.
4. Outbound Token Payload & Scoping Specification
The OP issues two distinct token tracks: id_token (identity for the relying
party) and access_token (authorization for downstream services). Both are
stateless JWTs signed by the OP, carrying the identity context as defined by
ADR 0017.
Rust Struct Layout Specification
#![allow(unused)]
fn main() {
/// Identity claims delivered to the relying party (per OIDC Core §2).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IdTokenClaims {
pub iss: String, // Issuer URL bound to the domain: /v4/oauth2/{domain_id}
pub sub: String, // Keystone user_id (or virtual identity via HMAC-SHA256)
pub aud: String, // OAuth2Client.client_id of the consuming RP
pub exp: i64, // Default 15 minutes ([oauth2] id_token_lifetime_minutes)
pub iat: i64,
pub nbf: i64, // Not-before, always == iat (defense-in-depth per Token Replay Model, §4); verified by relying parties per OIDC Core §2
pub auth_time: i64, // Epoch timestamp of primary authentication (for max_age, OIDC Core §3.1.2.1)
pub nonce: Option<String>, // Echoed verbatim from /authorize request (replay prevention)
pub amr: Vec<String>, // Authentication methods references: "pwd", "mfa_totp", "webauthn", etc.
pub at_hash: Option<String>,// Per OIDC Core §3.2.2.10: SHA-256(access_token)[:96 bits, base64url]. Binds id_token to its co-issued access_token, preventing access_token substitution attacks at the RP. Omitted when no access_token is issued (e.g. id_token-only scope).
pub token_use: String, // Fixed "id" (OIDC Core §3.1.3.4). Downstream services reject this token as authorization.
// Per-OAuth2Client `claims_template` output merged here (e.g. email, groups, roles).
// Populated by interpolating OAuth2Client.claims_template (see Claim Safety below).
#[serde(flatten)]
pub extra_claims: serde_json::Map<String, String>,
}
/// Minimal `access_token` issued on `authorization_code`/`refresh_token` grants
/// that did NOT request (or were not granted) the `openstack:api` scope (§4,
/// "Scope Validation"). Carries no OpenStack authorization data at all - no
/// `openstack_context`, no roles, no `openstack-apis:{domain_id}` audience.
/// Exists purely as the standard RFC 6749 access token for calling Keystone's
/// own `/userinfo` endpoint (OIDC Core §5.3), the same role a generic OIDC
/// access token plays for any RP that never intends to touch OpenStack APIs.
/// This is what closes Threat Model item 1 (§1): a compromised RP holding only
/// this token has no `aud` value any downstream OpenStack middleware (§6) will
/// ever accept.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OidcAccessTokenClaims {
pub iss: String, // Issuer URL bound to the domain
pub sub: String, // Keystone user_id
pub aud: String, // The requesting OAuth2Client.client_id itself, NEVER "openstack-apis:{domain_id}"
pub exp: i64, // Mirrors id_token lifetime (default 15 minutes)
pub iat: i64,
pub nbf: i64,
pub jti: String,
pub scope: String, // Granted scope string, echoed per RFC 6749 §5.1
pub token_use: String, // Fixed "access" (mirrors IdTokenClaims.token_use); downstream middleware (§6) checks this alongside `openstack_context` presence
}
/// Authorization claims consumed by downstream OpenStack services. Issued as
/// the `access_token` on `client_credentials` grants unconditionally (the
/// client itself is always the OpenStack-facing subject there), and on
/// `authorization_code`/`refresh_token` grants only when `openstack:api` was
/// requested and granted (§4, "Scope Validation") - otherwise those grants
/// produce `OidcAccessTokenClaims` above instead.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenStackAccessTokenClaims {
pub iss: String, // Issuer URL bound to the domain
pub sub: String, // Keystone user_id
pub aud: String, // Domain-bound identifier: "openstack-apis:{domain_id}" (see §5 threat note; NOT a flat cluster-wide value)
pub client_id: String, // Registered OAuth2Client that initiated the grant
pub exp: i64, // Short-lived expiration (default 15 minutes)
pub iat: i64,
pub nbf: i64, // Not-before, always == iat (defense-in-depth per Token Replay Model, §4); enforced by the downstream middleware (§6)
pub jti: String, // Unique token UUID for revocation mapping
pub keystone_ruleset_version: u128, // Policy rule state anchor: first 32 hex chars (128 bits) of the SHA-256 hash, same truncation convention as `kid` (§3)
pub amr: Vec<String>, // Authentication methods references (mirrors id_token for downstream)
pub token_use: String, // Fixed "access" (OIDC Core §3.1.3.4 analogue); downstream middleware (§6) checks this alongside `openstack_context` presence, rejecting id_token/OidcAccessTokenClaims presented here
/// Delegated auth context: structurally enforces that a plain auth method
/// cannot carry a `delegated_project_id`. V1 only produces `Plain` — the
/// three delegated variants are forward-declared now so the type already
/// matches what §12's v2 Token Exchange grant will populate, rather than
/// requiring a breaking enum change later. Each delegated variant carries
/// the immutable projection of the delegation boundary (security.md I2).
pub delegation_context: DelegationContext,
#[serde(flatten)]
pub openstack_context: OpenStackContext,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "auth_method", rename_all = "snake_case")]
pub enum DelegationContext {
Plain,
Trust {
#[serde(rename = "delegated_project_id")]
project_id: String,
},
AppCred {
#[serde(rename = "delegated_project_id")]
project_id: String,
},
Ec2 {
#[serde(rename = "delegated_project_id")]
project_id: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OpenStackContext {
pub user_id: String, // Core user UUID or virtual identity string
pub user_name: String, // Normalized, case-folded alphanumeric principal name
pub user_domain_id: Option<String>, // Home domain UUID of the identity itself (matches 0020's IdentityBinding.user_domain_id), distinct from scope below
#[serde(flatten)]
pub scope: OpenStackScope, // Structure structurally identical to `openstack_keystone_core::mapping::authorization::Authorization`
pub roles: Vec<String>, // List of effective roles evaluated at token issuance
}
/// Mirrors ADR 0020's `Authorization` enum shape exactly (same `#[serde(tag = ...)]`
/// pattern, same `system_id` field name) so a token's scope is one of exactly three
/// well-typed shapes instead of a bag of optional fields. The prior design (flat
/// `project_id`/`project_domain_id`/`system: Option<String>`/`is_system: bool`) let
/// illegal states compile - e.g. `is_system: true` with `system: None`, or `project_id`
/// and `system` both set - and the downstream WSGI shim (§4) had to re-derive
/// mutual exclusion by hand with `if/elif` presence checks, silently dropping the
/// domain-scope case entirely because no field ever signaled it.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(tag = "scope_type", rename_all = "snake_case")]
pub enum OpenStackScope {
Project {
project_id: String,
project_domain_id: String,
roles: Vec<RoleRef>
},
Domain {
domain_id: String,
roles: Vec<RoleRef>
},
System {
system_id: String,
roles: Vec<RoleRef>
},
Unscoped,
}
}
Amendment: scope_roles Wire Rename (Found in Phase 5)
The struct layout above, as originally specified, has OpenStackContext.roles
(Vec<String>, the effective role names the §6 middleware reads via
ctx['roles']) and each OpenStackScope variant’s roles: Vec<RoleRef>
both flatten into the same JSON object via nested #[serde(flatten)]. Since
both fields serialize to the identical wire key "roles", the actual JSON
produced contains a duplicate key – valid to write (serde_json silently
overwrites the earlier entry) but never valid to read back: flattened
deserialization resolves each Rust field by name against the first matching
key in the source object, not the last, so no OpenStackAccessTokenClaims
with a Project/Domain/System scope could ever be decoded back into this
type at all. This went unnoticed through Phases 3-4 because the only existing
tests serialized claims one-way and never round-tripped them. Phase 5’s
offline verifier is the first code to actually decode a signed token, and it
surfaced the defect immediately.
Fix: each OpenStackScope variant’s roles: Vec<RoleRef> field carries
#[serde(rename = "scope_roles")], keeping the Rust field name (and every
existing call site) unchanged while giving it a distinct wire key. The outer
OpenStackContext.roles: Vec<String> – the one the §6 middleware actually
reads – keeps the unrenamed "roles" key.
Token Replay Model
Access tokens are bearer tokens with no DPoP (RFC 9449) demonstrable proof of
possession in v1. Replay protection relies on short TTL (15 minutes) and nbf
(not before) claim enforcement. jti is included for audit trail and refresh
token family tracking, but is not used for server-side replay blocking (which
would contradict the stateless model). This bearer-only design carries inherent
token theft risk: a compromised access token (via XSS, log leakage, or network
capture on internal traffic) grants full access until exp. Operators deploying
to non-mTLS control planes should treat the network as partially trusted and
consider mTLS or shorter TTLs (see §11). DPoP binding is scoped to v1.5 to close
this gap without adding v1 complexity. For high-security environments in v1,
downstream services should implement per-request nonce tracking or use
back-channel introspection for access_token validation.
Claim Safety
claims_template (§5, OAuth2Client) is a per-client map of output claim name
to a template string, admin-authored via the Client Registration CRUD API. At
/token issuance, each template is interpolated against Keystone’s own
already-resolved session state and merged into extra_claims on the id_token.
This is a separate, outbound-only interpolation pass - it is not a reuse of
ADR 0020 §5.4’s engine. 0020’s ${claims.*} interpolates inbound claims
presented by an upstream federated IdP into identity/group fields, to decide who
a user is; claims_template here interpolates Keystone’s own outbound,
already-decided identity/scope/roles into extra token claims, to decide what to
tell the relying party. Different direction, different variable namespace
(${user.*}/${scope.*}/${roles.*}, not ${claims.*}), same
single-pass-no-recursion discipline.
Three mechanisms prevent claim name collision and value manipulation through
#[serde(flatten)]:
-
Reserved claim name rejection. Interpolated output keys that collide with OIDC-standard, JWT-reserved, and
OpenStackContext-owned claims (sub,iss,aud,exp,iat,nbf,auth_time,nonce,acr,amr,at_hash,c_hash,azp,jti,client_id,keystone_ruleset_version,delegation_context,auth_method,delegated_project_id,token_use,openstack_context,user_id,user_name,user_domain_id,scope_type,project_id,project_domain_id,domain_id,system_id,roles) are rejected at template compilation time. The reserved set is derived programmatically from the struct field names ofIdTokenClaims,OpenStackContext, andOpenStackScopeat compile time, not maintained manually, so future field additions cannot silently escape the check. The template save fails if any claim-template key matches the reserved set, preventing baseline claims from being overridden via#[serde(flatten)]. -
Trusted sources only. Template interpolation variables are restricted to admin-controlled sources:
${user.id},${user.domain_id},${scope.project_id},${scope.domain_id},${scope.system_id}- one variable perOpenStackScopevariant (§4).${roles.*}is excluded from v1claims_templateto prevent role data from leaking toid_token(the RP’s browser-visible identity token). User-settable attributes (${user.email},${user.name}) are excluded in v1. This prevents user-influenced data from appearing in the signed JWT. -
Output validation. Interpolated claim values must produce valid strings per RFC 7519 §2. Values containing control characters (U+0000-U+001F, U+007F-U+009F) are rejected at token issuance. The
extra_claimsmap is restricted to string values only (HashMap<String, String>) in v1, not arbitrary JSON.
Protocol Scope Integration
OAuth2 scope uses standard OIDC identifiers for display and identity control
(openid, profile, email). Role and authorization content is never
requested via a scope value the RP invents, because the RP cannot know the
user’s assignments ahead of time - but whether OpenStack authorization data is
included at all is gated by one explicit scope value, openstack:api (§4,
“Scope Validation”). When openstack:api is requested and granted,
keystone-rs resolves the user’s actual assignments at /authorize time via
calculate_effective_roles() and encodes the result directly into
openstack_context.roles on an OpenStackAccessTokenClaims access token,
bypassing parallel permission models entirely. When it is not requested (the
default for RPs that only want identity, e.g. openid profile email), the
access token issued is the minimal OidcAccessTokenClaims (§4) - no roles, no
openstack_context, no OpenStack-facing aud. The id_token itself never
carries roles either way; only claims_template output (Claim Safety, above)
reaches it.
Scope Validation and allowed_scopes Enforcement
OAuth2Client.allowed_scopes is validated differently depending on which grant
is invoked at /token - the two grants use incompatible scope grammars, not one
shared surface:
authorization_code/refresh_token: the requestedscopemust be a subset ofallowed_scopes ∩ {openid, profile, email, openstack:api}. Any requested value outside that set is rejected outright withinvalid_scope(400, RFC 6749 §5.2) - the server never silently narrows the grant to the allowed subset. Silent narrowing would let a client believe it received a broader identity disclosure than it did.openstack:apiis not a display scope: it must be explicitly present in the client’sallowed_scopes(an admin, Tier 1/Tier 2-gated action, §5) and explicitly requested per-call before the access token carries any OpenStack authorization data at all - see “Protocol Scope Integration” above and Threat Model item 1 (§1). Its absence is the safe default; omittingscopeentirely (below) does not implyopenstack:apieven if the client’sallowed_scopesincludes it, since defaulting to “full access” on an omitted scope would silently hand OpenStack authority to any client that merely forgets to passscope.client_credentials: the requestedscope(if present) must be a subset ofallowed_scopes, same reject-outright rule. These values are opaque resource-scope strings in v1 - they carry no OIDC meaning (noid_tokenis issued for this grant, so there is no display-claim surface to bound) and no bearing on role/authorization resolution, which is fully owned by the mapping engine regardless of requested scope (see Amendment below). They are echoed verbatim in the token responsescopefield per RFC 6749 §4.4.3 and reserved for a future RFC 8707 resource-indicator scheme - out of scope here, same deferral posture as §12’s Token Exchange.- Omitted
scopeparam (either grant): defaults to the client’s fullallowed_scopesvalue, per RFC 6749 §3.3 - exceptopenstack:apionauthorization_code/refresh_token, which is never implied by omission and must always be requested explicitly (see above);client_credentialsis unaffected by this carve-out since it has no display-scope surface to begin with.
5. Client & Issuer Topology (Machine Identity Execution)
To separate human directory assets from automated server processes,
keystone-rs treats non-human machine workloads as first-class citizens using
domain-bound client resource blocks.
#![allow(unused)]
fn main() {
pub struct OAuth2Client {
pub client_id: String, // Public, globally unique lookup identifier presented at /token and /authorize
pub provider_id: String, // Functional configuration slug anchor (mirrors ADR 0020 §2.A-C); unique within domain_id
pub domain_id: String, // Owning tenant domain; required, matching OidcProviderResource/K8sClusterResource/SpiffeTrustResource (ADR 0020 §2.A-C). No global/domain-less client: the registration endpoint (§5) is already domain-scoped (`POST /v4/oauth2/{domain_id}/clients`), and the resource key `data:oauth2:client:<domain_id>:<provider_id>` (§5) requires a concrete value. Cluster-wide machine identities register under a reserved system domain like any other ingress source.
pub client_secret_hash: Option<String>, // Argon2id PHC representation for confidential applications
pub redirect_uris: Vec<String>, // Exact-match allowlist strings; wildcards rejected. Confidential clients (non-null client_secret_hash) must use HTTPS-only URIs; rejected at CRUD time (422) for non-HTTPS schemes. `http://localhost:*` allowed for public clients with a one-time warning logged.
pub token_endpoint_auth_method: String, // Client authentication at /token: "client_secret_basic" (default), "private_key_jwt" or "tls_client_auth" (RFC 8705)
pub grant_types: Vec<GrantType>, // Set of {authorization_code, client_credentials, refresh_token, device_code}
pub require_pkce: bool, // Mandatory for public clients; S256 method only. Enforced at CRUD time: a public client (null client_secret_hash) with require_pkce=false is rejected (422) to prevent authorization code interception without PKCE binding.
pub allowed_scopes: Vec<String>, // Per-grant scope allowlist (Scope Validation, §4): OIDC identifiers + `openstack:api` for authorization_code/refresh_token, opaque resource-scope strings for client_credentials
pub pre_authorized: bool, // Skips user consent step for trusted first-party device-code clients (§7.C); SystemAdmin-only to set, high-severity CADF event on change
pub enabled: bool, // Active administrative switch
// NOTE: no `identity_mode` field here - Local vs. Ephemeral (ADR 0020 §3) is a
// property of the matched `MappingRule`'s `IdentityBinding`, not the provider
// resource. None of the sibling resources (`OidcProviderResource`,
// `K8sClusterResource`, `SpiffeTrustResource`) carry it either; it is resolved
// per-rule at match time against the ruleset attached to this client's
// `provider_id`, same as every other ingress source.
pub claims_template: HashMap<String, String>, // Output claim name -> template string (Claim Safety below); populates IdTokenClaims.extra_claims
}
}
Amendment to ADR 0020: OAuth2 Client as a Fourth Provider Resource
OAuth2Client is not a parallel, bespoke resource type - it is a fourth entry
in ADR 0020 §2’s Provider Configuration Resources, alongside
OidcProviderResource (§2.A), K8sClusterResource (§2.B), and
SpiffeTrustResource (§2.C). ADR 0020 §8 already reserved the keyspace slot for
this (index:oauth2:client:<client_id>, “Global Client Index”); this ADR
formalizes the resource that fills it:
- Resource key:
data:oauth2:client:<domain_id>:<provider_id>(parallel to the other three crypto resource keys in ADR 0020 §8). - Global index:
index:oauth2:client:<client_id>→{"domain_id", "provider_id"}, resolving the OAuth2-protocol-facingclient_idpresented at/tokento the resource’s admin-facing coordinate, exactly as the OIDC/K8s/SPIFFE resources resolve their own protocol identifiers. IdentitySourcevariant: ADR 0020 §3 gainsIdentitySource::OAuth2Client { provider_id: String }, matching the existingFederation { idp_id },K8s { cluster_id }, andSpiffe { trust_domain }pattern - the anchor is the resource’s own identifying field, not a generic wrapper (ADR 0020 §13 D1/D7).client_credentialsgrant is an ingress event. When a machine workload authenticates withclient_id/client_secret, it flattens to a minimal claims map (client_id,domain_id) and runs through the exact sameMappingRuleSetmatch → upsert pipeline as SPIFFE/K8s ingress (ADR 0020 §5, §7.2), not a separate authorization mechanism. Anyscoperequested on this grant is validated againstallowed_scopesas an opaque resource-scope string (§4 above, “Scope Validation”) - it never feeds role/authorization resolution, which flows entirely through the mapping engine, not a parallel scope-to-role table.
Dual-Role Clients: RP and Machine Identity Coexist by Design
A single OAuth2Client may legitimately hold both authorization_code and
client_credentials in grant_types at once - this is intentional, not an
oversight to close off. The two grants produce structurally different tokens
from the same registration:
- RP role (
authorization_code): the client is never the subject. An end user authenticates;subis the realuser_id; the client’s ownclient_idappears only asaudon theid_tokenit consumes (azpis reserved, §4, but unused in v1 sinceaudis always the single requesting client). - Machine-identity role (
client_credentials): the client is the subject.subis the ephemeral shadowuser_idderived per “Virtual Machine Alignment” below.
Token subject semantics are keyed on which grant is invoked at /token,
never on a per-client exclusive flag - the same shape as an application that
both offers “Login with OpenStack” to its human users and holds its own backend
service identity for calling OpenStack APIs. One registration, two roles.
Enabling client_credentials on a client is not itself a privileged
operation requiring a bespoke gate: it attaches the client’s provider_id to a
MappingRuleSet via IdentitySource::OAuth2Client, exactly like any K8s/SPIFFE
ingress source, and that ruleset write already goes through ADR 0020 §9.A’s
existing Tier 1/Tier 2 split unchanged:
- Tier 2 (default): a domain-confined, non-admin operator enables
client_credentialsand writesAuthorization::Project/Domainrules scoped to their own domain, granting only roles they themselves already hold - full self-service, no SystemAdmin involved. This is the common case and the point of domain-owned OAuth2 client management. - Tier 1 (SystemAdmin gate): triggers only if the attached rule requests
is_system: trueorAuthorization::System, same control-plane-bypass line ADR 0020 already draws for every ingress source - nothing OAuth2Client- specific about it.
Virtual Machine Alignment
When an external automated system (e.g., a Kubernetes service account)
authenticates via the client_credentials grant, the matched MappingRule
typically resolves to IdentityMode::Ephemeral (the common case for machine
workloads with no directory-backed account; an admin may configure Local
instead, same as any other ingress source, ADR 0020 §3). keystone-rs derives
the deterministic user_id inside the Shadow Virtual User Registry (ADR
0020 §4) using the same generic formula as every other ingress source:
$$\text{HMAC-SHA256}(\text{cluster_salt}, \text{client_id} \parallel \text{provider_id})$$
Here client_id plays the role of workload_id and provider_id anchors the
owning OAuth2Client resource - the same two-component shape ADR 0020 §4 uses
for Federation/K8s/SPIFFE sources. This record is not stateless: it is
persisted in the Shadow Virtual User Registry via Raft + FjallDB, subject to the
same creation/auth rate limits, 90-day inactivity janitor, and archive retention
as any other shadow principal (ADR 0020 §4.A, §7.2). The benefit is narrower
than “no persistence” - it is no SQL row, avoiding bloat in the relational
user tables that back real, directory-backed accounts.
Client ID Uniqueness Invariant: client_id is globally unique across all
domains (it is the sole key clients present at /token, before domain_id is
known), while provider_id need only be unique within its owning domain_id,
matching the other three provider resource types. The
token_endpoint_auth_method field in OAuth2Client determines how the client
authenticates at /token; v1 supports client_secret_basic, with
private_key_jwt and tls_client_auth reserved for v2.
Client Registration CRUD API
OAuth2Client administration follows the identical Tier 1/Tier 2 validation
pattern established for mappings (ADR 0020 §9.A) - SystemAdmin gate for anything
crossing domain/system boundaries, domain-confined self-service otherwise:
POST /v4/oauth2/{domain_id}/clients- Register a client. Confidential clients receive a one-time plaintextclient_secretin the response body (never persisted or retrievable again; onlyclient_secret_hashis stored).provider_idmust be unique withindomain_id(409 on collision);client_idis server-generated and globally unique.GET /v4/oauth2/{domain_id}/clients- List clients (domain-isolated, mirrors ADR 0020 §9.B).GET /v4/oauth2/{domain_id}/clients/{provider_id}- Get client profile (never includesclient_secret_hash).PUT /v4/oauth2/{domain_id}/clients/{provider_id}- Update mutable fields (redirect_uris,grant_types,require_pkce,allowed_scopes,enabled,claims_template,pre_authorized). Reserved-key and trusted-source validation (Claim Safety, §4) runs at save time - a write with an invalid template is rejected (422), not silently truncated. Settingpre_authorizedrequiresSystemAdminregardless of the Tier 2 self-service path otherwise available for this endpoint (§7.C).client_id,provider_id, anddomain_idare immutable post-creation, mirroringMappingRuleSet.domain_idimmutability (ADR 0020 §9.D).POST /v4/oauth2/{domain_id}/clients/{provider_id}/rotate-secret- Invalidates the currentclient_secret_hashand issues a new plaintext secret, one-time, in the response.DELETE /v4/oauth2/{domain_id}/clients/{provider_id}- Revokes the client and immediately invalidates all refresh tokens in its family tree (§9). Outstanding bearer access/id tokens remain valid until naturalexpper the stateless token model (§4). The jti revocation list (§3) is not populated on delete (access tokens are short-lived at 15 min); only the stateful refresh path is targeted. Operators requiring immediate access-token revocation for a compromised client should trigger emergency signing key rotation (§3) instead.
Every create/update/delete/rotate-secret event triggers a CADF audit event (§9), same as mapping ruleset mutations.
Domain Key Isolation and aud Binding
Each domain owns an independent signing keypair (§3), synchronized separately
via Raft + FjallDB. If aud were a single flat cluster-wide value (e.g.
"openstack-apis"), compromise of any one domain’s signing key would let an
attacker forge tokens accepted by every internal OpenStack service cluster-wide
- collapsing the per-domain trust isolation this ADR otherwise builds (mirroring
ADR 0006’s domain segregation). To close this,
audis domain-bound ("openstack-apis:{domain_id}"), and the downstream middleware (§6) validates bothaudagainst its configured domain andissagainst an explicit per-deployment issuer allowlist, not merely claim presence. A compromised domain key therefore only forges tokens accepted within that domain’s own blast radius, not the whole control plane.
6. Downstream Control Plane Enforcement Layer (Python WSGI Middleware)
To execute an incremental, zero-downtime parallel rollout alongside legacy
OpenStack installations, a thin custom Python WSGI middleware is dropped
directly into the Paste Deploy pipelines of legacy services (e.g., inside
/etc/nova/api-paste.ini or /etc/neutron/api-paste.ini).
This layer acts as an completely offline signature verification gate:
import jwt
import requests
import logging
from cachetools import TTLCache
from werkzeug.exceptions import Unauthorized
# Module-level logger for audit and operational monitoring
logger = logging.getLogger(__name__)
class KeystoneNativeJwtMiddleware:
def __init__(self, app, config):
self.app = app
self.jwks_url = config.get('keystone_jwks_url')
self.jwt_jti_revocation_url = config.get('keystone_jwt_jti_revocation_url')
# Domain-bound aud (see ADR §5, "Domain Key Isolation and `aud` Binding"):
# a flat cluster-wide audience would let a single compromised domain
# signing key forge tokens accepted by every service in the cluster.
domain_id = config.get('keystone_domain_id')
self.expected_audience = f"openstack-apis:{domain_id}"
# Explicit issuer allowlist. jwt.decode's `require: ["iss"]` only checks
# claim *presence*, not the value, so `iss` is verified separately below.
self.expected_issuers = config.get('keystone_expected_issuers', [])
# JWKS cache TTL must not exceed the JWKS endpoint Cache-Control max-age (300s)
# to ensure prompt validation of tokens after key rotation or emergency
# revocation. Fail-closed policy (see _get_cached_jwks): on fetch failure
# past this TTL, requests are rejected rather than served against a
# possibly-revoked stale keyset — a key pulled from JWKS during emergency
# rotation (§3) must stop validating immediately, not after some grace
# window an attacker can extend by interfering with connectivity.
self.jwks_cache = TTLCache(maxsize=1, ttl=300)
self._jwks_cache_key = 'jwks'
# JTI revocation list cache (lightweight, separate from JWKS). Same
# fail-closed policy: an unreachable revocation list is indistinguishable
# from an attacker actively suppressing it mid-incident, so fetch failure
# rejects the request rather than accepting the token (§11).
self.revocation_cache = TTLCache(maxsize=1, ttl=60)
self._revocation_cache_key = 'revoked'
# Primary signing algorithm. During operator transitions (ES256 <-> RS256),
# `fallback_signing_algorithm` enables dual verification with warning logs.
self.algorithms = [config.get('signing_algorithm', 'ES256')]
if fallback := config.get('fallback_signing_algorithm'):
self.algorithms.append(fallback)
def _sanitize_token_value(self, val, field_name):
"""Reject control characters in JWT claim values before WSGI injection.
Prevents HTTP response splitting via crafted claim values (Finding 5.4).
Even though the value was cryptographically signed, the origin may be
admin-authored claims_template data, which we do not trust at injection time.
"""
if not isinstance(val, str):
raise ValueError(f'{field_name}: non-string value')
if any(c in val for c in ('\r', '\n', '\x00')):
raise ValueError(
f'{field_name}: contains control character '
f'(rejecting to prevent HTTP response splitting)'
)
return val
def __call__(self, environ, start_response):
# 0. Sanitize identity headers on every request, not just the Bearer-path.
# Prevents stale headers leaking to the Fernet fallback path (Finding 5.1).
self._sanitize_environment_headers(environ)
auth_header = environ.get('HTTP_AUTHORIZATION', '')
if auth_header.startswith('Bearer '):
token = auth_header.split(' ')[1]
try:
# 1. Fetch/update asymmetric public verification keys. Raises
# requests.RequestException on failure past the cache TTL,
# caught below and treated as fail-closed (§11).
public_keys = self._get_cached_jwks()
# 2. Execute local, CPU-bound cryptographic signature validation.
# Supports dual-algorithm for operator transitions (Finding 5.2).
decoded_claims = jwt.decode(
token,
public_keys,
algorithms=self.algorithms,
audience=self.expected_audience,
options={
"require": ["exp", "iat", "nbf", "iss", "aud", "sub"],
"verify_exp": True,
"verify_nbf": True,
"verify_signature": True,
}
)
# Warn on fallback algorithm use (transition monitoring).
if len(self.algorithms) > 1:
header = jwt.get_unverified_header(token)
if header.get('alg') == self.algorithms[1]:
logger.warning(
'Token uses fallback algorithm %s',
header.get('alg'),
)
# 3. Verify iss value against the explicit allowlist (claim
# presence alone, enforced by `require` above, is not enough)
if decoded_claims['iss'] not in self.expected_issuers:
logger.warning('Unexpected issuer: %s', decoded_claims['iss'])
return self._abort_unauthorized(start_response, 'Untrusted issuer')
# 4. Structural type check: reject non-access-tokens (Finding 1.4).
# Belt-and-suspenders: both the explicit `token_use` claim and the
# structural presence of `openstack_context` must agree this is an
# OpenStack access_token — an id_token or RP-only OidcAccessToken
# has neither.
if (
decoded_claims.get('token_use') != 'access'
or 'openstack_context' not in decoded_claims
):
logger.warning(
'Token is not an OpenStack access_token (id_token or '
'RP-only access_token presented to OpenStack endpoint)'
)
return self._abort_unauthorized(
start_response, 'Token type not an OpenStack access_token'
)
# 5. JTI revocation list check (Finding 8.3). Raises
# requests.RequestException on fetch failure, caught below and
# treated as fail-closed (§11) — same policy as JWKS fetch.
jti = decoded_claims.get('jti')
if jti and self._is_jti_revoked(jti):
logger.warning('Revoked JTI: %s', jti)
return self._abort_unauthorized(
start_response, 'Token JTI has been revoked'
)
# 6. Extract the embedded context block
ctx = decoded_claims['openstack_context']
# 7. Enforce delegation invariants (security.md I1, I2, I3, I5)
# delegation_context is a tagged enum: {"auth_method": "plain"} or
# {"auth_method": "trust"|"app_cred"|"ec2", "delegated_project_id": "<id>"}.
# All three delegated variants carry the same delegated_project_id
# field (§4), so the check below is uniform across them — v1 only
# ever produces "plain" (§12); v2's Token Exchange grant is what
# populates the other three.
auth_ctx = decoded_claims.get('delegation_context', {})
auth_method = auth_ctx.get('auth_method', 'plain')
if auth_method != 'plain':
delegated_project = auth_ctx.get('delegated_project_id')
if delegated_project is None:
logger.warning(
'Delegation context "%s" missing delegated_project_id',
auth_method,
)
return self._abort_unauthorized(
start_response, 'Delegation context malformed'
)
# I3: Scope-drift tripwire — verify that the token's project scope
# matches the delegation's immutable project_id.
# Only Project scope carries project_id; delegated auth must be
# project-scoped (I5: delegated cannot be domain/system/scoped).
if ctx.get('scope_type') != 'project':
logger.warning(
'Delegated token with non-project scope_type: %s',
ctx.get('scope_type'),
)
return self._abort_unauthorized(
start_response,
'Delegated auth must be project-scoped (I5)',
)
if ctx.get('project_id') != delegated_project:
logger.warning(
'Scope-drift detected: token project_id=%s != '
'delegated_project_id=%s',
ctx.get('project_id'),
delegated_project,
)
return self._abort_unauthorized(
start_response, 'Scope-drift tripwire triggered (I3)'
)
# 8. Inject flat context variables expected downstream by oslo.policy.
# Sanitize each value to prevent HTTP response splitting (Finding 5.4).
environ['HTTP_X_IDENTITY_STATUS'] = 'Confirmed'
environ['HTTP_X_USER_ID'] = self._sanitize_token_value(
decoded_claims['sub'], 'sub'
)
environ['HTTP_X_USER_NAME'] = self._sanitize_token_value(
ctx['user_name'], 'user_name'
)
environ['HTTP_X_ROLES'] = ','.join(
self._sanitize_token_value(r, f'roles[{i}]')
for i, r in enumerate(ctx['roles'])
)
scope_type = ctx.get('scope_type')
if scope_type == 'project':
environ['HTTP_X_PROJECT_ID'] = self._sanitize_token_value(
ctx['project_id'], 'project_id'
)
environ['HTTP_X_PROJECT_DOMAIN_ID'] = self._sanitize_token_value(
ctx['project_domain_id'], 'project_domain_id'
)
elif scope_type == 'domain':
environ['HTTP_X_DOMAIN_ID'] = self._sanitize_token_value(
ctx['domain_id'], 'domain_id'
)
elif scope_type == 'system':
environ['HTTP_X_SYSTEM_SCOPE'] = self._sanitize_token_value(
ctx['system_id'], 'system_id'
)
return self.app(environ, start_response)
except ValueError as sanitize_err:
# Token claim sanitization failure.
logger.warning('Token sanitization failed: %s', sanitize_err)
return self._abort_unauthorized(
start_response, 'Token claim value invalid'
)
except requests.RequestException as fetch_err:
# Fail closed (§11): cannot reach the JWKS or JTI-revocation
# endpoint, so the token cannot be verified against current
# signing keys or checked for emergency revocation. Accepting
# it anyway would let a network partition (attacker-induced or
# not) resurrect an already-revoked compromised key for the
# duration of the outage — the exact window emergency rotation
# (§3) exists to close. Cost: a Keystone/network outage also
# blocks OpenStack API calls, not just token issuance.
logger.error('Verification dependency unreachable: %s', fetch_err)
return self._abort_unauthorized(
start_response, 'Verification service unavailable'
)
except (
jwt.ExpiredSignatureError,
jwt.InvalidSignatureError,
jwt.InvalidAudienceError,
jwt.InvalidIssuerError,
jwt.ImmatureSignatureError,
jwt.MissingRequiredClaimError,
jwt.DecodeError,
) as crypto_err:
# Instantly drop requests failing cryptographic or structural
# verification. The catch list is exhaustive over jwt.decode's
# documented exception set so no malformed/hostile token can
# fall through as an unhandled exception (fail closed, not
# fail open, on decode error).
return self._abort_unauthorized(start_response, str(crypto_err))
# Fallback path: pass through to traditional symmetric Fernet filters
return self.app(environ, start_response)
def _sanitize_environment_headers(self, environ):
"""Strict Sanitation: Purge pre-existing user-supplied identity headers
to eliminate spoofing and header injection vulnerabilities. Called on
every request (including Fernet fallback) to prevent stale header leakage.
"""
for header in [
'HTTP_X_USER_ID',
'HTTP_X_USER_NAME',
'HTTP_X_ROLES',
'HTTP_X_PROJECT_ID',
'HTTP_X_PROJECT_DOMAIN_ID',
'HTTP_X_DOMAIN_ID',
'HTTP_X_SYSTEM_SCOPE',
'HTTP_X_IDENTITY_STATUS',
]:
environ.pop(header, None)
def _get_cached_jwks(self):
"""Fetch JWKS, fail closed on failure (§11).
Serves the cached keyset while within the 300s TTL (matching the JWKS
endpoint's `Cache-Control: max-age=300`). On expiry, fetches
synchronously. Raises `requests.RequestException` on failure instead
of serving stale data — caught by the caller and translated into a
401, since a key pulled from JWKS during emergency rotation (§3) must
stop validating immediately, not after some grace window an attacker
can extend by interfering with connectivity to this endpoint.
"""
cached = self.jwks_cache.get(self._jwks_cache_key)
if cached is not None:
return cached
return self._fetch_jwks_from_network()
def _fetch_jwks_from_network(self):
"""Raw JWKS fetch from Keystone. Raises on failure (fail closed)."""
resp = requests.get(self.jwks_url, timeout=5)
resp.raise_for_status()
keys = resp.json()
self.jwks_cache[self._jwks_cache_key] = keys
return keys
def _is_jti_revoked(self, jti):
"""Check if a JTI appears in the JWKS-published revocation list.
The revocation list is published alongside JWKS at a dedicated endpoint,
keyed by `jti`. Only applies to tokens post-emergency-rotation or
post-client-deletion. Normal-state tokens have an empty list. (Finding 8.3)
Cached for 60 seconds. Fails closed (§11): an unreachable revocation
list during an active key compromise is indistinguishable from an
attacker actively suppressing it, so fetch failure raises
`requests.RequestException` rather than accepting the token.
"""
revoked = self.revocation_cache.get(self._revocation_cache_key)
if revoked is None:
resp = requests.get(self.jwt_jti_revocation_url, timeout=2)
resp.raise_for_status()
revoked = set(resp.json().get('revoked_jtis', []))
self.revocation_cache[self._revocation_cache_key] = revoked
return jti in revoked
def _abort_unauthorized(self, start_response, reason):
status = '401 Unauthorized'
start_response(status, [('Content-Type', 'text/plain')])
return [reason.encode()]
7. Defensive Shield: Throttling & Threat Containment
A. Pre-Hash Enforcement for /token (client_credentials)
When an automated workload requests a token using a client secret, the handler must check the quota bucket before executing the Argon2id password-hashing check.
- The Key Boundary: The limiter keys directly on the unverified
client_idstring payload. - The Defense: If an adversary triggers a brute-force credential attack, the handler trips the governor limit, completely halting execution and skipping the CPU-intensive Argon2 verification entirely, thereby insulating the cluster from CPU exhaustion.
B. Post-Lookup User Throttle for Browser /authorize
For interactive endpoints where a human provides credentials via the server-rendered login form, the system enforces Invariant 8 of ADR 0022:
- The incoming request passes through the
global_ip_limiterusing the originating client address resolved via trusted proxy CIDRs. - The user lookup occurs in the identity backend to confirm actual account existence.
- The per-user authentication limiter (
rate_limit_user_auth) is applied only after account existence is verified. This closes the key-exhaustion exploit path where an attacker presents an infinite series of randomized usernames to flood the in-memory state store and evict active operational quotas.
C. Device Code Rate Limiting (RFC 8628 §3.5)
The Device Authorization Grant introduces a device_code redemption path at
/token that is more susceptible to brute-force attacks than credential
endpoints. To mitigate:
device_codeanduser_codeare subject to separate per-IP and per-user_coderate limits with exponential backoff.- Invalid or expired
device_codepresented at/tokentriggers a mandatory 5-minute quiet period before further codes can be issued for that IP. - When a valid active grant is polled faster than the advertised
interval, the server returns aslow_downerror response per RFC 8628 §3.5 (not a generic penalty error), signaling the client to increase its polling rate. - The minimum
intervalbetween polling attempts is 5 seconds. - A
pre_authorizedflag onOAuth2Clientmay skip the user consent step for trusted first-party devices. Creating or updating this flag requiresSystemAdminand triggers a high-severity CADF event (ADR 0023). Apre_authorizedclient must not includeopenstack:apiinallowed_scopes(enforced at CRUD time), preventing silent consent bypass from granting OpenStack authorization. Ifopenstack:apiis added to apre_authorizedclient, thePUTrequest is rejected with 422. - Code entropy requirements (RFC 8628 §3.5):
device_codemust be at least 256 bits of entropy (43+ base64url chars) to prevent brute-force at/token.user_codemust be at least 8 characters using unambiguous characters[A-Z,0-9](excludingO/0,I/l/1). This length and character set balance human-typing ergonomics against brute-force surface on the console verification endpoint.
D. Device Console URI Rate Limiting
The device authorization endpoint returns verification_uri_complete (the
console URI where the user enters user_code). This page is susceptible to
user_code brute-force. The console endpoint enforces:
- Separate per-IP rate limit for console verification attempts (distinct from
/tokenpolling limit in §7.C). user_codeuniqueness checks must use constant-time comparison to prevent timing-based enumeration attacks.- Failed console attempts generate
device_codereuse alerts via CADF audit event (ADR 0023), correlating with/tokenpolling attempts to detect coordinated brute-force campaigns.
8. Interactive Login & Web Security Controls
Keystone currently has no web UI (it is an API-only service). The Authorization Code flow and Device code verification require interactive login pages, introducing new attack surface. All server-rendered OP endpoints carry defense- in-depth security headers:
Content-Security-Policy: default-src 'self'(restricts frame-ancestors, script-src)X-Frame-Options: DENY(clickjacking prevention on the consent screen)X-Content-Type-Options: nosniffX-XSS-Protection: 0(disabled per OWASP modern guidance; CSPdefault-src 'self'handles XSS. Browser-built-in XSS filters are unreliable and can introduce false positives. Document this rationale to prevent enterprise scanners from flagging it.)
CSRF Token Binding
The login and consent POST forms carry per-session anti-CSRF tokens. state and
code_challenge are chosen by whoever initiates /authorize - which may be an
attacker, not the victim - so a value merely derived from them (e.g. a plain
hash) carries no secret the attacker doesn’t already know, and would not
actually stop login CSRF. The CSRF token is therefore
HMAC-SHA256(server_side_session_secret, session_id ‖ state ‖ code_challenge):
server_side_session_secret is generated when GET /authorize first
establishes the pre-authentication browser session (set as an HttpOnly,
SameSite=Lax cookie) and never transmitted to the client in cleartext. An
attacker who crafts an /authorize URL for a victim to click still cannot
compute a matching token without that victim browser’s own session secret, which
closes the login CSRF vector the naive hash-of-public-values approach would not.
max_age Enforcement (OIDC Core §3.1.2.1)
The /authorize endpoint accepts an optional max_age parameter. If present,
keystone-rs compares the current time against the user’s authenticated session
auth_time. If auth_time + max_age < now, the server forces re-authentication
(including re-triggering MFA/TOTP/passkey challenges if configured).
9. Cryptographic Auditing & Non-Repudiation
Every single token issuance event, token refresh lifecycle step, and administrative client modification triggers a normative Cloud Auditing Data Federation (CADF) compliance log.
To prevent structural key sprawl across the deployment, the auditing framework uses the exact same per-node KEK-derived HMAC engine established in the storage layer:
$$\text{HKDF-Expand}(\text{KEK}, \text{info}=\text{“keystone-audit-hmac-v1”} \parallel \text{node_id_u64_be}, L=32)$$
JCS Canonicalization Requirements
Before generating an audit signature, the CadfAuditHook enforces strict RFC
8785 (JSON Canonicalization Scheme) processing on the CadfEventPayload.
Array keys must be sorted lexicographically with zero extraneous whitespace.
Missing parameters are skipped according to strict skip_serializing_if rules.
This ensures that the generated cryptographic signature matches perfectly when
evaluated downstream by an external SIEM system, closing the log-tampering
vulnerability surface.
Token Compromise Alerts (Refresh Reuse Invariant)
If a client presents a rotating refresh_token that has already been flagged as
spent, keystone-rs interprets the event as an active infrastructure breach:
- To prevent false positives during legitimate multi-device scenarios (e.g., a
user on phone + laptop where a refresh token might be reused within a short
window), a configurable grace period is applied
(
[oauth2] refresh_token_reuse_grace_minutes, default 10, range 0-30). Setting to 0 disables the grace period entirely (tightest breach detection at the cost of multi-device false positives). Only refresh token reuse exceeding this window triggers the cascade. This reduces user disruption from transient race conditions while maintaining rapid breach detection. Accepted risk: this is a deliberate detection-latency tradeoff - an attacker who steals a refresh token has up torefresh_token_reuse_grace_minutesto replay it before family revocation triggers (see §11). - The token engine invalidates the entire token family tree associated with that original grant immediately.
- The event handler bypasses the best-effort perimeter logging pool and commits
a critical alert via
dispatch_critical(). - If channel congestion occurs, the system writes a local compensating JSONL
log to disk and exposes the drop metric immediately to the Prometheus
monitoring alert
KeystoneAuditPostauditDrops.
10. Phased Implementation Approach
Phase 0: Token Provider Abstraction & JWS Parity (v3 surface)
│
▼
Phase 1: Crypto & JWKS (Raft Core) ────► Phase 2: Ingress API Routing ────► Phase 3: Client Credentials
│
Phase 5: Native Control Plane Acceptance ◄──── Phase 4: Auth Code & PKCE ◄─────────┘
Phase 0: Token Provider Abstraction & Python JWS Parity
This phase exists because of a gap this ADR otherwise ignores: Python
Keystone has shipped a second token provider since Stein —
[token] provider = jws (ES256-signed JWS tokens, keystone-manage create_jws_keypair, filesystem key repositories) — and keystone-rs supports
only Fernet. keystone-rs is deployed in parallel with Python Keystone
during migration, and both sides must decode each other’s v3 tokens. A
deployment running the JWS provider today cannot put keystone-rs behind the
same VIP at all. Two distinct JWT tracks must therefore not be conflated:
- v3-surface JWS tokens (this phase): Python-compatible, reference
tokens — the JWT payload carries only identity/scope anchors (no roles, no
catalog) and keystonemiddleware still validates them back-channel via
GET /v3/auth/tokens. Purely a token format, not an authorization model. - OP-issued access tokens (Phases 1-5): self-contained
OpenStackAccessTokenClaims(§4) with embedded roles, verified fully offline (§6). A different product, deliberately not wire-compatible with the above.
Deliverables:
- Decouple the token provider layer from Fernet:
TokenBackend::decode/encode(crates/core/src/token/backend.rs) andTokenApi::encode_tokencurrently take/returnFernetTokendirectly. Introduce a format-neutral token payload type and a[token] provider = fernet | jwsselector (mirroring Python’s config surface) so drivers are interchangeable. This refactor is a hard prerequisite: retrofitting it after OAuth2 code lands on top of the Fernet-typed trait would be strictly more expensive. - New
token-driver-jwscrate: ES256 sign/verify, Python-compatible claim layout (sub,exp,iat,openstack_methods,openstack_audit_ids,openstack_project_id/openstack_domain_id/openstack_system,openstack_trust_id,openstack_app_cred_id) and Python-compatible key-repository layout, plugged in via the existingKeySourceabstraction incrates/key-repositoryso filesystem keys shared with Python nodes work unchanged.
Verification: Round-trip fixture tests against tokens minted by Python Keystone’s JWS provider (decode theirs, they validate ours). Config-switch tests proving a node can validate both Fernet and JWS tokens during a provider transition.
Strategic payoff beyond parity: the ES256 signing/verification plumbing,
key-file handling, and kid conventions built here are exactly what Phase 1
generalizes into the Raft-backed OAuth2 KeyRepository — Phase 0 is not a
detour, it is the first increment of the same cryptographic engine, delivered
against the existing v3 surface where it immediately widens the set of
deployments keystone-rs can stand in for.
Phase 1: Cryptographic Engine & JWKS Infrastructure
- Deliverables: Asymmetric key generation added to
KeyRepository. Implement the publicGET /v4/oauth2/{domain_id}/jwksendpoint. Implement the multi-generational cache preservation logic. - Verification: Unit tests confirming DER-to-kid SHA-256 truncation, integration tests verifying multi-key overlap stability on Raft replication events, and integration tests simulating a sudden, active key retirement to confirm that edge nodes update their cached JWKS precisely at the 300-second boundary without human intervention (§3).
Phase 2: Ingress API Routing & OIDC Discovery
- Deliverables: Expose unauthenticated RFC 8414 discovery paths
(
/.well-known/openid-configuration). Implement theOAuth2Clientstorage schema within the consolidated partition layer in FjallDB. - Verification: Assert that the generated JSON discovery blocks match OIDC 1.0 structural validation test profiles. Validate Rego policy rules gating the administrative CRUD routes.
Phase 3: Machine-to-Machine client_credentials Implementation
- Deliverables: Write the
/tokenendpoint handling secret matching. Integrate the pre-hash rate-limiting checks from ADR 0022. Connect the token generation toIdentityMode::Ephemeralshadow user allocations. - Verification: Run automated integration scripts simulating high-velocity machine logins. Verify that Argon2id computation is skipped when rate-limiting thresholds are breached.
Phase 4: Human Authorization Code Flow with PKCE
- Deliverables: Build the secure browser interactive routes for
/authorize. Deliver the server-rendered login and consent forms. Enforce mandatoryS256PKCE verification loops. Implement refresh token rotation family tracking. - Verification: Trigger intentional token reuse attempts and assert that the
entire associated token lineage collapses while generating a critical CADF log
event via
dispatch_critical().
Phase 5: Native Control Plane Acceptance & Middleware Injection
- Deliverables: Deliver the “Regular Python”
KeystoneNativeJwtMiddlewarecodebase. Inject the filter into target test control plane networks (Nova / Neutron). - Verification: End-to-end integration mapping. Execute an OpenStack API
transaction using an ES256 access token generated by
keystone-rs. Assert that the Python service parses, cryptographically validates, and completes the authorization cycle locally without executing a single back-channel database lookup.
Implementation note: the Python middleware itself, and its injection into
Nova/Neutron, are out of scope for the keystone-rs repository – that code
runs in downstream service repos, not here. What this repository delivers for
Phase 5 is the Rust-side surface the middleware depends on plus a Rust-native
reference implementation of the exact same §6 verification algorithm
(openstack_keystone_core::oauth2_client::verify_openstack_access_token,
a pure function over an already-fetched JWKS/JTI-revocation set), and an
integration test that mints a real token through the same pipeline
POST /token uses and verifies it fully offline. This is the closest in-repo
proof of the Phase 5 verification bullet available without a second
repository in the loop.
11. Consequences
Positive
- Complete Database Decoupling: Downstream OpenStack microservices execute token authorization purely inside CPU memory cache lanes, completely insulating core storage clusters from token validation stress.
- Unified Protocol Surface: External cloud-native observability integrations (Grafana) and other third-party relying parties authenticate against the same standards-compliant OAuth2/OIDC surface, instead of a Keystone-specific integration.
Negative / Risks
- The Revocation Durability Gap: Because access tokens are fully stateless, immediate user termination or role revocation events must wait out the short 15-minute token TTL, or require services to implement a back-channel real-time verification endpoint for high-criticality operations.
- Refresh Token Reuse Grace Window: The 10-minute reuse grace period (§9) is a deliberate detection-latency tradeoff to avoid false positives on multi-device use. A stolen refresh token can be replayed by an attacker for up to 10 minutes before family revocation triggers - a real, bounded window, not a single-shot replay. Operators requiring tighter guarantees should disable the grace period at the cost of legitimate multi-device false positives.
- Stateful Refresh Token Bottleneck: While access and ID tokens are stateless, refresh token rotation requires persistent backend storage for reuse detection. Every refresh token write hits SQL or Raft, creating a partial database dependency that contradicts the “zero lookup” goal for the refresh path.
- Static Roles Window (§4,
openstack_context.roles): Roles are resolved at/authorizetime and baked into the JWT. If roles are removed from a user after issuance but beforeexp, the token carries stale roles. Unlike the Fernet path where roles are re-resolved on everynew_for_scope(), the stateless model has no re-validation at downstream services. The 15-minute default TTL bounds the window. This is an inherent tradeoff of stateless authorization and is documented rather than mitigated in v1. - Increased Key Rotation Surface: Operators must manage an independent asymmetric key rotation lifecycle policy alongside the existing symmetric Fernet key repositories.
- DPoP Deferred to v1.5: v1 tokens are pure bearer with no demonstrable
proof of possession (RFC 9449). A compromised access token grants access until
exp. The primary containment is the short 15-minute TTL combined withnbfenforcement. DPoP binding is scoped to v1.5 to close this gap without adding v1 complexity. Operators deploying to non-mTLS control planes should treat network traffic as partially trusted and consider mTLS or shorter TTLs as compensatory controls. - JTI Revocation List (§3 Emergency Rotation): The jti revocation list published alongside JWKS adds a lightweight stateful dimension to an otherwise stateless verification pipeline. The middleware must query this endpoint on every request. The list is TTL-bounded (15 min per entry) to prevent unbounded growth. Both this endpoint and the JWKS endpoint are fail-closed (§6): on fetch failure, the middleware rejects the request rather than serving stale JWKS data or accepting the token unchecked. This is a deliberate availability-over-containment tradeoff — a Keystone/network outage now also blocks OpenStack API calls, not just token issuance — accepted because fail open would let an attacker who can interfere with the middleware’s connectivity to either endpoint keep an already-revoked compromised key validating for the duration of the outage, defeating emergency rotation’s immediate-containment guarantee. Operators must treat both endpoints’ availability as load-bearing for the entire control plane, not just for token issuance.
- First HTML Surface: The server-rendered login/consent forms introduce CSRF, clickjacking, and open-redirect vectors that the rest of Keystone’s API-only design has never had to defend. RFC 9700 mitigations are specified in §8.
domain_idEnumeration via JWKS:GET /v4/oauth2/{domain_id}/jwksis unauthenticated by design (relying parties must fetch it without a Keystone token) and returns404for an unknowndomain_idversus200for a provisioned one, letting an anonymous caller confirm whether a givendomain_idexists. This is accepted, not mitigated further: every major multi-tenant OIDC provider (Auth0 tenant name, Okta org subdomain, Keycloak realm name, Google Workspacehddomain) treats the tenant identifier in the issuer/JWKS URL as public, not secret — it is embedded in every issued token’sissclaim and handed to every RP’s configuration, so it cannot be kept confidential once a single client is onboarded. Sincedomain_idis a server-generated 128-bit UUIDv4 (crates/core/src/resource/service.rs,Uuid::new_v4()), brute-force guessing across the ID space is computationally infeasible and further bounded by the endpoint’s per-IP rate limit (ADR-0022). The one exception is the bootstrap domain, whose ID defaults to the literal string"default"([identity] default_domain_id,crates/config/src/identity.rs) rather than a UUID — its existence is trivially guessable, but this is the same “Default domain exists” fact every OpenStack Keystone deployment has always exposed via other unauthenticated-adjacent surfaces (e.g.clouds.yamlconventions, federation metadata), and confirming its existence discloses nothing beyond that fact. No code change is planned; operators who consider even this disclosure unacceptable may override[identity] default_domain_idto a random value at bootstrap time.- Client ID Claim Enumeration:
OpenStackAccessTokenClaimscarriesclient_id, which identifies the registering OAuth2 client (OidcAccessTokenClaims, §4, does not). If an attacker obtains anOpenStackAccessTokenClaimsaccess_token(e.g., via XSS on an RP page or browser dev tools), they can extract the internalclient_id. Since the token is cryptographically signed and aud-bound, enumeration alone does not enable privilege escalation. However, combined with misconfigured endpoints that ignoreaudverification, it could enable targetedclient_idguessing. Mitigation: strictaudenforcement in all downstream services; v2 may moveclient_idto a separate internal claim not exposed to RPid_token. keystone_ruleset_versionBlast Radius: When OPA policy rules change, the ruleset hash (keystone_ruleset_version) updates. All outstanding tokens carrying the old hash become stale. This claim is advisory-only: downstream middleware accepts tokens with either the current or previous ruleset version during a rolling policy update. Only when a new version is published is the previous version retired (graceful 15-min window). The claim enables audit correlation but does not invalidate tokens.
12. Deferred: Token Exchange & Delegation (RFC 8693, v2)
OpenStackAccessTokenClaims already reserves the delegation_context
structurally-typed enum (§4) for trust/app-cred/EC2 delegation —
DelegationContext already has Trust/AppCred/Ec2 variants alongside
Plain (§4) — but v1’s grant set - authorization_code, client_credentials,
refresh_token, device_code - has no path that ever produces one of them.
Without a grant that can populate DelegationContext::Trust/AppCred/Ec2,
the Plain variant is baked into every v1 token. RFC 8693 Token Exchange is the
standards-based mechanism to close that gap, deferred to v2 rather than built
now, so it is recorded here rather than left implicit.
Not the same RFC 8693 usage §1 calls a trap. §1’s “Circular Token Exchange Trap” was an external-IdP-issued JWT being traded for a legacy Fernet token - eliminated by native OP issuance. This is the reverse direction: a Keystone-native credential (trust, application credential, EC2 signature) traded for a Keystone-native delegated JWT. That is a first-class OP capability this ADR enables, not the workaround it replaces.
v2 Shape (Not Built in Phases 1-5)
- New grant type:
urn:ietf:params:oauth:grant-type:token-exchangeadded toOAuth2Client.grant_types. - Request:
/tokenacceptssubject_token(an existing valid Keystone token - Fernet or native JWT - representing the trust/app-cred/EC2 grantor context),subject_token_type, andrequested_token_type(urn:ietf:params:oauth:token-type:access_token). - Response: A new
access_tokenwithdelegation_contextset to whichever ofDelegationContext::Trust { project_id },AppCred { project_id }, orEc2 { project_id }(§4) matches the grantor context of the presentedsubject_token(replacing the v1 defaultPlain),project_idpopulated from the delegation object’s immutable project, per security.md invariant I2. - Invariant enforcement unchanged: the downstream middleware’s I3
scope-drift tripwire (§6, step 7) already branches on
auth_method != 'plain'rather than a specific delegated variant, so it enforces I1/I2/I5 identically regardless of which of the three delegated variants token exchange populates; it does not introduce new enforcement paths. - Rate limiting: the exchange endpoint is a
/tokensub-path and inherits the pre-hash enforcement of §7.A - the requesting client’s credentials are quota-checked before any exchange logic runs. - Second candidate use (also deferred): on-behalf-of downscoping for
service-to-service hops - e.g., Nova holding a user’s bearer
access_tokenexchanges it for a narrower-audience, shorter-TTL token before calling Neutron, rather than forwarding the original bearer token unchanged across a service boundary. This reduces the blast radius of a token leaked or logged mid-hop, given §4 already establishes these tokens as bearer/stateless with no replay binding beyond TTL. Audience-narrowing semantics for this case are unspecified here and need their own design pass before implementation.
This section intentionally stops at the shape above - full request/response
schemas, OAuth2Client authorization checks for who may request delegation on
whose behalf, and CADF event definitions are left to the follow-up ADR amendment
that actually implements this grant.
Implemented (Phase 6 Amendment)
The grant above is now built, as the follow-up amendment this section itself anticipated. Concrete choices made during implementation:
AppCredonly, notTrustorEc2. OnlyApplicationCredential’s immutable project (ApplicationCredential.project_id) is carried on the object embedded inAuthenticationContext::ApplicationCredentialoncesubject_tokenis validated through the existingTokenApi::validate_to_contextpipeline - no new provider lookup needed.AuthenticationContext::TrustandAuthenticationContext::Ec2Credentialare rejected outright (invalid_grant). WhileTrust.project_idis technically available, trust tokens have special handling characteristics (impersonation, trust-specific constraints) that make them unsuitable for token exchange.AuthenticationContext::Ec2Credentialcarries no such object (by design - see its own doc comment: a plain EC2 credential has no delegation metadata of its own unless it was itself minted through a trust or app-cred, in which case the outerTrust/ApplicationCredentialcontext already applies). Deriving an EC2 credential’s own bound project would need a provider lookup this phase does not add; guessing wrong on I2’s boundary is not an acceptable risk. A future increment can add them once those concerns are addressed.- Authorization gating: the exchanging
OAuth2Clientmust holdtoken-exchangein its owngrant_types, the same Tier-1/Tier-2-agnostic mechanism every other grant uses (client.grant_types.contains(...)) - enabling the grant on a client is itself an ordinary client update, gated by the existingpolicy/oauth2/client/update.rego. No new admin-only carve-out was added specifically for this grant (unlikepre_authorized), since - unlike pre-authorization skipping user consent entirely - a Token Exchange grant only ever re-expresses a delegation the presentedsubject_tokenalready proves the caller holds; it grants no new authority beyond what that token’s own chain already carries. keystone_ruleset_version: set to0(a sentinel, not a real ruleset hash) - a token-exchange grant does not go through aMappingRuleSetat all (the subject is an already-authenticated native Keystone credential, not an external ingress source), so there is no ruleset state to anchor to. The claim remains advisory-only (§11) and this sentinel does not affect downstream enforcement.- No audit-log-derived jti backfill dependency: unlike §3’s emergency
rotation, Token Exchange needed no new audit query capability - the grantor
object needed is already embedded in the validated
ValidatedSecurityContext, no separate lookup or log query required. - CADF event: reuses the existing
emit_oauth2_session_eventbest-effort path (the same oneclient_credentials/authorization_codeuse), keyed onclient_id- no new event type was needed since delegation-specific detail already lives in the returneddelegation_contextclaim itself, and no emergency/critical posture applies to a routine token mint.
13. Fernet Coexistence & Long-Term Migration Strategy
keystone-rs is deployed in parallel with Python Keystone during the
migration phase: both serve the same v3 API behind a shared VIP, and existing
deployments overwhelmingly run the Fernet token provider with a shared,
filesystem-synchronized key repository. Everything in this ADR is therefore
additive by construction — the §6 middleware’s explicit fall-through to the
legacy Fernet filter chain is the load-bearing coexistence mechanism, and no
stage below invalidates a token or breaks a client that worked in the previous
stage.
Stage 0 — Today: Fernet Interchangeability (shipped)
Python Keystone is the issuer of record. keystone-rs encodes/decodes
byte-compatible Fernet tokens from the shared key repository
(token-driver-fernet + crates/key-repository). Operational constraint:
Fernet key rotation must remain coordinated across both implementations
(same rotation tooling, same max_active_keys discipline).
Stage 1 — v3 Token Format Parity (Phase 0)
The provider abstraction and Python-compatible JWS driver (§10, Phase 0) land.
keystone-rs can now stand in for Python Keystone regardless of which
[token] provider the deployment chose, and validates both formats during a
provider transition. This removes the last v3-surface reason a deployment
could not shift token issuance traffic to keystone-rs.
Stage 2 — OP Goes Live, Additive Only (Phases 1-5)
The OAuth2/OIDC surface ships. No existing flow changes: Fernet issuance and
validation continue untouched. New consumers onboard directly to JWT — cloud-
native workloads via client_credentials, third-party RPs via
authorization_code. The §6 middleware is injected into control-plane Paste
pipelines in front of keystonemiddleware.auth_token; requests without an
OP-issued Bearer JWT fall through unchanged. Rollout is therefore incremental
per service, per region, with instant rollback (remove the filter). Services
with the loosest revocation-latency requirements (read-heavy, low-criticality)
convert first; see the revocation gate below.
Stage 3 — Machine Identity Migration
Highest-volume, lowest-friction migration: automated API consumers (billing
collectors, monitoring, orchestrators, K8s operators) move from application
credentials / stored passwords to registered OAuth2Clients. This is where
the validation-load win (§11, “Complete Database Decoupling”) is actually
realized, since machines dominate request volume. Python Keystone is demoted
to serving legacy human/v3 flows.
Stage 4 — Human Flow Migration & Python Keystone Retirement
CLI login moves to the Device Authorization Grant; dashboards and portals
become OIDC RPs. Remaining v3 Fernet issuance is served by keystone-rs
alone (possible since Stage 1), and Python Keystone is removed from the VIP.
The RFC 8693 Token Exchange grant (§12, v2) eases the long tail: any client
still holding a valid Fernet token can trade it for a native JWT without
re-authenticating, inverting the §1 Circular Trap in the direction that helps
migration.
Stage 5 — Fernet Sunset
A config switch moves Fernet to validate-only (no new issuance), then off.
keystonemiddleware.auth_token and the §6 fall-through path are removed from
Paste pipelines; Fernet keys are retired after the last token’s exp.
End-state: a single asymmetric key lifecycle (§3) instead of two parallel key
repositories — retiring the “Increased Key Rotation Surface” risk in §11.
Cross-Cutting Gate: Revocation Semantics Parity
Fernet validation consults revocation events on every back-channel check;
stateless JWTs wait out exp (§11, “Revocation Durability Gap”). A service
may only move from Stage 2’s fall-through posture to preferring JWTs once its
operator explicitly accepts the 15-minute revocation window (or wires
back-channel introspection for its high-criticality operations). This
acceptance is per-service and must be recorded in the deployment’s migration
runbook — it is the one semantic regression Fernet-to-JWT migration cannot
paper over, and it is the reason Stages 2-4 are ordered by revocation-latency
tolerance rather than by implementation convenience.
27. LDAP Identity Driver
Date: 2026-07-09
Status
Proposed
Context
LDAP is the most widely deployed identity backend across OpenStack clouds. Organizations operate multi-thousand-user LDAP directories (FreeIPA, Active Directory, OpenLDAP, JumpCloud, Azure AD) and rely on Keystone as the authentication proxy, mapping external directory users and groups to local authorization contexts.
The Python LDAP backend (keystone/identity/backends/ldap/) is a mature,
field-proven implementation with over two decades of production deployments.
Any Rust implementation must achieve configuration-compatible behavior with the
Python driver so that both can operate against the same directory server
returning identical results.
Parallel Execution Requirement
The primary design goal is operational parity. A keystone-rs deployment must
behave identically to its Python counterpart when configured with the same LDAP
parameters, enabling:
- Rolling upgrades – mixed Python/Rust Keystone deployments querying the same directory without behavior divergence.
- Configuration portability – a
[ldap]config section written for Python Keystone works unmodified inkeystone-rs. - Fallback capability – operators can switch between
driver = sqlanddriver = ldapat the configuration level without code changes.
Python LDAP Backend Architecture
The Python implementation spans four modules totaling ~2,823 lines:
| File | Lines | Purpose |
|---|---|---|
__init__.py | 13 | Re-exports from core.py |
core.py | 491 | Identity, UserApi, GroupApi classes |
models.py | 75 | User and Group model definitions |
common.py | 2,244 | BaseLdap, EnabledEmuMixIn, connection handlers, type conversions |
Key characteristics inherited from the Python design:
- Not domain-aware (
is_domain_aware() -> False). All LDAP users occupy a single flat namespace. - Does not generate UUIDs (
generates_uuids() -> False). Identifiers come from the LDAP directory, not Keystone. - Read-only by default. All mutating operations are gated behind
common_ldap.WRITABLE(defaultFalse). - Two connection pools – a service pool for directory queries and a dedicated auth pool for end-user authentication.
keystone-rs Identity Model Tension
keystone-rs IdentityBackend trait (27 methods) is designed around a
local-data, domain-aware SQL backend. LDAP operates under fundamentally
different constraints:
- Users are managed externally, not by Keystone.
- Authentication is an LDAP bind, not local password hash verification.
- Group membership lives in
memberDN attributes, not a local junction table. - No local
domain_idconcept exists in LDAP.
This ADR reconciles these differences while maintaining the IdentityBackend
contract, ensuring both backends produce identical API responses.
Decision
1. Crate Structure
Follow ADR-0018 naming convention:
openstack-keystone-identity-driver-ldap.
crates/
openstack-keystone-identity-driver-ldap/
Cargo.toml
src/
lib.rs # LdapBackend, anchor(), inventory registration
backend.rs # IdentityBackend impl
config.rs # LdapConfig
connection.rs # Pool-managed LDAP connections
user.rs # UserApi: LDAP query builder and result mapper
group.rs # GroupApi: LDAP query builder and result mapper
filter.rs # Hints → LDAP filter translation
enabled.rs # Enabled emulation (bitmask, invert, group membership)
models.rs # LDAP entry → core-types conversion
The crate declares inventory::submit! registration and exposes a public
#[allow(dead_code)] pub fn anchor() {} for ADR-0018 linker anchor discovery.
2. Configuration Mapping
The [ldap] config section maps 1:1 with Python’s conf.ldap.* option names.
#![allow(unused)]
fn main() {
#[derive(Debug, Clone, Deserialize, Validate)]
pub struct LdapConfig {
// --- Connection ---
pub url: String,
pub user: Option<String>,
pub password: Option<SecretString>,
pub use_tls: bool,
pub tls_cacertfile: Option<String>,
pub tls_cacertdir: Option<String>,
pub tls_req_cert: String, // "demand", "allow", "try", "never"
pub connection_timeout: f64,
pub randomize_urls: bool,
pub pool: bool, // use_pool
pub pool_size: i32,
pub pool_retry_max: i32,
pub pool_retry_delay: f64,
pub pool_connection_timeout: f64,
pub pool_connection_lifetime: f64,
pub auth_pool: bool, // use_auth_pool
pub auth_pool_size: i32,
pub auth_pool_connection_lifetime: f64,
// --- Query ---
pub query_scope: String, // "one" or "sub"
pub page_size: i32,
pub alias_dereferencing: String, // "never", "search", "always", "find"
pub chase_referrals: bool,
pub debug_level: i32,
// --- User Mapping ---
pub user_tree_dn: String,
pub user_objectclass: String,
pub user_id_attribute: String,
pub user_name_attribute: String,
pub user_mail_attribute: String,
pub user_enabled_attribute: String,
pub user_enabled_mask: Option<i32>,
pub user_enabled_invert: bool,
pub user_enabled_default: bool,
pub user_additional_attribute_mapping: HashMap<String, String>,
pub user_filter: Option<String>,
pub user_attribute_ignore: HashSet<String>,
pub user_enabled_emulation: bool,
pub user_enabled_emulation_dn: Option<String>,
pub user_enabled_emulation_use_group_config: bool,
// --- Group Mapping ---
pub group_tree_dn: String,
pub group_objectclass: String,
pub group_id_attribute: String,
pub group_name_attribute: String,
pub group_desc_attribute: String,
pub group_member_attribute: String,
pub group_additional_attribute_mapping: HashMap<String, String>,
pub group_filter: Option<String>,
pub group_ad_nesting: bool,
// --- General ---
pub suffix: String,
}
}
Environment variable override: OS_LDAP__<KEY>. Defaults mirror Python’s
DEFAULT_* conventions (e.g., user_objectclass = "inetOrgPerson",
group_objectclass = "groupOfNames").
3. IdentityBackend Method Mapping
The LdapBackend struct implements IdentityBackend. Each of the 27 methods
maps to an LDAP operation, a read-only rejection, or a “not implemented”
response.
User CRUD
| Method | LDAP Operation |
|---|---|
create_user | Forbidden – returns IdentityProviderError::Readonly |
get_user(user_id) | id_to_dn → BASE search |
update_user | Forbidden – Readonly |
delete_user | Forbidden – Readonly |
list_users(params) | Subtree paged search + hint filters |
find_federated_user | Name-based search (maps idp_id → domain_id) |
get_user_domain_id | Returns default_domain_id from config |
Authentication
| Method | LDAP Operation |
|---|---|
authenticate_by_password | Two-step: resolve user DN via service bind, then bind as user DN + password |
The authentication path is the primary function of an LDAP identity driver:
- Resolve
user_idorname + domain_idto the user’s LDAP DN. - Attempt LDAP simple bind using the user DN and provided password.
- On success, fetch full user attributes using the service bind pool.
- Return
AuthenticationResultwith the mappedUserResponse.
Step 2 uses the dedicated auth_pool to prevent authentication storms from
exhausting the service query pool.
Group CRUD
| Method | LDAP Operation |
|---|---|
create_group | Forbidden – Readonly |
get_group(group_id) | id_to_dn → BASE search |
list_groups(params) | Subtree search, filtered by group_objectclass |
delete_group | Forbidden – Readonly |
Membership
| Method | LDAP Operation |
|---|---|
add_user_to_group | Forbidden – Readonly |
remove_user_from_group | Forbidden – Readonly |
list_groups_of_user(user_id) | Reverse search: find groups where member = user_dn. When group_ad_nesting, uses LDAP_MATCHING_RULE_IN_CHAIN. |
list_users_of_group(group_id) | Direct read of member attribute on group entry |
set_user_groups | Forbidden – Readonly |
All expiring-variant methods (add_user_to_group_expiring, etc.) return
IdentityProviderError::NotImplemented. Expiring membership is a federation
concept not applicable to LDAP.
Password and Service Accounts
| Method | Behavior |
|---|---|
update_user_password | Forbidden – Readonly. Passwords are LDAP-managed. |
create_service_account | NotImplemented. LDAP has no service account concept. |
get_service_account | Returns None always. |
4. ID-to-DN Mapping
Mirrors Python’s _id_to_dn and _dn_to_id methods exactly.
id_to_dn(object_id) → DN
For query_scope = "one": direct DN construction.
dn = "{id_attr}={object_id},{tree_dn}"
For query_scope = "sub": subtree search then extract DN from single result.
dn_to_id(dn) → object_id
If id_attr matches the RDN attribute, extract from DN directly. Otherwise,
perform a BASE-scoped search on the DN and read the id_attr value.
5. Filter Translation (Hints → LDAP Filters)
Mirrors Python’s filter_query method. Hints are translated to LDAP AND
clauses:
| Hint Comparator | LDAP Filter | Example |
|---|---|---|
equals | ({attr}={value}) | (uid=jdoe) |
contains | ({attr}=*{value}*) | (uid=*doe*) |
startswith | ({attr}={value}*) | (uid=j*) |
endswith | ({attr}=*{value}) | (uid=*doe) |
Exclusion rules (identical to Python):
- Case-sensitive filters (
equals_case): skipped. Handled at controller level. enabledfilter: skipped. Requires bitmask/emulation logic that doesn’t compose into a simple AND clause.- Unknown attributes: skipped silently.
Filter escaping: values must escape *, (, ), \, and NUL bytes
to prevent LDAP filter injection.
Combined filter structure:
(&{OBJCLASS}{USER_FILTER}{AND_ATTRIBUTE_FILTERS})
Satisfied filters are removed from hints; any remaining unsatisfied filters are re-evaluated in-memory after LDAP returns results.
6. Result-to-Model Conversion
Mirrors Python’s _ldap_res_to_model:
- Case-insensitive attribute matching – LDAP attribute names are lowercased
before matching
attribute_mapping. - Bytes handling – Rust LDAP library (e.g.,
ldap3) handles UTF-8 natively. - Multi-value ID attributes – fall back to DN as identifier.
- Additional attributes –
*_additional_attribute_mappingentries are exposed as user/groupextras. - Private attribute filtering – matches Python’s
filter_entity(removesdn,passwordfrom results).
7. Enabled Emulation
Four strategies, all configurable per the Python EnabledEmuMixIn:
| Strategy | Config | Mechanism |
|---|---|---|
| Bitmask | *_enabled_mask | Read integer attribute, apply bit mask |
| Invert | *_enabled_invert | Invert boolean interpretation |
| Emulation | *_enabled_emulation | Group membership in cn=enabled_us*rs,{tree_dn} |
| Default | *_enabled_default | Fallback when attribute absent |
8. Connection Architecture
┌─────────────────────┐
│ LdapBackend │ ← IdentityBackend impl
│ (query builder) │
└────────┬────────────┘
│
┌────────▼────────────┐
│ Connection Pools │
│ ├── service_pool │ service bind DN → all queries
│ └── auth_pool │ user DN + password → auth only
└────────┬────────────┘
│
┌────────▼────────────┐
│ LdapConnection │ ← ldap3 crate
│ (TLS, bind, etc.) │
└─────────────────────┘
Paged search (RFC 2696) is used for all subtree operations to handle
directories with more than page_size results.
9. Active Directory Nested Groups
When group_ad_nesting = true, list_groups_of_user uses LDAP_MATCHING_RULE_IN_CHAIN:
FILTER: (&{OBJCLASS}{member_attr}={user_dn}:1.2.840.113556.1.4.1941)
Guarded behind the group_ad_nesting flag for non-AD compatibility.
10. LDAP Library Choice
| Library | Pool | TLS | Paged Search | Maturity |
|---|---|---|---|---|
ldap3 | Yes | Full | RFC 2696 | Production |
slapd-rs | No | No | No | Niche |
ldap3 is selected for its complete operation set, native TLS support,
paged search, and async API compatibility.
11. No User Shadowing or ID Mapping
The keystone-rs SQL driver maintains three separate user storage
tables and two user-facing ID mapping layers:
| Table | Schema | Purpose |
|---|---|---|
user | id, name, domain_id, enabled, extra, … | Main user record |
local_user | id, password | Local user password data |
nonlocal_user | (domain_id, name, user_id) FK | Federated user link |
federated_user | id, user_id, idp_id, protocol_id, unique_id | IdP protocol binding |
idmapping | domain_id, entity_type, local_id, public_id | Local ↔ public ID mapping |
The SQL create flow works as:
- Insert
user(main record) - If local user → insert
local_user(with bcrypt password hash) +password - If federated user → insert
federated_user(links toidp_id+unique_id) - The
idmappingtable maps the local user UUID to a public-facing ID
The LDAP driver uses NONE of these tables. LDAP users are not shadowed into local storage. The authentication and lookup flow bypasses the SQL identity tables entirely:
┌──────────────┐ LDAP bind ┌──────────────┐
│ │ ────────────────→ │ │
│ Keystone │ │ LDAP │
│ (read-only) │ ← user entry ←── │ Directory │
│ │ │ │
└──────────────┘ └──────────────┘
When driver = "ldap":
authenticate_by_password→ LDAP bind (no local password verification)get_user,list_users→ LDAP subtree search → direct API responseget_group,list_groups→ LDAP subtree search → direct API response- No local table writes occur at any point
The idmapping system is used by the SQL driver to map internal UUIDs
(public_id: local_id). LDAP uses the directory’s native identifier
(user_id_attribute, default cn) as the id in the Keystone API
response. No additional ID mapping layer is needed because the LDAP
identifier IS the public identifier.
This is intentional and matches Python’s behavior: the Python LDAP driver
sets generates_uuids() -> False, meaning it never creates local
identifiers. The nonlocal_user table is exclusively for federation
(OIDC/SAML/Passkey) where external IdP assertions are mapped to local
Keystone user records. The LDAP driver is a standalone identity backend,
not a federation endpoint.
12. Error Handling
IdentityProviderError gains new variants:
#![allow(unused)]
fn main() {
pub enum IdentityProviderError {
// ... existing variants ...
Readonly(String), // Operation not permitted on read-only LDAP
LdapConnection(String), // Connection, bind, TLS, pool errors
LdapFilterBuild(String), // Filter construction failures
}
}
Consequences
Positive
-
Configuration parity – An operator’s
keystone.conf [ldap]section works identically across Python and Rust deployments. No conversion tooling required. -
Auth storm isolation – The dedicated auth pool prevents a login storm from exhausting directory query capacity and breaking control-plane operations.
-
Zero local data mutation – The read-only contract means
keystone-rscannot corrupt the LDAP directory. All write operations return clearly named errors. -
AD compatibility –
group_ad_nestingwith IN_CHAIN matching provides native AD nested group support, matching Python’s behavior. -
Pagination safety – RFC 2696 paged search ensures large directories don’t exceed LDAP server
sizelimitand return incomplete result sets. -
Identity separation – LDAP, federation, and SQL identity data remain in separate storage layers. No cross-backend confusion.
Negative
-
Trait overhead – ~8 of 27
IdentityBackendmethods are no-ops or return hardcoded errors. A more minimal trait (e.g.,ReadonlyIdentityBackend) could reduce this but would complicate the plugin registration model. -
No domain awareness – LDAP doesn’t map to Keystone’s multi-domain model. All users share
default_domain_id. Operators needing multi-domain separation must use a local SQL backend or federation mappings. -
No write capability – Deferred write support means operators cannot use LDAP for user or group lifecycle management. If future work enables
WRITABLEmode, the risk of directory corruption must be carefully managed. -
New dependency –
ldap3adds an external LDAP protocol dependency to the Rust crate graph. -
No
idmappingornonlocal_user– LDAP users bypass the local identity shadowing layer entirely. This means LDAP users cannot be mixed with local SQL users in the same Keystone deployment. If an operator needs both LDAP users AND local service accounts, they must switch back todriver = sqlor use federation to bridge the gap.
Migration Path
The driver crate integrates via ADR-0018’s automatic discovery:
- Add
openstack-keystone-identity-driver-ldapcrate to workspace. - Add as dependency of
crates/keystone/Cargo.toml. build.rsdiscovers it automatically via thedrivername filter.- Set
[identity] driver = "ldap"in config to activate.
Testing Strategy
- Config parity tests – Verify Rust
LdapConfigdeserializes identically to Python’sconf.ldapfor a set of representative config files. - Filter translation tests – Unit tests verifying hint-to-filter mapping matches Python output for all comparator types.
- LDAP mock server tests – Integration tests against a controllable LDAP
mock (e.g.,
ldap-mockorslapdin Docker) validating full query paths. - Side-by-side validation – Smoke tests running Python and Rust backends against the same directory and comparing API responses.
28. Quorum-Bypass Emergency Operations (Amends ADR 0026 §3 and ADR 0016-v2 §6.2)
Date: 2026-07-15
Status
Implemented (2026-07-16) — see Implementation Status below for the two resolved design gaps and the scope decisions made along the way.
Reference
Amends two existing emergency-rotation designs that share an identical, unbuilt gap:
-
ADR 0026 §3 (“Emergency Rotation and Signing Key Compromise”), which specifies but does not build:
As a fallback, an out-of-band emergency rotation can be triggered locally on any node via UDS + loopback, without Raft quorum coordination, when the cluster is compromised and dual-control is impossible.
-
ADR 0016-v2 §6.2 (DEK emergency rotation), whose
rotate-dek --emergencypath is also a Raft-gated, dual-control gRPC call with no local fallback, and which ADR 0026 §3’s design was explicitly modeled on.
Both are instances of the same underlying problem: a Raft-committed emergency operation cannot execute at the one moment it is most likely to be needed — when quorum is already lost. Rather than solve this twice (and inevitably drift into two slightly different local-bypass mechanisms with two slightly different failure modes), this ADR defines one quorum-bypass mechanism that both subsystems instantiate, and that future Raft-gated emergency operations can adopt without re-deriving the design.
Context
Every write in both existing emergency paths — staging, confirming, promoting,
and recording revocations for a signing key
(crates/core/src/oauth2_key/service.rs) or a DEK (crates/storage/,
RotateDekRequest) — is a Raft proposal. Raft proposals require quorum to
commit.
That is fine for the common case: “key/DEK compromised, cluster healthy.” It fails exactly in the scenario both ADRs called out as the reason for a fallback: Raft has simultaneously lost quorum (majority of nodes down, or network-partitioned) at the moment a key or DEK is suspected compromised. In that window:
- The operator cannot rotate anything through the existing paths — every proposal blocks indefinitely waiting for a leader/quorum that doesn’t exist.
- The compromised key/DEK keeps validating or decrypting (and, if an attacker holds it, keeps being useful to them) for as long as the partition lasts — possibly a window the attacker engineered by causing the partition in the first place.
- “Just wait for quorum” is not an acceptable answer, because the premise is that the material is being actively abused right now.
No existing mechanism in the codebase models this for either subsystem. This is why both ADRs recorded the requirement but did not build it: it is a standalone distributed-systems design problem — local-write availability under partition, an authentication boundary that does not itself depend on the thing that’s partitioned, and reconciliation on rejoin — not a follow-up-sized feature, and not one worth designing per-subsystem.
Decision
Add a generic node-local emergency write path, usable by any Raft-gated
emergency operation, that operates entirely without Raft and is authenticated
over the existing admin UDS — the same SPIFFE-mTLS Unix-domain-socket
interface (spiffe_tls_uds, [interface_admin]) that keystone-manage already
uses for the ordinary (quorum-requiring) emergency rotation paths in ADR 0026 §3
and ADR 0016-v2 §6.2. This section specifies the generic write path, why the
existing admin UDS is sufficient as the auth boundary, and the reconciliation
semantics required to make a non-Raft write safe. It intentionally does not
get built as part of this ADR being accepted — see “Implementation Status”
below.
0. Why the existing admin UDS, not a new one
- The admin UDS’s SPIFFE mTLS verification is against a locally cached
X.509-SVID and trust bundle, served to
keystoneby the localspire-agentover its own workload-API socket — not a round-trip to a remote SPIRE server. A Raft network partition (which affects[distributed_storage] node_cluster_addrconnectivity between nodes) is a different network path entirely from a host-local SPIRE agent socket. The two failure domains are not the same, and treating “Raft can’t reach quorum” as evidence “SPIFFE mTLS is unusable” was an unjustified leap. - The admin UDS listener (
interface_admininkeystone.rs,spiffe_tls_uds::start_axum_app) already enforces a peer-credential check —peer_uid/peer_gid— layered underneath the mTLS handshake. That is the same class of local trust boundary the original draft wanted to build from scratch (SO_PEERCREDagainst a dedicated group); it already exists, is already configured, and is already the boundary every other admin operation (client registration, normal signing-key rotation, DEK rotation triggers) depends on. - A second socket would mean a second listener, a second auth path, a second
keystone.confsection, and a second thing to keep in sync with the first — for a security property (local-process authentication) the first socket already provides. That is complexity this design does not need.
Reusing the admin UDS is therefore the default, not an optimization: this ADR builds one new capability (Raft-bypassing local writes with reconciliation) on the existing authentication boundary, rather than building a new boundary too.
When this would not be sufficient. If the local spire-agent itself is down
or its cached SVID has expired at the same moment quorum is lost — a compound
failure, not the partition scenario this ADR targets — the admin UDS is unusable
regardless of Raft state, with or without this ADR. That is a pre-existing
operational dependency of every admin-UDS-gated operation in the codebase today
(not something this ADR introduces), and is out of scope here: hardening
SPIRE-agent-down resilience, if wanted, is a separate ADR. This design does not
add a second fallback for that compound case, because doing so would reintroduce
exactly the “new trust boundary nobody asked for” cost this section just
rejected.
1. Trigger conditions and operator workflow
This path is not a first resort. The existing quorum-based emergency rotation paths (ADR 0026 §3, ADR 0016-v2 §6.2) remain the default; this path is reached only when an operator has already determined those are unusable. The CLI shape is shared across subsystems, with the target resource identifying which:
# OAuth2 signing key
keystone-manage oauth2 rotate-signing-key --domain <domain_id> \
--emergency --local-quorum-bypass \
--justification "<free text, required, goes into local audit trail>"
# DEK
keystone-manage storage rotate-dek \
--emergency --local-quorum-bypass \
--justification "<free text, required, goes into local audit trail>"
Both commands connect over the same admin UDS used by their non-bypass
--emergency counterparts (SPIFFE mTLS + peer_uid/peer_gid); the only new
element is the --local-quorum-bypass flag and the code path it selects on the
server side.
--local-quorum-bypassis refused unless the target node’s local Raft client reports it is not part of a functioning quorum (leaderless, or no heartbeat within a configurable window). This is a guardrail against accidental misuse, not a security control — see Threat Model for why it cannot be trusted as one.- Dual control is not required for this path. Both ADR 0026 §3 and ADR 0016-v2 §6.2’s two-operator confirmation exists to prevent a single compromised/rogue operator credential from unilaterally rotating a key/DEK; that control assumes the second operator can reach the cluster to confirm. Under partition, a second operator may not be reachable at all, and demanding one would make the “impossible” case both ADRs’ own framing already describes literally impossible. Single-control is the accepted tradeoff for this path only — see Consequences.
--justificationis mandatory free text, persisted locally (below) and surfaced prominently by the reconciliation step, so the eventual review has the operator’s own stated reasoning, not just a bare event.
2. The write path (generic, per-subsystem-instantiated)
- Perform the subsystem-specific generation step in memory: a fresh signing keypair (identical crypto path to ADR 0026 §3 normal rotation) or a fresh DEK (identical path to ADR 0016-v2 §6 step 1).
- Write it to a node-local, non-Raft store, under a namespace shared across
subsystems so the reconciliation and gossip machinery (below) is one
implementation, not two:
_local:<subsystem>:<scope_id>:emergency:<rotation_id>, e.g._local:oauth2_signing_key:<domain_id>:emergency:<rotation_id>or_local:dek:cluster:emergency:<rotation_id>— persisted in the node’s local FjallDB partition (the same storage engine Raft state uses, but written directly, bypassing the Raft log entirely — this is the whole point). - Mark it active for this node’s own signing/encryption operations only. Other nodes are not informed synchronously (they may be unreachable) — see Propagation below.
- Record the compromised key/DEK as locally revoked and start (or extend) a
local-only revocation record under the same namespace convention
(
_local:oauth2_signing_key:<domain_id>:revoked_jtisfor signing keys;_local:dek:cluster:revokedfor DEKs), merged with whatever the last-known Raft-replicated revocation state contained. For OAuth2 this is served immediately by the node’s copy of the ADR 0026 §6 revocation endpoint; DEKs have no external-facing revocation endpoint, so this entry exists for audit/reconciliation purposes only. - Append an entry to a local, tamper-evident audit log
(
_local:emergency:audit:<rotation_id>, HMAC-chained the same way as ADR 0023’s audit spool) recording: subsystem, operator identity (from the admin UDS’s SPIFFE ID and/or peer uid/gid), justification text, timestamp, old/new key identifier, and the node’s Raft term/quorum status at the time of the write. This is the record reconciliation and post-incident review depend on, for either subsystem.
3. Propagation while partitioned
A node that took a local emergency action while cut off from the rest of the cluster does not silently keep operating alone forever:
- If this node can still reach any peers (a network partition need not be
symmetric or total), it gossips the local emergency key/DEK and
revocation-state delta to reachable peers out-of-band from Raft —
best-effort, not consensus. A receiving peer stores it in the same
_local:...:emergency:...namespace (not promoted to its own Raft-replicated state) and, if it is not already using a different local emergency value for the same scope, adopts this one for its own signing/decryption so that reachable nodes converge without requiring quorum. - If a receiving peer already staged a different local emergency value for the
same scope (two operators independently declared an emergency on two different
partitioned segments), it does not silently pick one — it flags a
LOCAL_EMERGENCY_CONFLICTcondition (subsystem-tagged), keeps operating with its own local value, and surfaces the conflict for manual reconciliation (below). Silently choosing is exactly the split-brain risk this design must not paper over.
4. Reconciliation on quorum rejoin
This is the step that makes the local write safe rather than merely convenient — a local write is only acceptable because it has a defined, non-silent path back into cluster-authoritative state. Generic across both subsystems:
- When a node that performed (or adopted) a local emergency write rejoins a healthy quorum, it does not auto-promote its local value to the Raft-replicated authoritative slot. It submits the local emergency value as a proposed Raft rotation (reusing the normal rotation proposal shape for that subsystem) and blocks — continuing to serve with its local value in the meantime — until that proposal commits or is explicitly rejected by an operator.
- No value ever wins by default. If exactly one node performed a local
emergency write, its proposal is the obvious candidate and an operator
confirms it with a single, subsystem-specific command
(
keystone-manage oauth2 reconcile-emergency-key .../keystone-manage storage reconcile-emergency-dek ...). If multiple nodes performed conflicting local writes (LOCAL_EMERGENCY_CONFLICTfrom §3), reconciliation requires an explicit operator choice among the candidates — the system refuses to auto-merge two independently-generated values, since there is no principled way to know which (if either) actually excludes the compromised material an attacker might have used during the split. - Once a local emergency value is accepted into Raft, every node’s previous
local-only state for that scope (
_local:<subsystem>:<scope_id>:emergency:*) is cleared, and the standard subsystem-specific revocation and audit-event steps run as normal (ADR 0026 §3 step 5 for signing keys; ADR 0016-v2 §6.2 step 6 for DEKs), with the node-local audit trail (§2 step 5 above) attached as supplementary evidence, not a replacement for the normal audit event. - Data/tokens produced by a node during the local-only window remain valid
under whichever value ultimately wins reconciliation, provided that value is
what gets published; material produced under a rejected candidate becomes
unverifiable/undecryptable the moment that candidate is discarded (nothing
publishes or re-encrypts under it), which is the intended containment outcome
for a rejected/conflicting emergency candidate, not a bug to work around.
(For DEKs specifically, this means records written under a rejected local
emergency DEK must be identified via
dek_versionand re-encrypted under the winning key — an operator step, not automatic, mirroring ADR 0016-v2 §6 step 5’s CAS-on-version re-encryption but triggered manually here.)
5. Subsystem instantiations
- OAuth2 signing key (ADR 0026 §3): as originally scoped in this ADR’s first
draft — see §1-§4 above.
GET /v4/oauth2/{domain_id}/jwks/revocationcontinues to be the externally-visible surface; the local emergency revocation entries feed into it on the node that took the local action. - DEK (ADR 0016-v2 §6.2): the same mechanism applied to the cluster’s Data Encryption Key. Unlike the signing key, a DEK has no external HTTP surface — the “revocation” is purely internal (stop decrypting with it) — and reconciliation’s re-encryption step is heavier (full CAS-on-version sweep per §4.4 above), since DEKs protect data at rest, not externally-verified tokens.
- Future subsystems: any future Raft-gated emergency operation should instantiate this mechanism (namespace convention, admin-UDS trigger, gossip, reconciliation) rather than inventing a parallel one, unless it has a concrete reason the generic shape doesn’t fit — in which case that reason belongs in its own ADR amendment, the same way this one amends 0026 and 0016-v2.
Threat Model
This path deliberately narrows availability guarantees to preserve containment guarantees, mirroring the fail-closed posture ADR 0026 §6/§11 and ADR 0016-v2 §1 already established for their ordinary paths:
- This expands the operations reachable over the admin UDS, not the set of
principals who can reach it. Anyone who can already authenticate to the admin
UDS (SPIFFE mTLS +
peer_uid/peer_gid) — i.e. anyone who could already trigger ordinary emergency rotation given quorum — can now also trigger the quorum-bypass variant without a second operator’s confirmation. Because the auth boundary is unchanged from what every other admin-UDS operation already relies on, this ADR does not add a new category of exposure; it removes a control (dual-control) from an existing one, for the reasons given in §1. - Split-brain is possible, not eliminated. Two operators on two genuinely partitioned segments can each declare an emergency and produce two different “authoritative-until-reconciled” values for the same scope. The design’s answer is to make that conflict visible and blocking (§3/§4.2) rather than to guess a resolution — an incorrect automatic merge would be worse than a stalled reconciliation an operator has to look at.
- A local write is not retroactively provable as legitimate. The local audit
entry (operator identity from the admin UDS handshake, justification,
timestamp) is evidence for post-incident review, not a cryptographic proof the
action was authorized by policy the way the Raft-committed dual-control path
is. Deployments with a low tolerance for this residual trust requirement
should restrict admin-UDS access to the smallest possible operator set and
treat any use of
--local-quorum-bypassas an incident in its own right, reviewed regardless of outcome. - Compound failure (admin UDS also unusable) is out of scope, as noted in §0 — this ADR closes the “Raft partitioned, admin UDS fine” gap, not “everything is down at once.”
Implementation Status
Implemented across both subsystems: the shared local-emergency-store crate
(namespace, guardrail, gossip decision logic), a Fjall-backed store wired into
crates/storage, OAuth2 signing-key and DEK local-write/gossip/reconciliation
paths, and audit wiring. Two design gaps identified during implementation
(not anticipated by the ADR as originally written) were resolved as follows;
both diverge from a literal reading of this ADR and are recorded here rather
than silently.
Design gap 1: DEK transport. This ADR (and its originating planning
document) assumed keystone-manage storage rotate-dek already shared the
admin-UDS HTTP transport with the OAuth2 CLI commands. It doesn’t –
crates/cli-manage/src/storage/rotate_dek.rs talks gRPC directly to
ClusterAdminService over the internal management network (mTLS/SPIFFE), not
through interface_admin. Rather than moving DEK management onto a new HTTP
surface with no precedent, the DEK local-write, gossip, and reconciliation
operations were added as new ClusterAdminService RPCs
(RotateDekLocalEmergency, GossipLocalEmergencyCandidate,
ListDekLocalEmergencyCandidates, ReconcileDekLocalEmergency),
authenticated the same way RotateDek/ConfirmRotateDek already are. This is
a smaller, more consistent extension of an existing boundary than introducing
a parallel HTTP admin surface for one operation.
Design gap 2: audit mechanism. This ADR describes the local audit entry as
“persisted in the node’s local FjallDB partition… HMAC-chained the same way
as ADR 0023’s audit spool,” but ADR 0023’s actual mechanism
(crates/audit/src/spool.rs, dispatcher.rs) is a filesystem JSONL spool with
independent per-event HMAC (a seq field, not a hash chain), not a Fjall
partition. Implemented instead as an ordinary CadfEvent through the existing
AuditDispatcher/spool pipeline (OAUTH2_LOCAL_EMERGENCY_KEY_RECONCILED for
OAuth2; DEK_ROTATION_LOCAL_EMERGENCY_STAGED/_RECONCILED for DEK, via the
distributed-storage crate’s own AuditForwarder/AuditRecord mechanism,
which predates and is independent of AuditDispatcher). A compact
_local:emergency:audit:<rotation_id> pointer record (the
_local:... namespace convention this ADR specifies) is written in the local
Fjall keyspace on OAuth2 reconciliation, mapping a rotation id to its CADF
event id so reconciliation/audit tooling can find the spool entry without a
full scan; DEK does not need this indirection because its simpler audit
records already embed rotation_id directly.
Other scope decisions:
- Staging is audited on the OAuth2 side only if and when reconciled,
mirroring the pre-existing, unrelated-to-this-ADR convention that
stage_emergency_rotation(the ordinary Raft-backed emergency path) is likewise unaudited untilconfirm-rotate-signing-keysucceeds. DEK’s own audit convention differs (it already auditsRotateDek’s emergency stage 1) and the local-write path follows suit for consistency within that subsystem. - No admin-UDS-only SPIFFE extractor was built. No existing HTTP handler
anywhere in the codebase reads the
Interface::Adminconnection extension; every admin-UDS operation today is secured by the ordinaryAuthextractor plus an OPA policy requiringSystemAdmin. The OAuth2 local-write, list-candidates, and reconcile endpoints reuse that same boundary rather than introducing a new one with no precedent. - Reconciliation is a per-node operation an operator drives explicitly,
not an automatic sweep. An operator must run
list-local-emergency-candidates/list-dek-local-emergency-candidatesagainst every node that may hold a candidate, thenreconcile-local-emergency-key/reconcile-dek-local-emergencyagainst the specific node holding the chosenrotation_id. There is no cross-node broadcast to clear a candidate once a sibling wins reconciliation elsewhere in the cluster – only the node reconciliation was run against clears its own candidates. Gossip (§3) guarantees visibility of a conflict across nodes, not automatic cleanup everywhere once it is resolved. - DEK reconciliation additionally guards against a stale target version:
if a different rotation already committed to Raft while a candidate sat
staged (e.g. an operator ran an ordinary
rotate-dekin the meantime), reconciliation refuses rather than installing a DEK at a version that collides with or regresses past the live one, and the operator must re-stage a fresh local-quorum-bypass candidate instead.
Consequences
Positive
- Closes the gap both ADR 0026 §3 and ADR 0016-v2 §6.2 explicitly flagged, once, for both subsystems — instead of building (and maintaining) two independent local-bypass mechanisms.
- Reuses the existing admin UDS authentication boundary rather than introducing a new socket, new OS group, and new peer-credential check to operate and audit.
- Reconciliation semantics are explicit and non-silent, avoiding the common split-brain failure mode of “last writer wins.”
Negative / Risks
- Weakens dual control for exactly the operations most likely to be under active adversarial pressure. Accepted because demanding reachability of a second operator during a partition would make the fallback vacuous.
- Conflicting local writes require a human in the loop to resolve; there is no fully automated recovery from a true split-brain emergency write. This is intentional (see Threat Model) but does mean mean-time-to-recovery from that specific scenario includes an operator reconciliation step, not just a timer.
- Additional storage/propagation machinery (
_local:...namespace convention, best-effort peer gossip, reconciliation proposal type) that must be maintained alongside the existing Raft-backed lifecycles for both signing keys and DEKs, and shared carefully enough between them that a bug fixed in one instantiation doesn’t linger in the other. - Still depends on the local SPIRE agent being healthy, as it always has for every other admin-UDS operation — this ADR does not change that dependency, and does not attempt to remove it (see §0).
Distributed Encrypted Storage
This guide covers the architecture, cryptographic design, and operational
procedures for the Keystone-RS distributed storage engine. The design is
specified in ADR 0016-v2 and implemented across
four crates: openstack-keystone-distributed-storage (consensus, state
machine, gRPC), openstack-keystone-storage-crypto (shared cryptographic
primitives and the KekProvider trait), and the two production KEK
providers, openstack-keystone-storage-crypto-pkcs11 and
openstack-keystone-storage-crypto-tpm.
Table of Contents
- Architecture Overview
- Crate Layout
- Key Hierarchy
- Encryption Details
- Data Tiers and Read Consistency
- Intra-Cluster Transport (mTLS)
- Audit Log
- Quarantine and GCM Failure Handling
- DEK Rotation
- Deployment Guide
- Operational Runbook
- Security Invariants
CLI Reference
All cluster management operations use keystone-manage storage <subcommand>.
The --cluster_addr flag (type URI) selects which cluster member to contact;
it defaults to node_cluster_addr from the config file when omitted.
| Subcommand | Description |
|---|---|
init | Bootstrap a new single-node cluster |
join <cluster-addr> | Join the local node as a Raft learner |
promote <node-id> | Promote a learner to voting member |
demote <node-id> | Demote a voter to non-voting learner |
remove-peer <node-id> | Remove a peer from the cluster membership |
list-peers | Show cluster peers in a table |
metrics | Show raw cluster metrics and leader status |
clear-quarantine [--cluster-addr] [--partition] | Clear a GCM-failure quarantine (operator-only) |
rotate-dek [--cluster-addr] [--emergency] | Rotate the Data Encryption Key |
confirm-rotate-dek [--cluster-addr] --rotation-id | Confirm a pending emergency DEK rotation |
backup [--cluster-addr] --output | Create an encrypted Fjall snapshot |
restore [--cluster-addr] --snapshot | Restore an encrypted snapshot to the cluster |
Architecture Overview
The storage engine combines three components:
| Layer | Component | Purpose |
|---|---|---|
| Consensus | openraft | Log replication, leader election, linearizability |
| State machine / log store | fjall (LSM-tree) | Durable on-disk persistence, SSD-optimized |
| Transport | gRPC over mTLS | Intra-cluster Raft RPC (SPIFFE or custom PKI) |
All data is encrypted before it touches the Raft log or the Fjall on-disk storage. A full disk compromise or log exfiltration reveals only AES-256-GCM ciphertext — no plaintext, no key material.
┌─────────────────────────────────────────────────────────────────┐
│ Keystone API layer │
└───────────────────────────────┬─────────────────────────────────┘
│ StorageApi trait
┌───────────────────────────────▼─────────────────────────────────┐
│ Storage struct (app.rs) │
│ • Raft client • DEK epoch • Audit forwarder │
└───────┬───────────────────────┬─────────────────────────────────┘
│ Raft proposals │ Local reads (Tier 0/1)
┌───────▼───────────────────────▼─────────────────────────────────┐
│ OpenRaft (consensus) │
│ ┌──────────────────┐ ┌────────────────────────────┐ │
│ │ FjallLogStore │ │ FjallStateMachine │ │
│ │ (log_store.rs) │ │ (state_machine.rs) │ │
│ │ Log DEK encrypt │ │ State DEK encrypt/decrypt │ │
│ └──────────────────┘ └────────────────────────────┘ │
└─────────────────────────────────────────────────────────────────┘
│ │
▼ ▼
Fjall (log keyspace) Fjall (state keyspace)
[nonce][ciphertext][tag] [nonce][ciphertext][tag][version]
Write Path
- The API serializes the mutation to MessagePack.
- The Log DEK encrypts the payload (nonce:
node_id_BE ++ counter_BE, AD:term_BE ++ index_BE). - OpenRaft proposes the encrypted blob and replicates it over mTLS to a quorum.
- On apply, the state machine decrypts the log entry using the Log DEK.
- The current per-record
versionis read from Fjall (0 for new records). - The State DEK re-encrypts the value for at-rest storage (HKDF-derived nonce,
AD:
tier ++ domain ++ primary_key), then writes[nonce_12b][ciphertext][tag_16b][version_u32_BE]to Fjall.
Read Path
- Tier 0 / 1 (PUBLIC / INTERNAL): The local state machine decrypts and returns the value directly.
- Tier 2 / 3 (SENSITIVE / SECRET): A
ReadIndex(linearizable read) is issued to OpenRaft first, ensuring no stale follower can return a value that has since been revoked.
Crate Layout
crates/
├── storage-crypto/ # All cryptographic primitives
│ └── src/
│ ├── lib.rs # Public re-exports
│ ├── kek.rs # KekProvider trait, EnvKek (production
│ │ # providers: storage-crypto-pkcs11, -tpm)
│ ├── dek.rs # DekEpoch, LogDek, StateDek, BackupDek, generate_dek
│ ├── cipher.rs # log_encrypt/decrypt, state_encrypt/decrypt,
│ │ # backup_encrypt/decrypt
│ ├── nonce.rs # NonceManager — durable monotonic counter
│ └── audit.rs # AuditHmacKey
│
├── storage-crypto-pkcs11/ # Production KekProvider: PKCS#11 HSM/token
│ ├── src/lib.rs # Pkcs11Kek, Pkcs11KekParams, SlotSelector
│ └── tests/softhsm.rs # SoftHSM2-backed wrap/unwrap round-trip test
│
├── storage-crypto-tpm/ # Production KekProvider: TPM 2.0 resident key
│ ├── src/lib.rs # TpmKek, TpmKekParams, KeyReference
│ └── examples/
│ └── tpm_kek_demo.rs # Runnable sample against a software TPM (swtpm)
│
└── storage/ # Consensus, gRPC, state machine
└── src/
├── lib.rs # StorageApi impl, DEK bootstrap
├── app.rs # init_storage, Storage struct, StorageApi impl
├── preflight.rs # OS-level memory protection checks
├── audit.rs # AuditForwarder, AuditRecord
├── network.rs # NetworkManager, SpiffeTlsProvider,
│ # CertExpiryWatchdog, validate_svid_ttl
├── store/
│ ├── log_store.rs # FjallLogStore (OpenRaft LogStorage impl)
│ └── state_machine.rs # FjallStateMachine (OpenRaft StateMachine impl)
├── grpc/
│ ├── cluster_admin_service.rs # init, add_learner, rotate_dek, …
│ ├── raft_service.rs # Raft RPC forwarding
│ └── storage_service.rs # Data read/write RPCs
└── store_command.rs # StoreCommand, MutationInner, DataTier
Key Hierarchy
PKCS#11 HSM/token, or TPM 2.0 resident key (production)
│ or
KEYSTONE_DEV_KEK env var (dev mode only)
│
▼
Key Encryption Key (KEK) — never enters RAM as plaintext (prod)
│
│ AES-256-GCM unwrap
▼
Data Encryption Key (DEK) — 256-bit random, mlock'd allocation
│
├── Log DEK HKDF-Expand(DEK, "keystone-raft-log-v1", L=32)
├── State DEK HKDF-Expand(DEK, "keystone-fjall-state-v1", L=32)
└── Backup DEK HKDF-Expand(DEK, "keystone-backup-v1"
++ dek_version_u32_BE, L=32)
Audit HMAC key HKDF-Expand(KEK, "keystone-audit-hmac-v1"
++ node_id_u64_BE, L=32)
HKDF-Expand-only is used because the DEK is already uniformly random; HKDF-Extract would add no entropy. Each sub-key is domain-separated by a distinct info string, ensuring ciphertexts from different contexts are never encrypted under the same key material.
The Audit HMAC key is derived from the KEK (not the DEK) so it survives DEK rotation without needing re-derivation, while remaining per-node to prevent cross-node forgery.
DEK Persistence
The wrapped DEK is stored in Fjall under the key _meta:dek:current as
[version_u32_BE; 4] ++ wrapped_bytes. On startup, init_storage reads this
key, unwraps the DEK under the KEK, derives sub-keys, and stores an
Arc<RwLock<Arc<DekEpoch>>> that all state machine operations share.
Encryption Details
Raft Log Encryption
Function: log_encrypt(log_dek, plaintext, term, index) → Vec<u8>
On-disk layout: [nonce_12b][ciphertext][tag_16b]
| Field | Value |
|---|---|
| Nonce (12 bytes) | [node_id_u64_BE; 8] ++ [counter_u32_BE; 4] |
| Associated data | term_u64_BE ++ index_u64_BE |
| Tag | 16 bytes (full GCM tag, truncation prohibited) |
The AD binding of term ++ index prevents an attacker from replaying a log
entry from a different Raft position.
State Machine Encryption
Function:
state_encrypt(state_dek, plaintext, tier, domain_id, pk, version) → Vec<u8>
On-disk layout: [nonce_12b][ciphertext][tag_16b][version_u32_BE]
| Field | Value |
|---|---|
| Nonce (12 bytes) | HKDF-Expand(StateDek, pk ++ version_u32_BE, L=12) |
| Associated data | [tier_u8] ++ domain_id ++ pk |
| Tag | 16 bytes |
| Version suffix | version_u32_BE (read back on next write to compute next nonce) |
The HKDF-derived nonce guarantees uniqueness across record updates: each
(pk, version) pair produces a distinct nonce even if the same plaintext is
re-written. The version starts at 0 for new records and increments on every
write, stored as a 4-byte suffix alongside the ciphertext.
The tier byte in the AD cryptographically binds the sensitivity classification
to the ciphertext — altering the stored tier makes the GCM tag invalid.
Write rate guard: If version >= 2^30 (approximately 1 billion writes per
record per DEK epoch), further writes to that key are blocked with a
WRITE_RATE_EXCEEDED violation and a CRITICAL log entry is emitted. This
prevents nonce-space exhaustion within a DEK epoch for pathologically hot keys.
Backup Encryption
Function:
backup_encrypt(bdek, snapshot_bytes, dek_version, utc_epoch) → Vec<u8>
On-disk layout:
[dek_version_u32_BE; 4] ++ [utc_epoch_u64_BE; 8] ++ [nonce_12b][ciphertext][tag_16b]
| Field | Value |
|---|---|
| Associated data | b"keystone-backup-v1" ++ utc_epoch_u64_BE ++ dek_version_u32 |
The dek_version and utc_epoch in the AD bind the snapshot to a specific
point in time and DEK epoch, preventing time-travel and replay attacks across
backup archives. A separate DEK manifest (itself AES-256-GCM encrypted with AD
bound to the manifest label, epoch, and DEK version) is included in the backup
bundle alongside the encrypted snapshot.
Nonce Management
The NonceManager (storage-crypto/src/nonce.rs) maintains a durable monotonic
counter for Raft log nonces:
- Persists the counter in Fjall under
_meta:nonce_hwm:<node_id>. - Reserves blocks of 1024 counts on each flush to absorb node crashes without nonce reuse.
- On startup, validates the recovered counter against the persisted high-water mark; refuses to start if the counter ≤ HWM (operator intervention required).
- Emits a WARN when fewer than 10% of the
2^31rotation threshold remain.
Data Tiers and Read Consistency
Each record carries a DataTier marker (0–3) that is part of the AES-GCM
associated data and stored in the record metadata.
| Tier | Label | Read path | Examples |
|---|---|---|---|
| 0 | PUBLIC | Local read | Feature flags, role display names |
| 1 | INTERNAL | Local read | Display attributes, config markers |
| 2 | SENSITIVE | Linearizable (ReadIndex) | Group memberships, session tokens, API keys |
| 3 | SECRET | Linearizable (ReadIndex) | Credential plaintext, TOTP seeds |
Tier 2 and 3 always issue a ReadIndex RPC to the current Raft leader before
reading from the local state machine, ensuring a revoked credential or removed
group member can never be observed as still-valid on a lagging follower.
Configure local reads for Tier 0/1 data:
[distributed_storage]
local_reads_mode = "local_for_public" # default
# local_reads_mode = "linearizable_all" # force ReadIndex for everything
Intra-Cluster Transport (mTLS)
All cluster communication uses TLS 1.3 with AEAD cipher suites only
(TLS_AES_256_GCM_SHA384 or TLS_CHACHA20_POLY1305_SHA256). Manual joining is
permanently disabled; every peer must present a valid mTLS identity.
SPIFFE Mode (Default)
[distributed_storage]
trust_domains = "example.org"
- SVIDs issued by SPIRE are rotated automatically. TTL must not exceed 1 hour.
- Nodes reject SVIDs with less than 5 minutes remaining (force-renewal window).
- If SPIRE is unavailable before the renewal window, the node drains proposals and halts — it does not fall back to an expired SVID (fail-closed).
- Incoming SVIDs must match
spiffe://<trust-domain>/keystone/storage/<role>; mismatches are rejected withPERMISSION_DENIEDat the gRPC interceptor.
TLS Fallback Mode
[distributed_storage]
tls_cert_file = "/etc/keystone/storage/node.pem"
tls_key_file = "/etc/keystone/storage/node.key"
tls_client_ca_file = "/etc/keystone/storage/ca.pem"
- Certificates must be signed by a dedicated Keystone Intermediate CA.
- Leaf certificate validity must not exceed 30 days.
CertExpiryWatchdogchecks remaining validity hourly: WARN at 7 days, ERROR at 2 days, configurable shutdown at expiry.
NodeId Uniqueness
Each node has a manually configured node_id: u64. At startup and on every
add_learner gRPC call, the cluster membership is checked for a
(node_id, rpc_addr) collision. A detected collision is fatal — the node or the
operation is aborted with a clear error message. If membership cannot be queried
(no quorum), startup fails closed.
Audit Log
Every security-relevant operation is signed and forwarded to an external SIEM.
Record structure:
{
"timestamp": 1750000000,
"event_type": "DEK_ROTATION",
"actor": "operator@example.org",
"node_id": 1,
"dek_version": 3,
"details": { ... }
}
Signature: HMAC-SHA256(AuditHmacKey, canonical_json_of_record)
The 32-byte MAC is transmitted alongside the record as a hex string. The SIEM
retains each epoch’s key for audit retention purposes. The node_id in the HKDF
derivation ensures different nodes hold distinct signing keys, preventing a
compromised node from forging records attributed to other nodes.
Availability: If the SIEM is unreachable, records are buffered locally (encrypted under the Log DEK). At 90% buffer capacity a CRITICAL alert is emitted. Buffer exhaustion does not block writes to the identity store.
Audited events include: DEK_ROTATION, DEK_ROTATION_EMERGENCY,
QUARANTINE_CLEARED, and any operator access to gRPC management RPCs.
Quarantine and GCM Failure Handling
GCM tag verification failures indicate tampered or corrupted ciphertext.
| Failure count (within 60 s) | Action |
|---|---|
| 1 | WARN log, metric increment |
| 2 | ERROR log, alert |
| 3 | Drain in-flight Raft proposals, commit quarantine marker via Raft, set partition read-only |
Quarantine state is Raft-committed (stored in
_meta:quarantine:<node_id>:<partition>) and therefore persists across restarts
and is visible to all cluster members. A restarted node reads this key at
startup and re-enters quarantine if the marker is set.
Clearing quarantine requires a storage-operator identity:
keystone-manage storage clear-quarantine --partition <partition>
The clear operation is committed via Raft (so it takes effect cluster-wide) and is recorded in the audit log.
DEK Rotation
DEK rotation is triggered by time (dek_rotation_days, default 90 days) or
volume (log-encrypt counter reaches 2^31). The rotation is a live background
process with no downtime.
Normal rotation:
keystone-manage storage rotate-dek
Emergency rotation (suspected DEK compromise — requires dual-control):
# Operator A initiates:
keystone-manage storage rotate-dek --emergency
# returns rotation_id=<uuid>
# Operator B confirms within 5 minutes:
keystone-manage storage confirm-rotate-dek --rotation-id <uuid>
If the 5-minute confirmation window expires without confirmation, the pending
rotation is automatically aborted and an audit entry is written. Emergency
rotations mark the old DEK as revoked (not retired) — it is never reused for
any decryption, even for backup archives from that epoch.
Re-encryption: A background task re-encrypts all Fjall records under the new
DEK using optimistic CAS-on-version: it reads the on-disk version, encrypts
under the new DEK with version + 1, and writes only if the on-disk version is
unchanged. After 3 failed CAS attempts, the key is skipped (it was already
updated by a concurrent Raft write) and flagged in the post-rotation
verification report.
Progress: Progress is checkpointed to _meta:dek:rotation_progress. If the
node restarts mid-rotation, it resumes from the last checkpoint.
Deployment Guide
Prerequisites
- Rust toolchain (see
rust-toolchain.toml) - A SPIRE deployment, or TLS certificates from a dedicated Intermediate CA
- For production: a PKCS#11 HSM/token (
kek_provider = "pkcs11") or a TPM 2.0 chip (kek_provider = "tpm") for KEK storage — see PKCS#11 and TPM KEK Providers - For development: set
KEYSTONE_DEV_KEKandKEYSTONE_ALLOW_ENV_KEK=1
Configuration Reference
[distributed_storage]
# Unique identifier for this node within the cluster. Must be a u64.
# Collision with an existing node at a different address is fatal.
node_id = 1
# Advertised cluster-internal address (used by peers for Raft RPC).
node_cluster_addr = "https://10.0.0.1:8310"
# Local listener address for inbound cluster connections.
node_listener_addr = "0.0.0.0:8310"
# Directory where Fjall database files are stored.
path = "/var/lib/keystone/storage"
# Read consistency mode for Tier 0/1 data.
# "local_for_public" (default): serve Tier 0/1 locally, ReadIndex for Tier 2/3.
# "linearizable_all": require ReadIndex for all tiers.
local_reads_mode = "local_for_public"
# DEK rotation interval in days (default: 90).
dek_rotation_days = 90
# Per-record write version threshold before blocking further writes (default: 2^30).
write_rate_threshold = 1073741824
# Selects the production KEK source. "env" (default) is dev-mode only and is
# rejected unless dev_mode = true. See "PKCS#11 and TPM KEK Providers" below.
# kek_provider = "pkcs11"
# kek_provider = "tpm"
# --- Transport: SPIFFE (default) ---
trust_domains = "example.org"
# --- Transport: TLS fallback ---
# tls_cert_file = "/etc/keystone/storage/node.pem"
# tls_key_file = "/etc/keystone/storage/node.key"
# tls_client_ca_file = "/etc/keystone/storage/ca.pem"
# # Or embed content directly (base64 or PEM):
# tls_cert_content = "..."
# tls_key_content = "..."
# tls_client_ca_content = "..."
Environment variables (development only):
| Variable | Description |
|---|---|
KEYSTONE_DEV_KEK | Hex-encoded 256-bit KEK. Requires --dev-mode and KEYSTONE_ALLOW_ENV_KEK=1. |
KEYSTONE_ALLOW_ENV_KEK | Must be set to 1 when using KEYSTONE_DEV_KEK. |
Warning:
KEYSTONE_DEV_KEKandKEYSTONE_ALLOW_ENV_KEKmust never appear in production Dockerfiles, Kubernetes manifests, or systemd units. The CI gatetools/check_no_dev_mode.shenforces this.
PKCS#11 and TPM KEK Providers
Production deployments select one of the two hardware-backed KekProvider
implementations (ADR 0016-v2 §2.5). Both wrap/unwrap the DEK with
CKM_AES_GCM/TPM2 AES-GCM directly against a non-extractable AES-256 key
object — the key material never leaves the token or chip.
PKCS#11 (HSM or token)
[distributed_storage]
kek_provider = "pkcs11"
[distributed_storage.pkcs11]
# Path to the vendor's (or SoftHSM2's) Cryptoki shared library.
pkcs11_module_path = "/usr/lib/softhsm/libsofthsm2.so"
# CKA_LABEL of the AES-256 key object. Must have CKA_EXTRACTABLE = false
# (ADR 0016-v2 §10 invariant 13). init_storage never creates this key —
# it must already exist via an operator's out-of-band provisioning step.
pkcs11_key_label = "keystone-kek"
# Either the slot id or the token label must be given; label is preferred
# since slot ids can shift across token re-initialisation.
pkcs11_slot_label = "keystone-storage"
# pkcs11_slot_id = 0
# File containing the token PIN. Never accepted inline or via env var
# (ADR 0016-v2 §10 invariant 14).
pkcs11_pin_file = "/etc/keystone/storage/pkcs11.pin"
Provisioning the key (a one-time operator ceremony, run once per token before
the cluster’s first boot) uses any Cryptoki client capable of generating a
non-extractable AES-256 key under the target label — for example,
pkcs11-tool --keygen --key-type AES:32 --label keystone-kek, or the
provisioning helper in crates/storage-crypto-pkcs11/tests/softhsm.rs and
crates/storage/tests/test_pkcs11_cluster.rs, which do the equivalent
programmatically against SoftHSM2 for local testing.
Local testing against SoftHSM2:
# libsofthsm2.so and the softhsm2-util CLI:
sudo apt-get install -y softhsm2
# Run the SoftHSM2-backed tests (skip themselves if the module isn't found):
cargo test -p openstack-keystone-storage-crypto-pkcs11
cargo test -p openstack-keystone-distributed-storage --features pkcs11 --test test_pkcs11_cluster
TPM 2.0
[distributed_storage]
kek_provider = "tpm"
[distributed_storage.tpm]
# TCTI connection string: a hardware TPM's resource manager device, or a
# software TPM (swtpm) for testing.
tpm_tcti = "device:/dev/tpmrm0"
# Exactly one of the following identifies the pre-provisioned AES-256 key:
tpm_key_handle = "0x81000001" # persistent handle, or:
# tpm_key_context_file = "/etc/keystone/storage/tpm-kek.ctx"
# Optional: file containing the key's auth value (userWithAuth keys only).
# tpm_auth_file = "/etc/keystone/storage/tpm.auth"
As with PKCS#11, init_storage only ever opens the key with
auto_generate: false — provisioning is a separate operator step.
crates/storage-crypto-tpm/examples/tpm_kek_demo.rs is a runnable sample that
provisions and exercises a KEK against a software TPM:
# 1. Start a software TPM:
mkdir -p /tmp/swtpm-state
swtpm socket --tpmstate dir=/tmp/swtpm-state \
--ctrl type=tcp,port=2322 --server type=tcp,port=2321 \
--tpm2 --flags not-need-init &
TPM2TOOLS_TCTI="swtpm:host=127.0.0.1,port=2321" tpm2_startup -c
# 2. Run the sample (first run provisions the key, later runs reload it):
cargo run -p openstack-keystone-storage-crypto-tpm --example tpm_kek_demo
This example is compiled in CI on every run to catch rot, but is not executed there — real/virtual TPM availability isn’t reliable enough on shared CI runners to gate merges on (ADR 0016-v2 §2.5.2).
First-Time Cluster Bootstrap
Step 1 — Start each node (do not initialize yet):
keystone --config /etc/keystone/keystone.conf
Each node starts and waits; Raft is not yet initialized.
Step 2 — Initialize the first node as a single-node cluster.
Run from node 1’s host (node address and ID come from the config file):
keystone-manage storage init
Node 1 becomes the leader of a 1-node cluster. Wait for it to report a leader
(check keystone-manage storage metrics --cluster-addr https://10.0.0.1:8310).
Step 3 — Add learners.
Run from node 2 and node 3’s hosts respectively. The positional argument is the address of any existing cluster member to contact:
# On node 2's host:
keystone-manage storage join https://10.0.0.1:8310
# On node 3's host:
keystone-manage storage join https://10.0.0.1:8310
Step 4 — Promote learners to voting members.
Run from any node. Repeat once per learner to promote:
keystone-manage storage promote 2
keystone-manage storage promote 3
Adding Nodes
To add a new node to a running cluster:
# 1. Start the new node process (it will wait for a join instruction).
# 2. On the new node's host, join to an existing cluster member:
keystone-manage storage join https://10.0.0.1:8310
# 3. Optionally promote to voting member (run from any node):
keystone-manage storage promote 4
TLS Certificate Management
SPIFFE mode: No operator action required. SPIRE rotates SVIDs automatically. The node refuses connections from SVIDs with < 5 minutes remaining validity.
TLS fallback mode:
- Generate a new certificate from your Intermediate CA (max 30-day validity).
- Deploy the new certificate and key to the node.
- Restart the node, or use a runtime reload mechanism if available.
- The
CertExpiryWatchdoglogs WARN at 7 days remaining and ERROR at 2 days.
Operational Runbook
Cluster Metrics
Quick health check — shows current leader, voter set, and raw OpenRaft metrics:
keystone-manage storage metrics --cluster-addr https://10.0.0.1:8310
Sample output:
Current leader : node 1
Voters : [1, 2, 3]
All nodes : [1=10.0.0.1:8310, 2=10.0.0.2:8310, 3=10.0.0.3:8310]
Raw metrics:
Metrics{id:1, Leader, term:3, ...}
For a formatted peer table use list-peers instead.
Scheduled DEK Rotation
Automatic rotation fires after dek_rotation_days (default: 90) or when the
log-encrypt counter approaches 2^31. Manual rotation:
keystone-manage storage rotate-dek \
--cluster-addr https://10.0.0.1:8310
Monitor the audit log (event_type = "DEK_ROTATION") and the post-rotation
verification report for any skipped keys.
Emergency DEK Rotation
Use when a DEK is suspected compromised.
# Operator A — initiates rotation, receives rotation_id:
keystone-manage storage rotate-dek \
--cluster-addr https://10.0.0.1:8310 \
--emergency
# Output: rotation_id=550e8400-e29b-41d4-a716-446655440000
# Operator B — confirms within 5 minutes:
keystone-manage storage confirm-rotate-dek \
--cluster-addr https://10.0.0.1:8310 \
--rotation-id 550e8400-e29b-41d4-a716-446655440000
If no confirmation is received within 5 minutes, the rotation aborts
automatically and is recorded in the audit log. The dek_rotation_days timer
resets after successful completion.
Clearing a Quarantined Partition
A partition enters quarantine after 3 GCM verification failures within 60
seconds. In quarantine, the partition is read-only and all writes to affected
keys are rejected with a QUARANTINED violation.
Diagnosis:
# Check node metrics for quarantine state:
keystone-manage storage metrics --cluster-addr https://10.0.0.1:8310
Root-cause the GCM failures (hardware fault, storage corruption, or unauthorized modification) before clearing quarantine.
Clear:
keystone-manage storage clear-quarantine \
--cluster-addr https://10.0.0.1:8310 \
--partition <partition-name>
This commits a Raft proposal (visible cluster-wide) and emits an audit entry.
Backup and Restore
Create a backup (Fjall snapshot):
keystone-manage storage backup \
--cluster-addr https://10.0.0.1:8310 \
--output /mnt/backups/keystone-$(date +%Y%m%d).snap
The command triggers a fresh Fjall snapshot on the target node, then streams the
AES-256-GCM encrypted bytes to --output. The final output includes the
snapshot_utc_epoch and dek_version printed on completion for verification.
The snapshot is wrapped in a backup-specific AES-256-GCM envelope with the Backup DEK and a DEK manifest. Both are bound to the snapshot timestamp and current DEK epoch.
Restore:
# 1. Bootstrap a fresh single-node cluster (Step 1–2 from bootstrap guide).
# 2. Restore the snapshot to the leader:
keystone-manage storage restore \
--cluster-addr https://10.0.0.1:8310 \
--snapshot /mnt/backups/keystone-20260101.snap
# 3. Add remaining nodes as learners (Steps 3–4 from bootstrap guide).
The restore command validates the AES-256-GCM backup envelope (AD binding: epoch
- dek_version), decrypts it using the Backup DEK from the KMS, and installs the
snapshot into the Raft state machine via
install_full_snapshot. The KMS must hold thebackup_dekrole key for the DEK epoch encoded in the snapshot.
Retired DEK retention: Retired DEKs must be retained in the KMS for at least
365 days to allow offline decryption of archived backups. Use a separate
backup_dek_offline KMS role (distinct from the runtime role) with dual-control
access controls.
Security Invariants
The following invariants are enforced by the implementation and verified at code review. Any change that violates them must be explicitly justified and approved by the security team.
-
No plaintext on disk. Every byte is AES-256-GCM encrypted before the write call returns. GCM tags are always 16 bytes.
-
No DEK in plaintext outside mlock’d RAM. The DEK is stored wrapped under the KEK on disk. In memory it lives only inside mlock’d
Zeroizingbuffers. -
Strict mTLS. Auto-join is permanently disabled. Every inbound connection must present a valid SPIFFE SVID or an operator-managed certificate signed by the cluster Intermediate CA.
-
No stale reads for sensitive data. Tier 2 and Tier 3 reads always execute the ReadIndex protocol before returning data.
-
GCM failure quarantine is durable. Quarantine state is committed via Raft and persists across node restarts.
-
No environment-variable KEK in production. Starting with
KEYSTONE_DEV_KEKrequires both--dev-modeandKEYSTONE_ALLOW_ENV_KEK=1. CI rejects deployment artifacts that contain these flags. -
NodeId collision detection is fail-closed. A collision detected at startup or on
add_learneris fatal. Inability to query membership (no quorum) is treated as a detected collision. -
DEK generation targets mlock’d memory. The DEK must not be generated into an unlocked buffer and subsequently copied.
-
Per-record write rate guard. Writes beyond the version threshold (
2^30by default) are blocked with a CRITICAL log. This prevents nonce-space exhaustion for pathologically hot keys within a DEK epoch. -
Nonce sources are deterministic and audited. Random nonces are prohibited. All nonce strategies are documented in the ADR and reviewed by the security team before any new encrypted context is added.
-
Deployment validation.
tools/check_no_dev_mode.shruns in CI and rejects production service definitions containing--dev-modeorKEYSTONE_ALLOW_ENV_KEK. -
Startup pre-flight. Before loading any key material, the node verifies
RLIMIT_CORE == 0andPR_SET_DUMPABLE == 0. Failures emit CRITICAL log entries and (when--dev-modeis not set) prevent startup.
API policy enforcement
API policy is implemented using the
Open Policy Agent (OPA). It is a very powerful
tool and allows implementing policies much more complex than what the
oslo.policy would ever allow. The policy folder contain default policies.
They can be overloaded by the deployment.
OPA can be integrated into Keystone in 2 ways:
-
HTTP. This is a default and recommended way of integrating applications with the OPA. Usually the OPA process is started as a side car container to keep network latencies as low as possible. Policies themselves are bundled into the container which OPA process is capable of downloading and even periodically refreshing. It can be started as
opa run -s --log-level debug tools/opa-config.yaml. Alternatively the OPA process can itself run in the container in which case the configuration file should be mounted as a volume and referred from the entrypoint. -
WASM. Policies can be built into a WASM binary module. This method does not support feeding additional data and dynamic policy reload as of now. Unfortunately there is also a memory access violation error in the
wasmtimecrate happening for the big policy files. The investigation is in progress, so it is preferred not to rely on this method anyway. While running OPA as a WASM eliminates any networking communication, it heavily reduces feature set. In particular hot policy reload, decision logging, external calls done by the policies themselves are not possible by design. Using this way of policy enforcement requireswasmfeature enabled.
All the policies currently are using the same policy names and definitions as the original Keystone to keep the deviation as less as possible. For the newly added APIs this is not anymore the case.
With the Open Policy Agent it is not only possible to define a decision (allowed or forbidden), but also to produce additional information describing i.e. reason of the request refusal. This is currently being used by the policies by defining an array of “violation” objects explaining missing permissions.
Sample policy for updating the federated IDP mapping:
package identity.mapping_update
# update mapping.
default allow := false
allow if {
"admin" in input.credentials.roles
}
allow if {
own_mapping
"manager" in input.credentials.roles
}
own_mapping if {
input.target.domain_id != null
input.target.domain_id == input.credentials.domain_id
}
violation contains {"field": "domain_id", "msg": "updating mapping for other domain requires `admin` role."} if {
identity.foreign_mapping
not "admin" in input.credentials.roles
}
violation contains {"field": "role", "msg": "updating global mapping requires `admin` role."} if {
identity.global_mapping
not "admin" in input.credentials.roles
}
violation contains {"field": "role", "msg": "updating mapping requires `manager` role."} if {
identity.own_mapping
not "member" in input.credentials.roles
}
As can be guessed such policy would permit the API request when admin role is
present in the current credentials roles or the mapping in scope is owned by the
domain the user is currently scoped to with the manager role.`
List operation
All query parameters are passed into the policy engine to be provide capability
of making decision based on the parameters passed. For example an admin user may
specify domain_id parameter when the current authentication scope is not
matching the given domain_id or a user with the manager role being able to
list shared federated identity providers.
Policy is being evaluated before the real data is being fetched from the backend.
Show operation
Policy evaluation for GET operations on the resource are executed with the requested entity in the scope. This allows policy to deny the operation if the user requested resource it is should not have access to. This means that 404 error may be raised before the validation of whether the user is allowed to perform such operations.
Create operation
Resource creation operation would pass the whole object to be created in the context to the policy enforcement engine.
Update operation
For the update operation the context contain the current state of the resource and the new one. This allows defining policies preventing resource update upon certain conditions (i.e. when tag “locked” is added).
Delete operation
Resource deletion also passes the current resource state in the context to allow comprehensive logic.
Security Model: Scope, Delegation, Rescope, and Reauth
This document is the security-review reference for authentication and authorization in Rust Keystone. It captures the threat model, the invariants that defend against it, and a concrete reviewer checklist to apply when adding or changing any code that touches scope, delegation, rescope, or reauth.
It is deliberately advisory-driven: the invariants below were hardened in response to real Keystone vulnerabilities (see Advisory cross-reference) and exist to prevent that class of bug from recurring. Treat a change that weakens any invariant here as a security regression until proven otherwise.
Related design docs:
- ADR 0017 — Security Context — the
SecurityContext/ValidatedSecurityContextdesign these rules build on. - ADR 0014 — Application Credentials
- ADR 0019 — Credentials API
- Policy enforcement and ADR 0002
1. Vocabulary
| Term | Meaning |
|---|---|
| Authentication chain | The immutable record of how the caller authenticated: AuthenticationContext (token, app-cred, trust, EC2, K8s, …) plus the delegation objects themselves. Fixed at authentication time. |
| Scope | The authorization target of the current token: ScopeInfo (Project, Domain, System, TrustProject, Unscoped). Chosen per-request and re-chosen on every rescope. |
| Delegated auth | Any auth where the caller acts on behalf of another principal with a bounded slice of their power: trusts, application credentials, and EC2 credentials minted under either. |
| Rescope | Exchanging a token for another token with a different scope. |
| Reauth | Re-running authentication (new token) rather than reusing/rescoping an existing one. |
| Scope-bind escape | A privilege escalation where a delegated caller acts outside the delegation’s fixed boundary by influencing the scope while the security decision was (wrongly) keyed on the scope instead of on the immutable delegation. |
2. The one rule that prevents scope-bind escapes
Security decisions about a delegation MUST be keyed on the authentication chain (immutable), never on the token scope (attacker-influenceable).
The token scope can legitimately change across a rescope, and the delegation’s
own binding cannot. If a check reads credentials.project_id (scope) to decide
“is this caller bound to project X”, an attacker who can rescope to X defeats
the check. If the same check reads the delegation object’s own project_id
(chain), rescoping cannot move the boundary.
Every invariant in §4 is a specialization of this rule.
3. Trust boundaries
X-Auth-Token / creds ─▶ authenticate ─▶ SecurityContext (raw)
│
new_for_scope(ctx, scope, state) ◀── scope pinning,
│ role bounding
▼
ValidatedSecurityContext (VSC) ◀── ONLY validated form
│
TryFrom<&VSC> ─▶ Credentials ─▶ OPA policy (input.credentials)
- Nothing downstream of
ValidatedSecurityContextmay reconstruct trust from scope. The VSC already carries the full chain; project it, do not re-derive it. - OPA sees only
Credentials. If a fact is not onCredentials, the policy cannot enforce it. Adding a delegation-sensitive policy rule almost always requires first projecting a new chain-derived field ontoCredentials. - Secrets never reach OPA. Decrypted credential
blobs (EC2 secret keys, TOTP seeds) are stripped before policy input is built.
4. Invariants
Each invariant lists what, why, and where enforced. When you touch the “where”, re-verify the “what”.
I1 — Delegation facts come from the chain
What: Credentials.auth_type, is_delegated, unrestricted, trust, and
delegated_project_id are all read from sc.authentication_context(), never
from the scope. Why: keying on scope is the scope-bind escape
(OSSA-2026-015). Where: TryFrom<&ValidatedSecurityContext> for Credentials
(crates/core/src/policy.rs).
I2 — Delegation boundary anchors on delegated_project_id
What: every delegated-caller policy rule compares the resource’s project to
input.credentials.delegated_project_id (the delegation’s own immutable
project), not to input.credentials.project_id. Why: same as I1, enforced
at the policy layer. Where: bound_to_own_delegation_project(project_id) /
not_delegated_or_bound_to_own_project(project_id), defined once in
policy/credential/common.rego (package identity.credential) and imported as
credential_common by policy/credential/{create,show,update,delete}.rego and
policy/os_ec2/create_credential.rego — a new delegation-sensitive endpoint
imports the check instead of hand-copying it. Callers must pass an argument
that is never undefined (e.g.
object.get(input.target.credential, "project_id", null), not a bare
input.target.credential.project_id): Rego evaluates a function’s argument
before dispatching to either body, so an undefined argument makes even the “not
delegated” fast path undefined too — common.rego’s doc comment calls this out.
I3 — Scope-drift tripwire, enforced twice
What: delegated rules additionally assert
credentials.project_id == credentials.delegated_project_id and that
delegated_project_id != null; the rule fails closed on divergence. This is
checked in two independent layers: the rego helper above (I2), and a
Rust-side assertion inside TryFrom<&ValidatedSecurityContext> for Credentials
(PolicyError::ScopeDrift, crates/core/src/policy.rs) that runs for every
caller of that conversion, including a future policy that forgets to import the
rego helper. Why: defense-in-depth. validate_scope_boundaries() keeps
scope pinned to the delegation project today, so any observed drift means a
scope-pinning regression upstream — fail rather than trust it. Where: the
rego helpers (I2); the Rust tripwire in policy.rs; every delegated policy test
carries a “scope-drift tripwire” negative case.
I4 — Effective roles are bounded by the delegation, on every scope shape
What: a delegated auth’s effective roles are always the delegation’s role
set (∩ current assignments), regardless of whether it presents as its native
TrustProject/app-cred scope or as a plain Project scope. Why: an EC2
credential minted under a restricted delegation and redeemed at /v3/ec2tokens
reconstructs the delegated chain but presents a bare project scope; without
bounding it would inherit the trustee’s full project roles (OSSA-2026-005 /
CVE-2026-33551). Where: calculate_effective_roles() routes
AuthenticationContext::Trust under ScopeInfo::Project through
resolve_trust_roles(); app-cred roles are intersected in
resolve_project_default_roles() (crates/core/src/auth.rs).
A Trust presented on a plain Project scope is legal at the boundary layer only
for the trust’s own bound project, and only when the
AuthenticationContext::Trust was freshly reconstructed rather than decoded
from a presented bearer token (token.is_none()):
validate_scope_boundaries()’s Project arm mirrors the
ApplicationCredential arm and checks trust.project_id == project.id and
token.is_none() (crates/core-types/src/auth.rs), rather than rejecting
Trust-on-Project outright. Earlier, that arm rejected the combination
unconditionally, which made this invariant’s “Trust presented on a plain Project
scope” premise unreachable — any EC2 credential minted under a trust would fail
at redemption with ScopeNotAllowed before role resolution was ever reached (a
functional bug masking the intended defense-in-depth, not an exploitable
escalation, since it failed closed).
The token.is_none() condition matters because a real OS-Trust auth request can
only ever ask for OS-TRUST:trust scope — there is no client-facing way to
present a trust identity and request a plain project scope. The single
legitimate producer of the Trust+Project shape is /v3/ec2tokens redemption
of an EC2 credential minted under a trust, which reconstructs
AuthenticationContext::Trust directly from the credential’s stored trust_id
blob field with token: None (create_inner in
crates/keystone/src/api/v3/ec2tokens/create.rs), because the EC2 credential
carries a bare project_id, never a TrustProject scope. A caller
reauthenticating with auth method "token" against an actual trust-scoped
bearer token (token: Some(_), the shape validate_to_context_impl in
crates/core/src/token/service.rs produces when decoding a presented trust
Fernet token) and requesting a project scope is rejected: trust tokens can never
be used to mint another token, matching the existing TrustProject-arm
rejection of trust-from-trust renewal and closing the equivalent escape hatch on
the Project arm. See
test_new_for_scope_delegated_roles_never_exceed_delegation_matrix and
test_new_for_scope_trust_on_foreign_project_rejected in
crates/core/src/auth.rs for end-to-end coverage through new_for_scope() (not
just calculate_effective_roles() in isolation) across both
Trust-on-TrustProject and Trust-on-Project, plus the negative case for a
different project; test_validate_scope_boundarires_trust in
crates/core-types/src/auth.rs covers the token: Some(_) reauth-rejection
case directly.
Making that shape reachable exposed a second, more serious gap one layer down:
FernetToken::from_security_context() (crates/core-types/src/token.rs) chose
the encoded payload by scope shape, and a plain ScopeInfo::Project fell
through to a bare ProjectScopePayload regardless of AuthenticationContext —
including for Trust. ProjectScopePayload carries no trust reference, so on
the next use of that issued token, validate_to_context_impl /
build_authz_info_from_fernet_token (crates/core/src/token/service.rs) would
decode it back to a generic AuthenticationContext::Token, and
calculate_effective_roles() would resolve the trustee’s own live project roles
instead of the trust’s bounded set — the delegation restriction silently
disappearing on reuse, one round-trip after the correctly-bounded first
response. This affected every trust-backed EC2 credential redemption, since the
token handed back by /v3/ec2tokens is a normal token meant for repeated use,
not a one-shot artifact. Fixed by adding an explicit
AuthenticationContext::Trust arm in from_security_context()’s Project
match that emits the same TrustPayload used for the native TrustProject
scope (valid here because validate_scope_boundaries() already guarantees
project.id == trust.project_id), so decoding always re-derives
AuthenticationContext::Trust and re-bounds roles on every use. Covered by
test_from_security_context_trust_on_project_scope_emits_trust_payload in
crates/core-types/src/token.rs.
I5 — Scope changes are re-validated against the auth method
What: app-creds cannot be scoped beyond their bound project; trusts cannot
be re-scoped to a different trust; token restrictions block domain/system/trust/
unscoped and pin project scope to the restriction’s project. Why: prevents a
narrow auth method from being broadened via a request-supplied scope. Where:
SecurityContext::validate_scope_boundaries()
(crates/core-types/src/auth.rs), invoked from new_for_scope() when the
requested scope differs from the context’s existing scope, and unconditionally
(via set_authorization_scope()) when no scope is set yet. Caveat:
re-presenting an already-validated token with its stored scope unchanged
(token/trust re-authentication, which reconstructs authorization directly from
a decoded Fernet token) intentionally skips re-validation — the scope was
checked once at issuance, and a Fernet token is authenticated encryption, so the
stored scope cannot have been tampered with between issuance and reuse. This is
not a hole: every authorization-setting path in the codebase either runs
through set_authorization_scope() (validated) or reconstructs a value that was
already validated when its own token was minted. I3 exists as an independent,
second-layer backstop regardless.
I6 — Redemption paths re-assert type and shape
What: the EC2 redemption lookup (get_by_ec2_access → primary key
sha256(access)) has no type filter, so the handler must reject any fetched
credential whose type != "ec2", and must reject blobs carrying an
access_token_id (OAuth1, unimplemented → would drop restrictions). Why:
the sha256(access) == id invariant is load-bearing; a mislabelled or
wrong-shaped credential redeemed here would bypass every type=="ec2"
create-time guard. Where: crates/keystone/src/api/v3/ec2tokens/create.rs.
I7 — Secrets are stripped from policy input
What: the decrypted credential blob is removed before the object is passed
to OPA as input.target/input.existing. Why: no policy rule reads it;
shipping it leaks EC2 secret keys / TOTP seeds into OPA decision logs.
Where: credential_policy_input() in
crates/keystone/src/api/v3/credential/mod.rs, used by all credential handlers.
I8 — List re-checks every item individually
What: list endpoints run the collection-level policy first, then re-enforce
the per-item show policy against each record’s own identifiers, dropping
unreadable rows. Why: a permissive list filter must not leak individual
objects the caller cannot read (CVE-2019-19687). Where:
crates/keystone/src/api/v3/credential/list.rs.
5. Delegated-auth specifics
Trust, application-credential, and EC2 are authentication methods, each with
its own AuthenticationContext variant. All three are permanently bound to a
single project (optionally further narrowed to a role subset) at credential
creation time; none of them can escape that binding through rescope or reauth —
the mechanism differs per method (below), but the invariant is uniform.
Each method also carries its source-auth information differently:
| Method | Fernet payload | Source-auth carrier | Token-from-token (reauth) |
|---|---|---|---|
| Trust | dedicated TrustPayload | trust embedded in the payload | Forbidden — see AuthenticationError “Token renewal (getting token from token) is prohibited.” |
| Application credential | dedicated ApplicationCredentialPayload | application_credential embedded in the payload | Allowed, but scope-locked — a token minted from an AC token cannot change scope |
| EC2 credential | regular ProjectScopePayload (same shape as a plain project-scoped token) | none in the payload itself — auth_methods carries the "ec2credential" marker, and if the EC2 credential was minted under a trust/app-cred, redemption reconstructs AuthenticationContext::Trust/ApplicationCredential from the credential’s stored trust_id/app_cred_id blob fields instead of using AuthenticationContext::Ec2Credential | N/A — EC2 redemption is a fresh authentication (signed request), not a token-from-token flow |
Trusts
Reconstructed as AuthenticationContext::Trust { trust, token }.
is_delegated == true, delegated_project_id == trust.project_id. Roles
bounded to the trust’s delegated set (I4). Cannot be re-scoped to another trust
(I5). Cannot be reauthenticated token-from-token (table above).
Application credentials
AuthenticationContext::ApplicationCredential { application_credential, token }.
unrestricted flows to OPA from the AC object (I1).
delegated_project_id == application_credential.project_id. Roles = frozen AC
roles ∩ current user assignments (I4). Cannot be scoped beyond the bound project
(I5). May be reauthenticated token-from-token, but the new token is locked to
the same scope (table above).
Open gap: application-credential
access_rules(per-endpoint restrictions) are stored and CRUD’d but not enforced at request time — no middleware matches the incoming (service, method, path) against them. A rules-restricted app-cred can currently call any endpoint. Tracked separately; see §7.
EC2 credentials
Minted under a trust or app-cred, redeemed at /v3/ec2tokens. This is the
highest-risk delegated path because redemption reconstructs the delegated
chain from stored blob fields and presents a plain project scope (via the
regular ProjectScopePayload, not a dedicated trust/AC payload — see table
above). All of I4/I6 exist specifically for it. Any change to EC2 mint/redeem
must be reviewed against §6 below in full.
A bare AuthenticationContext::Ec2Credential (an EC2 credential not minted
under a trust/app-cred) carries no delegation metadata at all —
validate_scope_boundaries allows it on every scope shape unconditionally,
since there is no delegation boundary to enforce.
6. Rescope and reauth rules
- Rescope preserves the chain. A rescoped token keeps its
AuthenticationContext; onlyScopeInfochanges. Therefore delegation decisions survive rescope unchanged iff they key on the chain (I1/I2). - Rescope is bounded by I5. The new scope is checked against the auth method before it is accepted.
- Delegated tokens do not widen on rescope. Roles are recomputed via
new_for_scope()and re-bounded by the delegation (I4); rescoping a trust/AC token cannot yield roles outside the delegation. - Reauth (fresh authentication) re-runs the full pipeline —
SecurityContext::validate(), expiry, trust-chain validation, role resolution — so it is the safe path when in doubt. A validated context is only obtainable vianew_for_scope()/test_new()(test-only).
7. Reviewer checklist
Apply to any diff touching auth, scope, delegation, tokens, credentials, EC2, or policy input:
- Does any delegation/authorization decision read the scope
(
project_id,ScopeInfo) where it should read the chain (authentication_context(), delegation object)? → violates I1/I2. - New delegation-sensitive policy rule? Is the fact it needs projected
onto
Credentialsfrom the chain, and does the rule anchor ondelegated_project_id+ carry the scope-drift tripwire (I2/I3)? - New scope shape or redemption path for a delegated auth? Are effective roles still bounded by the delegation (I4)? Add a test that a restricted delegation cannot exceed its roles via the new path.
- New
ScopeInfovariant or auth method? Updatedvalidate_scope_boundaries(),calculate_effective_roles(),fully_resolved(), andCredentials::try_from(I5)? - New lookup by a client-derivable key (like
sha256(access))? Does it re-asserttype/shape after fetch (I6)? - Does any new data reaching OPA include secrets/decrypted blobs (I7)?
- New list/collection endpoint? Does it re-check each item with the per-item read policy (I8)?
- Does the change let a narrow auth method be broadened by a request-supplied scope (I5)?
- Are there negative tests proving the escape is blocked, not just positive tests proving the happy path works?
- Does the test drive
ValidatedSecurityContext::new_for_scope()end-to-end, not just the inner helper (calculate_effective_roles,validate_scope_boundaries) in isolation? A unit test on the helper can pass while the full call chain still rejects or mis-routes the case — see I4’s history.
8. Advisory cross-reference
| Advisory / CVE | Class | Invariants |
|---|---|---|
| OSSA-2026-015 | Delegated token not bound to its delegation project on credential CRUD | I1, I2, I3 |
| OSSA-2026-005 / CVE-2026-33551 | Restricted app-cred escapes role restriction via minted EC2 credential | I4, I6 |
| CVE-2019-19687 | List leaks objects the caller cannot read | I8 |
| CVE-2020-12691 | Mutation of immutable credential fields | provider-layer field guard; see ADR 0019 |
9. Known open gaps
- App-cred
access_rulesunenforced at request time (§5). Feature-sized; needs request-matching middleware and likely its own ADR. Until then, treataccess_rulesas advisory, not a security control.
Security Architecture Review: Preemptive Gates, Testing, and Vulnerability Vectors
Status: advisory review (2026-07-09). Companion to Security model
(the normative invariant reference) and Policy enforcement. Where
the two disagree, security.md wins; this document proposes additions, it
does not restate or replace the invariants there.
Disclaimer: This review was performed by Claude Fable model with a human directions.
1. Purpose and scope
This is an architecture-level security review of Rust Keystone from the attacker’s point of view. It answers three questions the project asked:
- Where can more preemptive security gates (CI, design-time, structural) be added so that a class of bug is caught before merge rather than by review or in production?
- How should the project test for security gaps rather than only testing the happy path?
- What vulnerability vectors should the project name explicitly and control on purpose — through CI jobs, design documents, and penetration testing?
It deliberately does not re-derive the threat model already captured in
security.md. That document, ADR 0017 (Security Context), and ADR 0002 (OPA)
are the substrate; this review builds on them.
2. Assessment of the current posture
The core authorization design is strong and, in the areas that have already been attacked, well defended:
- The load-bearing invariant is correct and enforced in depth. Security
decisions key on the immutable authentication chain
(
sc.authentication_context()), never on the attacker-influenceable token scope (security.md§2, invariants I1–I2). The scope-drift tripwire (I3) is enforced twice — once in Rego per delegated policy and once in Rust inTryFrom<&ValidatedSecurityContext> for Credentials(crates/core/src/policy.rs), so a future policy that forgets the Rego assertion still fails closed. - Two-phase validation is structurally sound. A handler can only ever
observe a
ValidatedSecurityContext, obtainable in production solely vianew_for_scope();Deref-only, noDerefMut,pub(crate)fields, and#[cfg]-gated test constructors mean an unresolved or mutated context is unreachable from an endpoint (ADR 0017). - The highest-risk path is explicitly modeled. EC2-credential redemption
reconstructing a delegated chain onto a bare project scope (I4/I6,
OSSA-2026-005 / CVE-2026-33551) is documented down to the individual match
arm, with the token round-trip regression (
from_security_contextfalling through toProjectScopePayload) called out and covered. - Advisories map to invariants.
security.md§8 ties each hardening back to a real CVE/OSSA, which is exactly the discipline that prevents regression.
The gaps below are therefore not “the design is wrong.” They are “the design depends on humans remembering a checklist, and the checklist is not yet mechanically enforced,” plus a handful of named surfaces that are documented as open or are newly proposed.
3. Vulnerability vectors to control explicitly
Each vector lists the attack, the current state, and the control the project should commit to (gate / design / pentest). Priority is the review’s opinion, not a mandate.
V1 — Delegation boundary escape via rescope/reauth (P1, mitigated, keep proving it)
Attack. A delegated caller (trust, app-cred, or EC2 credential minted under one) rescopes or reauthenticates to influence the token scope and act outside the delegation’s fixed project or role set. This is the scope-bind escape class (OSSA-2026-015, OSSA-2026-005).
Current state. Defended by I1–I5. The residual risk is not the existing code
— it is the next change. The defense is spread across
validate_scope_boundaries(), calculate_effective_roles(),
from_security_context(), build_authz_info_from_fernet_token(), and
Credentials::try_from. A change that touches one and forgets another reopens
the class, exactly as the from_security_context → ProjectScopePayload
fall-through nearly did (I4 history).
Control.
- Testing: make the delegation-bound property a matrix test that is
generated, not hand-written — every
(AuthenticationContext variant) × (ScopeInfo variant) × (restricted / unrestricted delegation)cell asserted end-to-end throughnew_for_scope(), with the invariant “effective roles ⊆ delegation role set” checked mechanically.test_new_for_scope_delegated_roles_never_exceed_delegation_matrixis the seed; the gate is that adding a variant to either enum without adding its row fails to compile or fails the test (see Gate D, §4). - Design: keep V1’s controls anchored on
delegated_project_id(chain), and keep the Rust tripwire as the catch-all backstop.
V2 — Incomplete fan-out on a new auth method or scope shape (P1, structural)
Attack. Not an external attacker per se — a contributor adds an auth method
or ScopeInfo variant and updates 6 of the 7 places that must change (ADR 0017
lists them). The missed one silently widens authority.
Current state. Partially compile-enforced: exhaustive match on
AuthenticationContext / ScopeInfo forces some arms. But projections that
use a catch-all _ => arm (as Credentials::try_from does for the
non-delegated cases) or a fall-through default (the from_security_context bug)
are not caught by the compiler.
Control.
- Design gate: forbid wildcard
_ =>arms in the security-critical projections (Credentials::try_from,from_security_context,build_authz_info_from_fernet_token,validate_scope_boundaries,calculate_effective_roles). Require every variant named explicitly so a new variant is a compile error, not a silent default. Encode as a clippywildcard_enum_match_armallow-list scoped to those files, or a review checklist item promoted to a grep-based CI lint (Gate J). - Testing: the V1 matrix (Gate D) doubles as V2’s coverage — a new variant with no matrix row is a visible hole.
V3 — OPA policy correctness, coverage, and fail-open (P1)
Attack. A policy is missing, references the wrong input field, hits the Rego
“undefined argument poisons the function” trap (security.md I2), or the
handler never calls enforce() at all. Any of these is an authz bypass that no
Rust type catches.
Current state.
opa test policyruns, but only inpolicy-container.yml, gated onpaths: policy/**. A Rust-only change that alters whichpolicy_namea handler enforces, or changes theCredentialsprojection, does not trigger the policy test suite. The two halves of the authz decision are tested in separate CI jobs that never both run on a cross-cutting PR.opa fmt --checkruns inlinters.yml, but formatting is not correctness.- There is no gate asserting every enforced
policy_namehas a matching.regorule and a_test.rego, nor that every CRUD handler callsenforce(). CLAUDE.md requires “>=3 tests per CRUD handler” and the “>=1 negative policy test” convention, but nothing enforces it mechanically (agrep -rL enforceoverv3today returns onlyauth/token/create.rs, which is fine — but nothing keeps it that way). - Fail-closed on OPA error looks correct (
PolicyError→forbidden()), but there is no explicit test that an OPA outage / malformed response / timeout yields deny, not allow.
Control.
- Gate A: run
opa test policyin the mainci.ymlmatrix (OPA is already installed there for the API tests), unconditionally, so a Rust PR that changes enforcement is gated by policy tests too. - Gate B (coverage checker): a small CI script that (1) extracts every
enforce("<name>", …)string literal from the handlers, (2) asserts apolicy/**/<name>.regorule and a sibling_test.regoexist, and (3) asserts every handler module implementing a CRUD verb contains anenforcecall. Fail the build on any orphan in either direction. - Gate E (Rego footgun lint): a
conftest/opa-based check (or a regex gate) that flags delegated-policy helpers called with a bareinput.target.<x>.project_idinstead ofobject.get(..., null)— the exact trapsecurity.mdI2 warns about, where anundefinedargument makes even the “not delegated” fast path undefined. - Testing: add an explicit “OPA unreachable / returns garbage → request denied” integration test.
V3a — The handler→policy input-contract seam is untested (P1)
Attack. This is the sharpest and most under-appreciated form of V3, and it
is worth calling out on its own. The authorization decision is policy(input),
where input = {credentials, target, existing} is assembled in
HttpPolicyEnforcer::enforce (crates/keystone/src/policy.rs:118). Only the
credentials half comes from a tested projection (Credentials::try_from +
the Rust tripwire). The target, existing, and policy_name are chosen
by each handler, by hand, and the correctness of that choice is asserted nowhere
systematic. A handler that:
- picks the wrong
policy_name(evaluates…/showlogic on adelete), - keys the object under the wrong resource name (ADR 0002 mandates
{"target": {"<resource>": obj}}; a typo makes everyinput.target.<resource>.…lookupundefined, and anundefined-driven Rego rule can silently allow), - puts the stored object in
targetinstead ofexistingon an update (so an ownership check reads the attacker’s patch instead of the current row), - or forgets to strip a secret (I7),
produces a well-formed request to a correct policy that nonetheless decides on
the wrong document. opa test policy cannot catch this — it tests the policy
against hand-authored input that matches the intended contract, not the
input the handler actually emits.
Current state. Three layers exist; only two are tested.
| Layer | Tested today |
|---|---|
| Rego logic in isolation | ✅ opa test policy (synthetic input) |
credentials (chain) projection | ✅ Credentials::try_from tests + Rust scope-drift tripwire |
Handler-built target/existing/policy_name, and its composition with the real policy | ❌ ad-hoc mock captures only |
The seam is exercised only two ways today, neither sufficient:
- Ad-hoc mock capture. A handler test may inject
MockPolicyand assert on the captured arguments — e.g.test_create_policy_input_omits_password(crates/keystone/src/api/v4/user/create.rs) checksexisting.is_none()and that the password is absent fromtarget. This is exactly the right idea, but it is opt-in and sparse: it exists where an author remembered it. The delegation-sensitive credential handlers (credential/{create,show,update,delete,list}, the OSSA-2026-015 surface) ship an empty#[cfg(test)] mod tests {}and assert nothing at the seam. And a mock, by construction, short-circuits the real Rego — it proves “the handler built shape X,” never “policy P decides correctly on shape X.” - Implicit API tests.
test_apiruns a real OPA, but asserts functional / HTTP outcomes; it is not an authorization test suite — it does not sweep actor×target authorization matrices, and it does not isolate the input contract, so a handler that feeds OPA a subtly-wrong document but still returns the expected status on the happy path passes.
Control. Split the coverage gate (Gate B) into three graduated levels and make the seam a first-class, non-opt-in test target:
- Gate B2 (input-contract harness). Provide one shared capturing test enforcer
(a
PolicyEnforcerdouble that records every(policy_name, target, existing)) with a standard, uniform assertion set applied to every handler, not re-derived per test:policy_nameis a member of the known policy set and resolves to an existing.rego(ties to Gate B1);target(andexisting, when present) is a JSON object whose single outer key equals the endpoint’s expected resource name (ADR 0002 contract), so a mis-key is a test failure, not anundefinedat runtime;- operation/slot correctness: create/show/delete/list pass
existing: None; update passesexisting: Some(stored)andtarget: patch(never swapped); - secret-free: no denylisted field (
blob,password,*_secret, TOTP seed, token) appears anywhere intarget/existing— the generalized, mechanically-checked form of I7. Drive it as a route-sweep: enumerate the registered routes and push a request through each, so a newly-added handler is covered automatically and a handler that never callsenforce()is a visible failure. This is the piece that converts “someone remembered to assert the shape” into “the shape is always asserted.”
- Gate B3 (composition / decision test). Evaluate the handler-produced input
against the real
policy/bundle, so the test asserts an actual allow/deny — the layer a mock can never reach. Today onlyHttpPolicyEnforcerexists (OPA over HTTP/unix socket); to make this usable in handler-level andtest_apitests without a hand-maintained live server, add aPolicyEnforcerimplementation that evaluates the compiled bundle in-process (OPA already compiles a bundle inpolicy-container.yml;opa build -t wasm+ an in-process wasm evaluator, or a managedopa evalsubprocess, are the two options). With that in place, write a dedicated authorization matrix per endpoint — authorized actor → allow, unauthorized → deny, cross-domain → deny, delegated-escape → deny — driven through the real handler and the real policy. This is the suite that “targets authorization checks,” as distinct from the functional API tests that do not.
Gate B1 (existence) is cheap and should land first; B2 (contract) is the highest value-to-effort item for this specific gap and needs only a shared harness plus the route sweep; B3 (composition) is the strongest but carries the in-process-evaluator design cost and can follow.
V4 — OPA policy-bundle supply chain (P2)
Attack. The authorization logic is shipped as an OCI artifact
(opa build … --bundle → oras push ghcr.io/…/opa-bundle:latest,
policy-container.yml). Whatever the running Keystone loads is the policy. If
the bundle can be tampered with in the registry, or a stale/rolled-back latest
is pulled, every authz decision is attacker-defined — without touching
Keystone’s code or the policy/ tree.
Current state. The bundle is pushed unsigned; there is no evidence of
signature generation or of verification at load time. latest is mutable.
Control.
- Gate H: sign the bundle at publish (cosign / Sigstore keyless, which fits
the existing
id-token: writepermission already present in the publish job) and verify the signature + digest at bundle load in Keystone. Pin by digest, notlatest, in deployment config. - Design: document the policy bundle as a first-class trust boundary in
security.md(today it is implicit) — the running policy is as security-critical as the binary, and should have the same provenance bar.
V5 — Application-credential access_rules unenforced at request time (P1, live gap)
Attack. An operator creates a restricted app-cred with access_rules
limiting it to, say, GET /v3/servers. The rules are stored and CRUD’d
(crates/core-types/src/application_credential/…, appcred-driver-sql) but
no middleware matches the incoming (service, method, path) against them, so
the credential can call any endpoint. This is documented as an open gap in
security.md §5 and §9 — the review flags it as the single highest-impact
known live gap, because it silently converts a control the operator believes
is active into a no-op.
Current state. Advisory only, by the project’s own admission.
Control.
- Design: the ADR the gap already calls for — request-matching middleware keyed on the app-cred’s stored rules, evaluated before handler dispatch.
- Gate: until enforcement lands, add a startup/CRUD-time warning (and a
doc banner) that
access_rulesare not enforced, so operators are not misled. Optionally reject creation of an app-cred with non-emptyaccess_rulesbehind a config flag, to fail loud rather than silently accept an unenforceable restriction. - Testing: the enforcement middleware, when built, needs the full positive/negative matrix (in-scope call allowed, out-of-scope call denied, path/method/service each varied) plus a rescope test (rules survive rescope, per V1).
V6 — Denial of service on unrate-limited cryptographic endpoints (P2)
Attack. ADR 0022 phase 1 rate-limits POST /v3/auth/tokens by IP (and
optionally per confirmed user). The ADR itself notes that federation
authenticate endpoints, application-credential flows, EC2 token redemption, and
token validation are not covered — all perform crypto (signature/hash
verification) and are DoS amplifiers. An attacker hits /v3/ec2tokens or an
OIDC authenticate endpoint to burn CPU without ever authenticating.
Current state. Global-IP limiter merged (phase 1); per-endpoint coverage is
“follow-up ADR TBD.” Also note governor is per-node in-memory, so effective
limits are N× in an N-replica deployment (documented consequence).
Control.
- Design: the promised follow-up ADR extending handler-level limiting to federation / app-cred / EC2 / token-validate, with IP governance before the crypto step (ADR 0022 Invariant 4, “pre-hash enforcement,” generalized to “pre-crypto”).
- Testing: a load/abuse test per crypto endpoint asserting 429 before the
expensive path executes; a test that spoofed
X-Forwarded-Forfrom an untrusted peer does not reset the bucket (ADR 0022 Invariant 9). - Pentest: resource-exhaustion probing of every unauthenticated, crypto-bearing endpoint.
V7 — Dynamic auth plugins: pre-auth attack surface (P1 when implemented, ADR 0025 is Proposed)
Attack. ADR 0025 introduces WASM auth plugins invoked pre-authentication
by definition — a remote, unauthenticated party triggers plugin execution and
its http_fetch calls at will. Named sub-vectors from the ADR’s own threat
model:
- SSRF via
http_fetch(DNS-rebinding / connect-time IP re-validation againstallowed_hosts). - Claims injection: a plugin’s response claims shadowing a
privilege-relevant field — mitigated structurally by outer-keying under
plugin_claims.<plugin_name>(visible already inCredentials,crates/core/src/policy.rs) and a reserved-key denylist. - Identity-binding bypass: a
find_userthat does an unscoped lookup would be a full account-takeover; the ADR binds to a per-plugin(plugin_name, external_id)namespace precisely to prevent it. route-mode observation surface: a router sees raw credential material for a larger slice of traffic than any other plugin.- Resource exhaustion: fuel/deadline/memory caps + per-source-IP token bucket.
Current state. Design-stage; AuthenticationContext::WasmPlugin and
plugin_claims projection already exist in the tree, so partial plumbing has
landed ahead of the full mechanism.
Control.
- Design: ADR 0025 is unusually thorough — the review’s ask is that its §4–§7
controls each land with a test that exercises the failure, not just the
intended path (SSRF to a rebinding host is blocked; a fabricated
ResolvedIdentityHandleis rejected; a claim namedis_systemis dropped; aroutetarget off the allowlist is rejected). - Gate: fuzz the host↔guest JSON boundary (
AuthPluginRequest/AuthPluginResponse/RouteResponse) — untrusted guest output parsed by the host is a classic memory/logic sink. Add a CI gate that the reserved header denylist (Authorization,Cookie,X-Auth-Token, …) cannot be named inexposed_headers(config-load rejection), tested directly. - Pentest: treat the plugin invocation path as an internet-facing,
unauthenticated endpoint — SSRF, request smuggling into
routetargets, and rate-limit bypass are the priority scenarios.
V8 — OAuth2/OIDC provider role (P2 when implemented, ADR 0026 is proposed)
Attack. Acting as an OAuth2/OIDC provider adds the classic web-authz
surface Keystone did not previously have: open-redirect via redirect_uri,
authorization-code interception without PKCE, CSRF on the consent flow,
clickjacking, refresh-token replay.
Current state. ADR 0026 already commits to the right defaults — exact-match
redirect_uris (wildcards rejected), mandatory S256 PKCE for public clients
enforced at CRUD time, HTTPS-only for confidential clients, refresh-token
rotation. The controls are specified; the risk is drift during implementation.
Control.
- Testing: a negative test per web-authz vector — non-matching
redirect_urirejected,plainPKCE rejected, missingcode_verifierrejected, reused authorization code rejected, rotated refresh token’s predecessor invalidated. - Pentest: standard OAuth2 provider test suite (redirect handling, PKCE downgrade, mix-up, token substitution).
V8a — OAuth2 client enumeration, timing side-channels, and credential-probing (fixed 2026-07-16; P1 device-flow rate-limit gap closed)
Attack. Prompted by public research on “OAuth client ID spoofing”
(Proofpoint, July 2026: attackers validate stolen Entra ID credentials at scale
by presenting spoofed/arbitrary client_ids to a token endpoint that
distinguishes valid from invalid client IDs, checking passwords without a
successful sign-in ever being logged against a real, registered application —
and without needing that application to actually exist). The generalizable
attack classes are: (a) distinguish “unknown client_id” from “wrong secret” by
response content, status, or timing; (b) probe usernames/passwords through an
endpoint that doesn’t require a real, pre-registered relying party; (c)
brute-force short human-facing codes (device user_code) with no throttle.
Current state — verified by direct code read, 2026-07-16.
| Sub-vector | Verdict | Evidence |
|---|---|---|
client_credentials/authorization_code/refresh_token/token-exchange: unknown client_id vs. wrong secret vs. disabled client, by response | Mitigated | token.rs:376-448 (client_credentials), :580-638 (authenticate_client, shared by the other three grants) — get_by_client_id runs unconditionally; every rejection branch (unknown :396, disabled/deleted :403,605, no secret :419,425) calls crypto::generate_dummy_hash() before returning the same 401 invalid_client / "client authentication failed" body as a real wrong-secret rejection (:437-448, :617-621) |
| Argon2id timing (unknown client vs. known client, wrong secret) | Mitigated (defense-in-depth, residual accepted) | crypto.rs:81-107 — generate_dummy_hash() performs a real Argon2id hash (not a cheap early-return) with the same configured cost params as verify_secret()’s verify; hash and verify are comparable-cost Argon2id operations but not byte-identical code paths, and the DB lookup itself is faster for an unknown client_id than a known one. This residual gap is explicitly called out in-code (token.rs:387-395) and bounded by the pre-hash, raw-client_id-keyed rate limiter (token.rs:367-373, checked before the DB lookup) |
client_credentials grant: existence+grant-type oracle | Fixed | token.rs now checks grant_types.contains(ClientCredentials) after secret verification (moved below the verify_secret/generate_dummy_hash block), matching the shared authenticate_client() posture used by the other three grants. Covered by the existing test_client_without_client_credentials_grant_is_unauthorized_client |
/authorize: unknown client_id vs. unregistered redirect_uri | Not vulnerable (by design) | authorize.rs:257-302 — messages differ (“unknown or disabled client” vs. “redirect_uri is not registered”), but client_id is intentionally public (RFC 6749 §2.2) and client registration is admin/Tier-1/Tier-2-gated per domain (ADR 0020), not a global self-service namespace an outsider can probe cross-tenant the way Entra’s is — the precondition that makes Entra’s spoofing technique work (any client_id, from any tenant, reaches a password check without being registered) does not exist here: every client_id presented anywhere must already be a real OAuth2Client row in that domain |
Human login (/authorize/login, /device/login): username enumeration | Mitigated | authorize.rs:470-521 — uniform "invalid username or password" on both bad-request-shape and authenticate_by_password failure; ADR 0010’s per-user throttle applies inside authenticate_by_password itself regardless of entry point |
/device, /device/login, /device_authorization: per-IP rate limiting | Fixed | device.rs’s device_login_code (user_code submission) and device_login (password check) and device_authorization.rs’s device_authorization now call state.rate_limiters.check_ip() before any DB lookup or password hashing, mirroring authorize.rs’s /authorize//authorize/login posture exactly. /device/consent intentionally left unguarded, mirroring authorize_consent’s precedent (only reachable with an already-authenticated session, so it carries no unauthenticated probing surface of its own). Covered by new tests test_device_submit_code_rate_limited_by_ip_before_lookup, test_device_login_rate_limited_by_ip_before_password_check (device.rs) and test_rate_limit_returns_429_before_client_lookup (device_authorization.rs) |
| Refresh token lookup | Not vulnerable | oauth2_session/service.rs:70-73,275-287 — lookup key is SHA-256(bearer), an indexed equality read, not a raw-value or prefix comparison; no partial-match timing leak |
Control (implemented 2026-07-16).
- Per-IP
check_iprate limiting, identical to/authorize//authorize/login’s, now gates/device_authorization,/device, and/device/login, applied before any DB lookup or password hashing — closing the one concrete gap this review found; everything else in the OAuth2 provider was already either spec-correct-by-design or carrying a matching defense. handle_client_credentials_grant(token.rs) now checksgrant_typesafter secret verification, so it matches the uniform-response posture of the other three grant handlers.- Testing: negative tests asserting
429under burst-exhaustion now exist for/device,/device/login,/device_authorization, alongside the pre-existing/authorize//tokencoverage. - Design: V6’s “endpoints not yet covered by rate limiting” list (ADR 0022 follow-up) should still be updated to note the device-flow browser endpoints are now covered, alongside the federation/app-cred/EC2/ token-validate endpoints that remain open.
- Pentest: password-spray
/device/loginacross many usernames from a single IP; brute-force/deviceuser_codeguessing at volume; attempt to reach a password check via anyclient_idvalue without it being a pre-registeredOAuth2Clientrow (expected: impossible, confirm it stays that way) — all should now hit429after one request under a tight burst config, matching/authorize’s behavior.
V9 — Secret leakage into policy input, logs, and audit (P2, mitigated)
Attack. Decrypted credential blobs (EC2 secret keys, TOTP seeds) reaching OPA (which logs decisions) or the CADF audit trail.
Current state. I7 strips the blob in credential_policy_input(); secrets
are now wrapped with the secrecy crate (recent commit b35ca42). Good.
Control.
- Gate I: a structural test that serializes a
Credentials/ policy-input object built from a secret-bearing credential and asserts the secret bytes do not appear in the JSON — run in CI so a future field addition that re-introduces a blob is caught. Extend the same assertion to the audit event payload (ADR 0023) and to errorDisplayimpls. - Design: document “no secret in policy input / audit / logs / error strings” as a named invariant (I7 covers policy input; generalize it).
V10 — Token lifecycle: revocation and version binding (P2)
Attack. A token outliving the authority it was minted under — a role removed, a trust deleted, a plugin patched to fix a bug, an identity link revoked. If validation trusts the token’s frozen claims over live state, the window stays open.
Current state. authorize_by_token re-expands and re-resolves roles against
live assignments and checks revocation (ADR 0017); ADR 0025 adds plugin_sha256
version-binding and bulk revoke_all. The design is revocation-aware.
Control.
- Testing: property test — “role removed at time T ⇒ token issued before T cannot exercise that role after T,” across each scope shape; likewise trust deletion and app-cred expiry mid-token-lifetime.
- Pentest: revocation-window probing.
4. Preemptive security gates to add (CI + design)
Summary of the gates referenced above, ordered by value-to-effort. All are
additive to the existing pipeline (ci.yml, linters.yml, audit.yml,
policy-container.yml).
| Gate | What it does | Catches | Effort |
|---|---|---|---|
| A | Run opa test policy in main ci.yml, not only on policy/** paths | V3 — Rust change that breaks policy enforcement merges green today | Low |
| B1 | Policy↔handler existence checker (every enforce(name) ⇒ .rego + _test.rego; every CRUD handler ⇒ enforce) | V3 — missing policy, orphan policy, unenforced handler | Low |
| B2 | Handler→policy input-contract harness: a shared capturing enforcer + route-sweep asserting resource-key correctness, target/existing slotting, and secret-free input, on every handler automatically | V3a — wrong policy_name, mis-keyed resource, target/existing swap, secret leak — none caught by opa test | Med |
| B3 | Handler→policy composition test: an in-process real-Rego enforcer feeding handler-built input to the actual bundle, plus a per-endpoint authorization matrix (authorized/unauthorized/cross-domain/delegated-escape) | V3a — handler feeds a subtly-wrong document that a mock accepts but the real policy would decide differently on | Med/High |
| C | Invariant-test presence check: every delegated-auth policy carries a scope-drift negative case; every new scope/auth arm has a matrix row | V1/V2 — silent boundary regression | Med |
| D | Generated (auth method × scope × restricted?) matrix test through new_for_scope() with “roles ⊆ delegation” assertion | V1/V2 — the I4 near-miss class | Med |
| E | Rego lint for the undefined-argument footgun (object.get(…, null) required for delegated helper args) | V3 — I2 trap | Low |
| F | Fuzz Credentials::try_from, Fernet token decode, and (when built) the WASM host↔guest JSON boundary | V1/V7 — malformed-input logic bugs | Med |
| G | Mutation testing (cargo-mutants) scoped to core/core-types auth+policy modules, to prove the negative tests actually fail on a regression | all — verifies the tests have teeth | Med |
| H | Sign the OPA policy bundle (cosign) + verify signature/digest at load | V4 — policy supply chain | Med |
| I | Structural “no secret in policy input / audit / error string” serialization test | V9 | Low |
| J | Grep-based SAST encoding the security.md §7 checklist (e.g. flag credentials.project_id used as a delegation boundary; flag wildcard _ => in the 5 critical projections) | V1/V2 | Low |
The highest-leverage items are A, B1, and B2. A and B1 close the
structural blind spot where the Rust half and the Rego half of an authorization
decision are validated by different CI jobs that don’t both run on a
cross-cutting PR. B2 closes the seam that neither opa test nor the
existing handler mocks cover: whether the handler actually feeds the policy the
right document (V3a). Together they remove the ways an authz bypass can merge
green; B3 then upgrades from “the input shape is right” to “the real policy
decides right on that input,” and everything else hardens an already-good
position.
Design-time gates (not CI)
- Promote the
security.md§7 reviewer checklist into a required PR template section for any diff touching auth/scope/delegation/token/policy, with the reviewer ticking each invariant. It exists as prose today; make it a gate on the PR. - Name new trust boundaries in
security.mdas they appear: the OPA policy bundle (V4), the WASM plugin invocation path (V7), and the OAuth2 provider surface (V8) are all boundaries the current §3 diagram does not draw.
5. Testing strategy for security gaps
The project already tests the happy path well and has good negative coverage in the hot spots. To find gaps rather than confirm behavior:
- Negative-test-first, mechanically required.
security.md§7 already asks “are there negative tests proving the escape is blocked?” Gate C makes it non-optional for delegated policies and new scope shapes. - Property-based invariants over example-based cases. Encode the security
properties as properties, not fixtures:
- Delegation monotonicity: for any rescope/reauth sequence, effective roles never exceed the original delegation’s role set.
- Scope pinning:
delegated_project_id == project_idholds for every delegatedCredentialsthe projection can produce (the tripwire, as a property, not just a per-policy assertion). - Revocation: authority removed at T is unusable after T (V10). Use
proptestto search the input space around these.
- Matrix/exhaustiveness tests tied to the enums (Gate D) so coverage grows
automatically with the type system. 3a. Test the handler→policy input
contract and its composition, not just the policy (Gates B2/B3, vector
V3a).
opa test policyproves the policy is right on the intended input; it says nothing about whether the handler emits that input. Assert the emitted(policy_name, target, existing)uniformly across every handler (B2), and — separately from the functional API tests — run a dedicated authorization matrix (authorized / unauthorized / cross-domain / delegated-escape actors per endpoint) through the real handler and the real policy (B3). Functional API tests that assert HTTP status on the happy path are not authorization tests and must not be counted as such. - Differential testing against Python Keystone. CI already installs
pip install keystonefor cross-verification — extend it to authorization-decision differentials on the delegated paths, so a divergence from the reference implementation’s allow/deny is visible. - Mutation testing (Gate G) to confirm the negative tests fail when the invariant is broken — a negative test that still passes after you delete the check is worse than none.
- Fuzzing the untrusted-input parsers (Gate F): Fernet decode, the OPA response deserializer, and the WASM boundary.
6. Penetration testing targets
A pentest engagement should be handed this prioritized scenario list rather than “test Keystone.” Each maps to a vector above.
- Delegation escape (V1). Mint a restricted app-cred / trust, mint an EC2
credential under it, redeem at
/v3/ec2tokens, then rescope/reauth the resulting token every way the API allows; assert roles never exceed the delegation and scope never leaves the delegation project. This is the crown-jewel scenario and maps directly to OSSA-2026-005/015. - List leakage (I8, CVE-2019-19687). For every list endpoint, confirm per-item re-check drops rows the caller cannot individually read.
- OPA bypass / supply chain (V3/V4). Attempt to reach a handler whose policy is missing or misnamed; test behavior when OPA is unreachable; assess bundle provenance.
- Pre-auth DoS (V6). Resource-exhaust every unauthenticated crypto
endpoint; attempt
X-Forwarded-Forspoofing to defeat per-IP limits. - App-cred
access_rules(V5). Confirm the currently-unenforced state, and re-test once middleware lands. - WASM plugins (V7) and OAuth2 provider (V8) — full dedicated suites when those features ship; treat both as internet-facing pre-auth surfaces.
- Token lifecycle (V10). Revocation-window and version-binding probing.
- OAuth2 device-flow rate limiting (V8a, fixed 2026-07-16). Password-spray
/device/loginfrom a single IP across many usernames; brute-force/deviceuser_codeguessing at volume — both should now hit429after burst exhaustion; re-verify this holds after any future change todevice.rs/device_authorization.rs.
7. Prioritized recommendation
If the project adopts nothing else from this review:
- Gate A + Gate B1 + Gate B2 (§4) — close the split-CI authz blind spot
and the untested handler→policy input contract (V3a). A + B1 are low
effort; B2 needs only a shared capturing enforcer and a route sweep, and is
the single highest value-to-effort item because it makes “the handler feeds
the policy the right document” a mechanical, non-opt-in test on every handler
— which
opa testand the current mocks do not. - Ship
access_rulesenforcement or fail loud (V5) — the highest-impact known live gap; today a control operators trust is a no-op. - Gate D matrix + Gate G mutation testing (V1/V2) — convert the “remembered checklist” defense of the delegation boundary into a structural one.
- Sign the policy bundle (V4) — the running policy deserves the same provenance bar as the binary.
The rest (rate-limit coverage, plugin/OAuth2 test suites, secret-leak structural tests) should land alongside the features they protect, with the failure-exercising tests treated as part of the feature’s definition of done, not a follow-up.
Federation support
Python Keystone is not implementing the Federation natively (neither SAML2, nor OIDC). It relies on the proxy server for the authentication protocol specifics and tries to map resulting users into the local database. This leads to a pretty big number of limitations (not limited to):
-
Identity Provider can be only configured by cloud administrators only
-
Pretty much any change on the IdP configuration require restart of the service
-
Certain protocol specifics can not be implemented at all (i.e. backend initiated logout)
-
Forces deployment of the proxy service in front of Keystone relying on the modules for SAML2 and/or OIDC implementation (such modules may be abandoned or removed).
-
Client authentication right now is complex and error prone (every public provider has implementation specifics that are often even not cross-compatible)
In order to address those challenges a complete reimplementation is being done with a different design. This allows implementing features not technically possible in the py-keystone:
-
Federation is controlled on the domain level by the domain managers. This means that the domain manager is responsible for the configuration of how users should be federated from external IdPs.
-
Identity providers and/or attribute mappings can be reused by different domains allowing implementing social logins.
-
Keystone serves as a relying party in the OIDC authentication flow. It decreases amount of different flows to the minimum making client applications much simpler and more reliable.
API changes
A series of brand new API endpoints have been added to the Keystone API.
-
/v4/federation/identity_providers (manage the identity providers)
-
/v4/federation/auth (initiate the authentication and get the IdP url)
-
/v4/federation/oidc/callback (exchange the authorization code for the Keystone token)
-
/v4/federation/identity_providers/{idp_id}/jwt (exchange the JWT token issued by the referred IdP for the Keystone token)
-
/v4/mappings/rulesets (manage mapping rulesets that define how claims are mapped to Keystone identities)
Note: The legacy
/v4/federation/mappingsAPI has been removed. Mapping configuration is now handled through the unified mapping engine at/v4/mappings/rulesets.
DB changes
Following tables are added:
-
federated_identity_provider -
federated_auth_state
Compatibility notes
Since the federation is implemented very differently to how it was done before it certain compatibility steps are implemented:
-
Identity provider is “mirrored” into the existing
identity_providerwith the subset of attributes -
For every identity provider “oidc” and “jwt” protocol entries in the
federation_protocoltable is created pointing to the “<<null>>” mapping.
Testing
Federation is very complex and need to be tested with every supported public provider. Only this can guarantee that issues with not fully compliant OIDC implementations can be identified early enough.
Authorization code flow requires presence of the browser. Due to that the tests need to rely on Selenium.
At the moment following integrations are tested automatically:
- Keycloak (login using browser)
- Keycloak (login with JWT)
- GitHub (workload federation with JWT)
Authentication using the Authorization Code flow and Keystone serving as RP
sequenceDiagram
Actor Human
Human ->> Cli: Initiate auth
Cli ->> Keystone: Fetch the OP auth url
Keystone --> Keystone: Initialize authorization request
Keystone ->> Cli: Returns authURL of the IdP with cli as redirect_uri
Cli ->> User-Agent: Go to authURL
User-Agent -->> IdP: opens authURL
IdP -->> User-Agent: Ask for consent
Human -->> User-Agent: give consent
User-Agent -->> IdP: Proceed
IdP ->> Cli: callback with Authorization code
Cli ->> Keystone: Exchange Authorization code for Keystone token
Keystone ->> IdP: Exchange Authorization code for Access token
IdP ->> Keystone: Return Access token
Keystone ->> Cli: return Keystone token
Cli ->> Human: Authorized
TLDR
The user client (cli) sends authentication request to Keystone specifying the identity provider and optionally the scope (no credentials in the request). In the response the user client receives the time limited URL of the IDP that the user must open in the browser. When authentication in the browser is completed the user is redirected to the callback that the user also sent in the initial request (most likely on the localhost). User client is catching this callback containing the OIDC authorization code. Afterwards this code is being sent to the Keystone together with the authentication state and the user receives regular scoped or unscoped Keystone token.
Identity provider and mapping configuration
The identity provider is bound to a domain via --domain-id and references a
mapping ruleset through --default-mapping-name. The mapping ruleset (managed
at /v4/mappings/rulesets) defines how JWT/OIDC claims are mapped to Keystone
identities. The IDP --default-mapping-name must match the mapping_id or
rule_name in the ruleset so that the engine can resolve the correct mapping at
callback time.
Note: Legacy federation mappings (
/v4/federation/mappings) have been replaced by the unified mapping engine (/v4/mappings/rulesets).
User domain mapping
A Keystone identity provider can be bound to a single domain by setting the domain-id attribute on it. This means all users federated from such IDP would be placed in the specified domain.
Domain resolution can also be controlled by the mapping ruleset through its
domain_resolution_mode:
- Fixed: locked to the IDP domain
- ClaimsOrMapping: rules may override domain via claims templates
- ClaimsOnly: neither IDP nor mapping is bound to a domain
The ultimate flexibility of having a single IdP for multiple domains is by
using the user_domain_id template in the mapping rule to specify domain the
user should belong to. Authentication with the claim missing is going to be
rejected.
User group membership
When a user authenticates using OIDC, group memberships are synced on every
login via the mapping engine. The mapping ruleset defines which groups the user
should be assigned to based on JWT/OIDC claims. With IdentityMode::Local the
engine performs user create/find and group membership sync on every login.
The major consequence when using application credentials that rely on roles assigned through group memberships is that the user needs to periodically login using the OIDC, since only the mapping engine can refresh group memberships.
Authenticating with the JWT
It is possible to authenticate with the JWT token issued by the federated IdP. More precisely it is possible to exchange a valid JWT for the Keystone token. There are few different use scenarios that are covered.
Since the JWT was issued without any knowledge of the Keystone scopes it becomes hard to control scope. In the case of real human login the Keystone may issue unscoped token allowing user to further rescope it. In the case of the workflow federation that introduces a potential security vulnerability. As such in this scenario the mapping ruleset is responsible to fix the scope.
Login request looks following:
curl https://keystone/v4/federation/identity_providers/${IDP}/jwt -X POST -H "Authorization: bearer ${JWT}" -H "openstack-mapping: ${MAPPING_RULESET_NAME}"
The openstack-mapping header references a mapping ruleset managed at
/v4/mappings/rulesets. The engine resolves the ruleset from the IDP and uses
the header as the rule_name hint for targeted rule matching.
Regular user obtains JWT (ID token) at the IdP and presents it to Keystone
In this scenario a real user (human) is obtaining the valid JWT from the IDP using any available method without any communication with Keystone. This may use authorization code grant, password grant, device grant or any other enabled method. This JWT is then presented to the Keystone and an explicitly requested mapping ruleset converts the JWT claims to the Keystone internal representation after verifying the JWT signature, expiration and further restricted bound claims.
Workload federation
Automated workflows (Zuul job, GitHub workflows, GitLab CI, etc) are typical workloads not being bound to any specific user and are more regularly considered being triggered by certain services. Such workflows are usually in possession of a JWT token issued by the service owned IdP. Keystone allows exchange of such tokens to the regular Keystone token after validating token issuer signature, expiration and applying the configured mapping ruleset. Since in such case there is no real human the mapping also needs to be configured slightly different.
-
It is strongly advised the mapping ruleset must fill
token_user_id,token_project_idvia theAuthorizationfields. This allows strong control of which technical account is being used and which project such request can access. -
Mapping ruleset should use
bound_audiences,bound_claims,bound_subject, etc to control the tokens issued by which workflows are allowed to access OpenStack resources.
GitHub workflow federation
In order for the GitHub workflow to be able to access OpenStack resources it is
necessary to register GitHub as a federated IdP and establish a corresponding
attribute mapping of the jwt type.
IdP:
"identity_provider": {
"name": "github",
"bound_issuer": "https://token.actions.githubusercontent.com",
"jwks_url": "https://token.actions.githubusercontent.com/.well-known/jwks"
}
Mapping:
"mapping": {
"type": "jwt",
"name": "gtema_keystone_main",
"idp_id": <IDP_ID>,
"domain_id": <DOMAIN_ID>,
"bound_audiences": ["https://github.com"],
"bound_subject": "repo:gtema/keystone:pull_request",
"bound_claims": {
"base_ref": "main"
},
"user_id_claim": "actor_id",
"user_name_claim": "actor",
"token_user_id": <UID>
}
TODO: add more claims according to docs
A way for the workflow to obtain the JWT is described here.
...
permissions:
token: write
contents: read
job:
...
- name: Get GitHub JWT token
id: get_token
run: |
TOKEN_JSON=$(curl -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://github.com")
TOKEN=$(echo $TOKEN_JSON | jq -r .value)
echo "token=$TOKEN" >> $GITHUB_OUTPUT
...
# TODO: build a proper command for capturing the actual token and/or write a dedicated action for that.
- name: Exchange GitHub JWT for Keystone token
run: |
KEYSTONE_TOKEN=$(curl -H "Authorization: bearer ${{ steps.get_token.outputs.token }}" -H "openstack-mapping: gtmema_keystone_main" https://keystone_url/v4/federation/identity_providers/IDP/jwt)
Federating Keystone with Keycloak
Kestone enables federating users from the Keycloak to OpenStack. This integration is considered a primary citizen and is enforced using the integration tests.
Connection methods
It is possible to user Keycloak as the shared/global Identity Provider or to bind it for the single Keystone domain to be used as the private IdP.
Using Keycloak as a global IdP
When connecting the Keycloak as the Idp that can be used by all Keystone domains
an IdP is registered without specifying the domain_id. It is important to
remember also that in this case a single Keycloak realm can be registered at a
time and Keystone is not itself supporting multirealms configuration. It is,
however, possible to register every single Keycloak realm as a technically
independent IdP, what they in reality are.
Further it is necessary to establish rules into which Keystone domains Keycloak users are going to be placed. It can be accomplished with two different ways:
- domain bound mappings.
- using the
domain_idin the OIDC claim.
When using the domain bound mapping such mapping specifies the domain_id
property and every user is explicitly selecting the desired mapping. Logically
this introduce possibility for users to easily roam between different domains
without control. They only get the permissions explicitly granted them on the
concrete scope, so they will not get elevated privileges. But users are still
going to be created in the different domains. This may be acceptable for the
private cloud use case or when anyway only a single domain is existing. It
should not, however, be used for the public cloud use case.
A far more flexible alternative is to rely on the domain_id claim populated
into the user ID token issued by the IdP. This way the IdP controls the user
domain relations and can apply whichever logic is internally desired. The only
requirement for this method is that the domain_id claim must be present in the
token. It can be achieved, for example, by creating a client scope that
re-exposes the domain_id user attribute as the token claim. On the Keycloak
side users can be structured into groups where each group stands for the
Keystone domain and the domain_id attribute is being set on the group level.
Every user automatically inherits all group attributes in Keycloak.
Keycloak as a private IdP
In a very similar way to connecting Keycloak as a shared IdP making it bound to
the concrete Keystone domain can be chosen (i.e. in a public cloud a certain
customer has already Keycloak instance on premises and is willing to use it to
consume cloud resources). The only difference to the previous scenario is that
both IdP and mapping in Keystone explicitly specify the domain_id property of
the domain they should be bound to.
Configuration
A first step to connect Keycloak as an IdP in Keystone is in the preparation of the OIDC client. Since the Kecloak volatile and changes the UI concepts quite often no screenshots are going to be present in this guide. Instead just a description is given. Functional tests in the project are performing all this steps using the API and can be used as a reference for uncertainty.
- A OIDC type client should be created.
-
redirect_urisspecifies list of clients (i.e. user cli/tui, dashboards, etc) that would require to provide a callback listener to interact with the IDP as a relying party capturing the authorization code. To allow users to use rust cli (osc) an urlhttp://localhost:8050/*) must be added. -
client authorization should be enabled for the client for the better security. The
client_secretis only going to be known by the Keystone itself and is not required to be known by the end users of the cloud. -
When using Keycloak in the shared mode it is most likely necessary to add
domain_idclaim into the token. For this a protocol mapper should be added (or the existing one extended) adding a claim into the access token, id token and userinfo token. The claim name is not relevant and is going to be used on the Keystone side. It is described in the previous chapter how the corresponding attribute can be assigned to the user (directly or through the group membership).
- Registering the IdP on Keystone.
An osc is going to be used to register the IdP.
osc identity4 federation identity-provider create --bound-issuer <KEYCLOAK_ISSUER> --oidc-client-id <CLIENT_ID> --oidc-client-secret <CLIENT_SECRET> --oidc-discovery-url <KEYCLOAK_DISCOVERY_URL> --default-mapping-name keycloak --domain-id <DOMAIN_ID> --name keycloak
The default-mapping-name references a mapping ruleset managed at
/v4/mappings/rulesets. The ruleset must match the IDP source
(IdentitySource::Federation) and be named accordingly.
- Creating the mapping ruleset.
Now it is necessary to create a mapping ruleset that converts OIDC protocol
claims into the corresponding Keystone user attributes via the
/v4/mappings/rulesets API:
{
"mapping": {
"mapping_id": "keycloak",
"domain_id": "<DOMAIN_ID>",
"source": { "type": "federation", "idp_id": "<IDP_ID>" },
"domain_resolution_mode": "fixed",
"enabled": true,
"rules": [{
"name": "keycloak",
"match": { "all_of": [] },
"identity": {
"identity_mode": "local",
"user_name": "${claims.preferred_username}"
}
}]
}
}
source.type=federationbinds this ruleset to the identity provider.user_nametemplate interpolates the OIDC claim into the Keystone user name.identity_mode: localcreates/finds the user and syncs groups on every login.
- API Login process
The osc supports natively the federated authentication.
clouds:
devstack-oidc-kc-shared:
auth_type: v4federation
auth:
auth_url: https://<KEYSTONE_API_URL>
identity_provider: <IDENTITY_PROVIDER_ID>
attribute_mapping_name: <OPTIONAL_MAPPING_NAME>
project_name: <OPTIONAL_TARGET_PROJECT_NAME>
project_domain_name: <OPTIONAL_TARGET_PROJECT_DOMAIN_NAME>
With the clouds.yaml configuration entry as above osc will prompt user to
open a browser with the specially prepared url. It then starts the callback
handler webserver to receive the OIDC authorization code from the direct IdP
interaction. In the next step it exchanges the code for the Keystone session
token.
The same in the API would map to the following steps:
-
POST call to https://KEYSTONE_API_URL/v4/federation/auth to get the IdP URL.
-
waiting for the IdP callback at the given redirect_uri with the authorization code.
-
POST to https://KEYSTONE_API_URL/v4/federation/oidc/callback to finish the authentication exchanging the authorization code for the Keystone API token.
Using Okta as the Identity provider
While it is possible to use Okta as the shared Identity Provider in OpenStack it only makes sense for private cloud installations. For the public cloud this is unlikely to be suitable, therefore it is described how to use Okta as the private (domain bound) identity provider. It is possible to have as many connections to Okta for different domains as necessary.
Configuration
Okta/Auth0 as an managed Identity provider can be easily integrated as a source of the users and groups for the customer dedicated domain. A dedicated application need to be established on Okta (i.e. OpenStack) for the authentication delegation. There are many configuration options that can be used on the Okta side and will influence the interaction. It is not possible to describe every single one precisely, therefore only the basic setting are described here:
- grant type: authorization code
- sign in redirect uris (enable the cli login):
[
http://localhost:8050/oidc/callback].
Group memberships are not exposed by default and require additional changes
On the Keystone side the following must be implemented:
- register an identity provider with the data obtained from Okta app configuration:
osc identity4 federation identity-provider create --bound-issuer <OKTA_ISSUER> --oidc-client-id <CLIENT_ID> --oidc-client-secret <CLIENT_SECRET> --oidc-discovery-url <OKTA_DISCOVERY_URL> --default-mapping-name okta --domain-id <DOMAIN_ID> --name okta
The --default-mapping-name references a mapping ruleset managed at
/v4/mappings/rulesets. The ruleset must match the IDP source
(IdentitySource::Federation) and be named accordingly.
- create mapping ruleset
A mapping ruleset must be created via /v4/mappings/rulesets that defines how
Okta OIDC claims are mapped to Keystone identities:
{
"mapping": {
"mapping_id": "okta",
"domain_id": "<DOMAIN_ID>",
"source": { "type": "federation", "idp_id": "<IDP_ID>" },
"domain_resolution_mode": "fixed",
"enabled": true,
"rules": [{
"name": "okta",
"match": { "all_of": [] },
"identity": {
"identity_mode": "local",
"user_name": "${claims.preferred_username}"
}
}]
}
}
Afterwards osc can be used by users to authenticate.
clouds.yaml
clouds:
devstack-oidc-okta:
auth_type: v4federation
auth:
auth_url: <KEYSTONE_URL>
identity_provider: <IDP_ID>
$ osc --os-cloud devstack-oidc-okta auth show
A default browser is going to be opened at `https://<CENSORED>.okta.com/oauth2/default/v1/authorize?response_type=code&client_id=<CENSORED>&state=<CENSORED>&code_challenge=<CENSORED>&code_challenge_method=S256&redirect_uri=http%3A%2F%2Flocalhost%3A8050%2Foidc%2Fcallback&scope=openid+profile+openid&nonce=<CENSORED>`. Do you want to continue? [y/n]
Using Dex as the Identity provider
Dex is an identity service that uses OpenID Connect to drive authentication for other apps. Dex acts as a portal to other identity providers through “connectors.” This lets Dex defer authentication to LDAP servers, SAML providers, or established identity providers like GitHub, Google, and Active Directory. At the same time Dex is not an Identity Provider in the classical sense since it does not itself stores user identity data. Instead it serves more like an OpenIDConnect proxy.
Since Dex is not responsible for the ideneity data it also not the right place for the advanced claims that would be necessary to address all possible scenarios of the Keystone integration. It should be considered therefore as mostly suitable for the private IdP mode only or being the only existing IdP.
Configuration
Dex is designed to be deployed in front of a real IdP (Keycloak, GitHub, Google, etc). For the sake of example a static user base and a static client is going to be used.
- Prepare the Dex configuration
issuer: http://localhost:5556/dex
storage:
type: memory
web:
http: 0.0.0.0:5556
staticClients:
- id: keystone_test
redirectURIs:
- "http://localhost:8050/oidc/callback"
name: 'Keystone'
secret: keystone_test_secret
enablePasswordDB: true
staticPasswords:
- email: "admin@example.com"
# bcrypt hash of the string "password": $(echo password | htpasswd -BinC 10 admin | cut -d: -f2)
hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"
username: "admin"
userID: "08a8684b-db88-4b73-90a9-3cd1661f5466"
With this configuration Dex server can be started
dex server
- Registering the IdP on Keystone.
An osc is going to be used to register the IdP.
osc identity4 federation identity-provider create --oidc-client-id <CLIENT_ID> --oidc-client-secret <CLIENT_SECRET> --oidc-discovery-url <DEX_DISCOVERY_URL> --default-mapping-name dex --domain-id <DOMAIN_ID> --name dex
The --default-mapping-name parameter must reference a mapping ruleset name
that is created in the next step.
- Registering the mapping ruleset.
A mapping ruleset must be created via /v4/mappings/rulesets that defines how
Dex OIDC claims are mapped to Keystone identities. The mapping_id or
--default-mapping-name from the IDP is used by the engine to resolve the
correct ruleset and rule at callback time.
{
"mapping": {
"mapping_id": "dex",
"domain_id": "<DOMAIN_ID>",
"source": { "type": "federation", "idp_id": "<IDP_ID>" },
"domain_resolution_mode": "fixed",
"enabled": true,
"rules": [
{
"name": "dex",
"match": { "all_of": [] },
"identity": {
"identity_mode": "local",
"user_name": "${claims.email}",
"user_id": "${claims.sub}"
},
"authorizations": [],
"groups": []
}
]
}
}
sourceidentifies the identity provider.matchdefines the conditions for rule evaluation (empty array matches all).identity_mode: "local"performs user CRUD and group sync on every login.user_nameanduser_idare templates that interpolate OIDC claims.
- API Login process
The osc supports natively the federated authentication.
clouds:
devstack-oidc-kc-shared:
auth_type: v4federation
auth:
auth_url: https://<KEYSTONE_API_URL>
identity_provider: <IDENTITY_PROVIDER_ID>
attribute_mapping_name: <OPTIONAL_MAPPING_NAME>
project_name: <OPTIONAL_TARGET_PROJECT_NAME>
project_domain_name: <OPTIONAL_TARGET_PROJECT_DOMAIN_NAME>
With the clouds.yaml configuration entry as above osc will prompt user to
open a browser with the specially prepared url. It then starts the callback
handler webserver to receive the OIDC authorization code from the direct IdP
interaction. In the next step it exchanges the code for the Keystone session
token.
PassKey (WebAuthN)
A new way of authentication using Security Device (a passkey type) is being added to allow authenticating the user more securely.
Important thing to be mentioned is that Operating System Passkeys (Apple keychain passkey, Google passkey, Microsoft ???) require browser to be running. This makes them unsuitable for the remote access. It is possible to implement client authentication similar to the OIDC login which also requires browser, but it is not implemented now. Therefore only authentication with bare security device (Yubikey or similar) is implemented.
Authenticate with Security Device
sequenceDiagram
participant Authenticator
Client->>Server: Authentication request
Server->>Client: Challenge to be signed
Client->>Authenticator: Challenge
Authenticator->>+Authenticator: Sign with the private key and verify user presence
Authenticator->>Client: Signed Challenge
Client->>Server: Signed Challenge
Server->>Server: Verify signature
Server->>Client: Token
User enumeration prevention
The /auth/passkey/start endpoint must not reveal whether a user exists or
has registered passkeys. When authentication is started for an unknown user
(or a user without passkeys), Keystone responds with a regular challenge
containing deterministic decoy credential IDs (stable per user id) instead
of an error or an empty allow_credentials list. Completing such a ceremony
fails with the same 401 as an attempt against a real user with a credential
that is not in the allow list.
The decoy credential IDs are derived with an HMAC key configured as
[webauthn]fake_credential_hmac_key. The key must stay stable across restarts
and be identical on all nodes of a deployment, otherwise decoys become
distinguishable from real credentials. When unset, a random per-process key is
generated at startup and a warning is logged.
API changes
Few dedicated API resources are added controlling the necessary aspects:
-
/users/{user_id}/passkeys/register_start(initialize registering of the security device of the user) -
/users/{user_id}/passkeys/register_finish(complete the security key registration) -
/users/{user_id}/passkeys/login_start(initialize login of the security device of the user) -
/users/{user_id}/passkeys/login_finish(complete the security key login)
DB changes
Following DB tables are added:
webauthn_credential
#![allow(unused)]
fn main() {
{{#include ../../crates/keystone/src/db/entity/webauthn_credential.rs:9:17}}
}
webauthn_state
#![allow(unused)]
fn main() {
{{#include ../../crates/keystone/src/db/entity/webauthn_state.rs:9:12}}
```
}
API-Key Authentication (SCIM)
API keys give Identity Providers (IdPs) doing SCIM provisioning a static,
long-lived bearer credential that authenticates in a single request, without a
prior /v3/auth/tokens exchange. See
ADR 0021 for the full design and security
rationale; this page covers day-to-day usage.
API keys are domain-owned machine identities, not human user accounts.
They are only accepted on the SCIM sub-router — core /v3//v4 endpoints
reject them outright.
Token format
A generated token looks like:
kscim_{43-char base62 entropy}_{crc32 checksum}
The token is shown to the administrator once, at creation time. Keystone
never stores it in recoverable form — only a lookup_hash (fast SHA-256, used
as the DB index) and a secret_hash (Argon2id, used for verification).
Managing keys
All admin endpoints below require the DomainManager (or SystemAdmin) role
and are authenticated with a normal Fernet token, not an API key.
Create a key
POST /v4/api-keys/
{
"api_key": {
"domain_id": "d1",
"provider_id": "entra-scim",
"expires_at": 1798761600,
"allowed_ips": ["198.51.100.0/24"],
"description": "Entra ID SCIM provisioning"
}
}
Response (201) — this is the only time the raw token is returned:
{
"api_key": { "client_id": "9f2c...", "domain_id": "d1", "provider_id": "entra-scim",
"enabled": true, "created_at": 1751500000, "expires_at": 1798761600,
"allowed_ips": ["198.51.100.0/24"], "description": "Entra ID SCIM provisioning" },
"token": "kscim_9pQ...xz_1a2b3c4d"
}
List / show
GET /v4/api-keys/?domain_id=d1[&enabled=true][&provider_id=entra-scim]
GET /v4/api-keys/{client_id}?domain_id=d1
Neither ever returns secret_hash or lookup_hash.
Update
PUT /v4/api-keys/{client_id}?domain_id=d1
{ "api_key": { "description": "renamed", "allowed_ips": null } }
Fields use nested-Option semantics: an absent field leaves the value
unchanged, null clears it. A revoked key cannot be re-enabled through this
endpoint (409 Conflict on enabled: true) — see Revocation below.
Revoke
POST /v4/api-keys/{client_id}/revoke?domain_id=d1
Soft-revoke only: sets enabled: false, stamps revoked_at/revoked_by,
emits a CADF revoke event. Nothing is hard-deleted (needed for incident
audit trails). Revocation is permanent — the only way back into service is
creating a new key. Physical purge of revoked records happens later via the
janitor (see Configuration below).
Zero-downtime rotation
Create a new key against the same provider_id, update the IdP with the new
token, then revoke the old key once traffic has moved. Both keys resolve
against the same mapping ruleset for provider_id in the interim.
Dry-run: simulate access
POST /v4/api-keys/simulate-access
{ "client_id": "9f2c...", "domain_id": "d1" }
client_id is passed in the body (not the URL) so it doesn’t leak into proxy
access logs. Response shows what the key would resolve to without performing
real authentication:
{
"client_id": "9f2c...", "domain_id": "d1", "provider_id": "entra-scim",
"matched": true,
"scope": { "type": "domain", "domain_id": "d1" },
"roles": ["member"],
"reason": null
}
Using a key
Only the SCIM sub-router accepts API keys, via Authorization: Bearer:
GET /SCIM/v2/{domain_id}/whoami
Authorization: Bearer kscim_9pQ...xz_1a2b3c4d
{ "user_id": "...", "scope": { "type": "domain", "domain_id": "d1" } }
By design, SCIM API keys are domain-scoped only — a key authenticates
only if its mapping ruleset (ADR 0020) resolves to exactly one
domain-scoped authorization for the key’s own domain_id. This is an
allowlist: only a domain scope is accepted, so zero matches, multiple
matches, or a match resolving to any other scope (project, system, or
otherwise) are all rejected.
IP allow-listing
If allowed_ips is set, the request’s effective client IP must fall inside
one of the listed CIDR blocks. The effective IP is the rightmost address in
X-Forwarded-For that isn’t in the configured trusted_proxies, with the raw
TCP peer appended to the right of that chain first — this defeats
leftmost-entry XFF spoofing through an untrusted intermediate proxy. If
allowed_ips is unset, no IP restriction applies.
Configuration ([api_key] in keystone.conf)
| Option | Default | Purpose |
|---|---|---|
argon2_memory_kib | 65536 | Argon2id memory cost |
argon2_time_cost | 3 | Argon2id iterations |
argon2_parallelism | 4 | Argon2id parallelism |
trusted_proxies | (empty) | CSV of CIDRs, e.g. 10.0.0.0/8,192.168.1.0/24 |
rate_limit_burst_size | 10 | Token-bucket burst, keyed on lookup_hash (or source IP for malformed tokens) |
rate_limit_replenish_per_minute | 60 | Token-bucket refill rate |
janitor_inactive_days | 90 | Auto-disable a key unused for this long |
janitor_grace_days | 7 | Extra grace period absorbing async last_used_at write drift |
janitor_tombstone_retention_days | 365 | Hard-purge revoked keys older than this |
Exceeding the rate limit returns 429 Too Many Requests.
Errors
| Condition | Response |
|---|---|
| Malformed token / bad CRC32 | request dropped |
| Unknown / disabled / expired key | 401 (dummy Argon2id hash computed regardless, to avoid timing-based lookup enumeration) |
IP outside allowed_ips | 401 |
| Mapping resolves to zero authorizations | 401 |
| Mapping resolves to more than one authorization | 401 |
| Mapping resolves to any authorization other than a domain scope (project, system, …) | 401 |
| Rate limit exceeded | 429 |
Re-enabling a revoked key via PUT | 409 |
OAuth2 / OIDC Provider — User & Application Guide
Keystone can act as a standards-compliant OAuth2 Authorization Server / OpenID Connect Provider (OP). This means:
- Human users can log in through a browser (Authorization Code + PKCE, or the Device Authorization Grant for CLIs/headless machines) and get a short-lived, self-contained JWT instead of a Fernet token.
- Automated workloads (CI/CD pipelines, Kubernetes controllers, service
accounts) can authenticate with
client_credentialsand call OpenStack APIs directly with the resulting JWT — no Fernet exchange needed. - Third-party applications (Grafana, Harbor, internal portals) can use Keystone as a normal OIDC identity provider (“Login with OpenStack”).
See ADR 0026 for the full design. This page covers the flows you actually call. If you’re operating/deploying the provider rather than consuming it, see the administrator guide.
All endpoints below are under /v4/oauth2/{domain_id}/... — the OP is
per-domain, so domain_id is always part of the path, and each domain has its
own issuer and signing keys.
Discovery
GET /v4/oauth2/{domain_id}/.well-known/openid-configuration
GET /v4/oauth2/{domain_id}/jwks
Both are unauthenticated. Point any standard OIDC library at the discovery
document and it will find authorization_endpoint, token_endpoint,
jwks_uri, supported grant types, and scopes.
Scopes
openid,profile,email— standard OIDC identity scopes.openstack:api— a distinct, explicit scope. Only when this is requested and granted does the returnedaccess_tokencarry OpenStack authorization data (openstack_context: scope + effective roles) and anaudthat OpenStack services will accept (openstack-apis:{domain_id}). Without it, you get a minimal identity token good only for calling Keystone’s own/userinfo— not usable against Nova/Neutron/etc.- Omitting
scopeentirely defaults to the client’s fullallowed_scopes— exceptopenstack:apiis never implied by omission; you must request it explicitly every time.
Machine-to-machine: client_credentials
For CI/CD, Kubernetes operators, Terraform controllers, and any workload holding a registered client secret.
POST /v4/oauth2/{domain_id}/token
Content-Type: application/x-www-form-urlencoded
grant_type=client_credentials&client_id=<id>&client_secret=<secret>&scope=openstack:api
Response:
{
"access_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 900,
"scope": "openstack:api"
}
Use access_token directly as Authorization: Bearer <token> against any
OpenStack service running the native JWT middleware. No id_token is issued for
this grant.
Human login: Authorization Code + PKCE
For browser-based apps and CLIs that can open a browser.
-
Redirect the user to:
GET /v4/oauth2/{domain_id}/authorize ?response_type=code &client_id=<id> &redirect_uri=<your callback> &scope=openid profile openstack:api &state=<random> &code_challenge=<S256 PKCE challenge> &code_challenge_method=S256PKCE (
S256only) is mandatory for public clients. Keystone serves its own login and consent pages. -
On success, your
redirect_urireceives?code=...&state=.... Exchange the code:POST /v4/oauth2/{domain_id}/token Content-Type: application/x-www-form-urlencoded grant_type=authorization_code&code=<code>&redirect_uri=<same as above> &code_verifier=<PKCE verifier>&client_id=<id>[&client_secret=<secret>]Response includes
access_token,id_token,expires_in, and (if the client is registered forrefresh_token) arefresh_token. -
Refresh when the access token expires:
grant_type=refresh_token&refresh_token=<token>&client_id=<id>Refresh tokens rotate on every use (a new one is returned each time; the old one becomes invalid). Do not reuse an old refresh token — presenting an already-used one is treated as a possible theft and revokes the entire token family, forcing a fresh login.
CLI / headless login: Device Authorization Grant (RFC 8628)
For openstack/osc CLI and other headless clients, the same flow every major
cloud CLI (aws sso, gcloud, az) uses.
-
Start the flow:
POST /v4/oauth2/{domain_id}/device_authorization Content-Type: application/x-www-form-urlencoded client_id=<id>&scope=openid profile openstack:apiResponse:
{ "device_code": "...", "user_code": "WDJB-MJHT", "verification_uri": "https://keystone.example.com/v4/oauth2/<domain_id>/device", "verification_uri_complete": "https://keystone.example.com/v4/oauth2/<domain_id>/device?user_code=WDJB-MJHT", "expires_in": 600, "interval": 5 } -
Show the user
verification_uri_complete(orverification_uri+user_code) and have them approve it in a browser. -
Poll for the token:
grant_type=urn:ietf:params:oauth:grant-type:device_code &device_code=<device_code>&client_id=<id>Poll no faster than
intervalseconds — polling too fast returnsslow_downper RFC 8628 §3.5, which means “back off further,” not a hard failure.user_codeuses an unambiguous character set ([A-Z0-9]minusO/0/I/l/1) so it’s easy to type by hand.
Token types you’ll see
id_token— identity only,audis yourclient_id. Never carries roles or OpenStack scope; only your client’s configuredclaims_templateoutput is added.access_token(openstack:apigranted, or anyclient_credentialsgrant) — carriesopenstack_context(scope + effective roles at issuance time) andaud: "openstack-apis:{domain_id}". This is what OpenStack services accept.access_token(openstack:apinot granted) — minimal,audis your ownclient_id, not usable against any OpenStack service. Good only for/userinfo.
Access and ID tokens are short-lived (15 minutes by default) and stateless
bearer tokens — there’s no server-side revocation for them short of waiting
out exp (or an operator triggering emergency signing-key rotation, which is
out of your hands as a client). Treat them like any other bearer credential:
don’t log them, don’t put them in URLs.
Errors
Token endpoint errors follow RFC 6749 §5.2:
{ "error": "invalid_grant", "error_description": "..." }
Common ones: invalid_client (bad client_id/secret), invalid_grant
(expired/used code, revoked refresh token, wrong PKCE verifier), invalid_scope
(requested a scope outside allowed_scopes — the server never silently narrows
a request), slow_down / authorization_pending (device flow polling).
OAuth2 / OIDC Provider — Administrator Guide
This page covers day-to-day operation of the native OAuth2 Authorization Server / OpenID Connect Provider (OP): configuration, client registration, key rotation (including emergency rotation), and the downstream middleware that lets Nova/Neutron/etc. accept OP-issued JWTs directly. See ADR 0026 for the full design rationale and threat model; this page is the operational surface on top of it.
For end-user/application-developer facing flows (login, token requests, device code), see the OAuth2 / OIDC user guide.
Concepts
- Every domain owns its own independent signing keypair — there is no
cluster-wide key. Issuer, JWKS, and discovery are all per-domain:
GET /v4/oauth2/{domain_id}/jwks,GET /v4/oauth2/{domain_id}/.well-known/openid-configuration. - A domain created through
POST /v3/domainsgets its signing key automatically (provisioned byOauth2KeyHookon the domain-create event). A domain provisioned any other way — most notably thedefaultdomain, which is seeded directly into the database at bootstrap and never fires that hook — has no signing key and/jwks//token/discovery will fail for it until you provision one (seeensure-signing-keybelow). - Tokens are stateless JWTs (
id_token,access_token) signed ES256 by default (RS256configurable). Refresh tokens are the one stateful piece — rotating, family-tracked, stored in Raft + FjallDB. OAuth2Clientregistrations (relying parties / machine identities) are a fourth ADR 0020 provider resource, domain-owned, managed via the/v4/oauth2/{domain_id}/clientsCRUD API below.
Configuration ([oauth2] in keystone.conf)
| Option | Default | Purpose |
|---|---|---|
signing_algorithm | ES256 | ES256 or RS256. Governs both outbound signing and inbound verification — must match across a deployment. |
signing_key_rotation_days | 90 | Automatic rotation cadence. Manual rotation via keystone-manage oauth2 rotate-signing-key is always available regardless of this value. |
argon2_memory_kib | 65536 | Argon2id memory cost for confidential-client secret hashing. |
argon2_time_cost | 3 | Argon2id iterations. |
argon2_parallelism | 4 | Argon2id lanes. |
access_token_lifetime_minutes | 15 | access_token TTL. |
id_token_lifetime_minutes | 15 | id_token TTL. |
authorization_code_lifetime_seconds | 60 | Single-use authorization code TTL. |
refresh_token_lifetime_days | 30 | Idle lifetime of a refresh token family; reset on each rotation. |
refresh_token_reuse_grace_minutes | 10 | Grace window before a reused refresh token is treated as a breach (family revoked). 0 = tightest detection, most multi-device false positives. |
pre_auth_session_lifetime_minutes | 10 | Pre-authentication browser session TTL for the login/consent sequence. |
device_code_lifetime_minutes | 10 | RFC 8628 device_code/user_code TTL. |
device_code_poll_interval_seconds | 5 | Minimum interval between /token polls for a device_code. |
token_rate_limit_burst_size | 10 | /token rate-limit burst, keyed on unverified client_id. |
token_rate_limit_replenish_per_minute | 60 | /token sustained rate after burst is exhausted. |
Exceeding a rate limit returns 429 Too Many Requests.
Provisioning a domain’s signing key
keystone-manage oauth2 ensure-signing-key --domain <domain_id>
Idempotent — a no-op if the domain already has a key. Run this once for any
domain that did not go through POST /v3/domains (notably default after a
legacy bootstrap). Normal domain creation via the API does this automatically;
you only need the CLI for out-of-band-provisioned domains.
Client registration (relying parties & machine identities)
All admin endpoints below require SystemAdmin/domain-manager Tier 1/Tier 2
gating per ADR 0020 §9.A and are authenticated with a normal Keystone token, not
an OAuth2 access token.
POST /v4/oauth2/{domain_id}/clients
GET /v4/oauth2/{domain_id}/clients
GET /v4/oauth2/{domain_id}/clients/{provider_id}
PUT /v4/oauth2/{domain_id}/clients/{provider_id}
POST /v4/oauth2/{domain_id}/clients/{provider_id}/rotate-secret
DELETE /v4/oauth2/{domain_id}/clients/{provider_id}
- Confidential clients get a one-time plaintext
client_secretin thecreate/rotate-secretresponse body — it is never stored or returned again, only its Argon2id hash. provider_idis unique within a domain;client_idis server-generated and globally unique (it’s the sole key presented at/token, beforedomain_idis known).client_id,provider_id,domain_idare immutable after creation.- Setting
pre_authorized: true(skips user consent for trusted first-party device-code clients) requiresSystemAdminregardless of the Tier 2 self-service path otherwise available on this endpoint, and is rejected together withopenstack:apiinallowed_scopes(a pre-authorized client cannot silently gain OpenStack authorization). DELETErevokes the client and immediately invalidates all refresh tokens in its family tree. Outstanding bearer access/id tokens remain valid until naturalexp— for immediate access-token invalidation on a compromised client, use emergency signing-key rotation instead.- Every create/update/delete/rotate-secret call emits a CADF audit event.
Signing key rotation
Normal rotation
keystone-manage oauth2 rotate-signing-key --domain <domain_id>
Generates a fresh keypair, commits it via Raft, promotes it to Primary/Active,
and demotes the prior Primary to Previous. The Previous key stays
published on JWKS for one full token max-lifetime after demotion so in-flight
tokens keep verifying, then a background janitor removes it. Also runs
automatically every signing_key_rotation_days.
Emergency rotation (suspected/confirmed key compromise)
Emergency rotation requires dual control: the initiating operator stages the rotation, and a different operator must confirm it within 15 minutes or it auto-aborts (recorded in the audit log either way).
# Operator A, over admin UDS + SPIFFE mTLS:
keystone-manage oauth2 rotate-signing-key --domain <domain_id> --emergency
This prints a rotation_id and expires_at. A second operator then runs:
keystone-manage oauth2 confirm-rotate-signing-key \
--domain <domain_id> --rotation-id <rotation_id> \
--revoke-jti <jti-1> --revoke-jti <jti-2> ...
What happens:
- A fresh keypair is generated and committed via Raft, promoted directly to
Primary/Active— no grace-window overlap with the compromised key. - The compromised key is marked
revoked, not removed from JWKS outright (removing it would invalidate every outstanding token signed by it — a domain-wide denial of service). Instead itsjtis are published on a dedicated revocation list:GET /v4/oauth2/{domain_id}/jwks/revocation. --revoke-jtiis how you seed that list: pass everyjtiyou already know was minted during the compromise window. This is currently manual — the operator must supply known-suspect JTIs by hand; there is no automatic audit-log-derived backfill yet (see the companion ADR amendment tracking this gap).- The downstream middleware (below) checks this list on every token verification and fails closed if the endpoint is unreachable.
- A distinct CADF event (
OAUTH2_EMERGENCY_KEY_ROTATION) is recorded withdomain_id, revokedkid, newkid, operator identity, and the fullrevoked_jtislist.
Normal rotation cadence resumes afterward; the signing_key_rotation_days timer
resets.
Note: the stage and confirm steps above go through the normal Raft-backed
HTTP path (admin UDS + SPIFFE mTLS to
/v4/oauth2/{domain_id}/rotate-signing-key), which requires Raft quorum to
commit. If the cluster has lost quorum at the same moment a key is compromised,
use the quorum-bypass path below instead.
Emergency signing key rotation during quorum loss
See ADR 0028 and the admin guide’s Quorum-Bypass Emergency Rotation section for the full node-local mechanism (guardrail, gossip, scope limits) shared with DEK rotation. This section covers the OAuth2-specific commands.
Requires [local_emergency] enabled = true on the responding node, and the Raft
leader must have been unknown for at least leaderless_grace_period_seconds
(guardrail refuses otherwise).
1. Stage — during quorum loss, on a guardrail-enabled node:
keystone-manage oauth2 rotate-signing-key \
--domain <domain_id> --local-quorum-bypass \
--justification "suspected key compromise, quorum lost"
Writes a rotation candidate to that node’s local Fjall keyspace only — never
touches Raft. A background sweep gossips it (best-effort) to reachable peers
every gossip_interval_seconds, marking it conflicted: true on any node where
a different active candidate already exists for the same domain.
2. List candidates — once quorum returns, on every node that may have been reached during the outage:
curl -X GET https://keystone:5000/v4/oauth2/<domain_id>/local-emergency-candidates \
-H "X-Auth-Token: $ADMIN_TOKEN"
CLI equivalent:
keystone-manage oauth2 list-local-emergency-candidates --domain <domain_id>.
Check conflicted before choosing a rotation_id — true means gossip saw a
different candidate elsewhere and you must decide deliberately which one wins.
3. Reconcile — a different operator than the one who staged it, against the specific node holding the chosen candidate (reconciliation does not fan out cluster-wide):
keystone-manage oauth2 reconcile-local-emergency-key \
--domain <domain_id> --rotation-id <rotation_id>
Promotes the candidate’s key to Primary via the same Raft transaction path
normal rotation uses (requires quorum), demotes the prior Primary to
Previous, clears the candidate on this node, and revokes any other active
candidate for the domain on this node. Rejects if the confirming operator
matches the initiator (dual-control) or if the candidate was already revoked.
Emits OAUTH2_LOCAL_EMERGENCY_KEY_RECONCILED (CADF) with a
_local:emergency:audit:<rotation_id> pointer recorded in the local emergency
store back to the event — staging itself is not audited (consistent with the
ordinary emergency path’s stage/confirm asymmetry); only reconciliation is.
Downstream control-plane enforcement (Nova/Neutron/etc.)
A thin Python WSGI middleware (KeystoneNativeJwtMiddleware) drops into
existing Paste Deploy pipelines (e.g. /etc/nova/api-paste.ini) in front of
keystonemiddleware.auth_token. Requests without an OP-issued Bearer JWT fall
through unchanged to the legacy Fernet filter chain, so rollout is incremental
per service/region with instant rollback (just remove the filter).
Required config per service:
keystone_jwks_url = https://keystone.example.com/v4/oauth2/<domain_id>/jwks
keystone_jwt_jti_revocation_url = https://keystone.example.com/v4/oauth2/<domain_id>/jwks/revocation
keystone_domain_id = <domain_id>
keystone_expected_issuers = https://keystone.example.com/v4/oauth2/<domain_id>
signing_algorithm = ES256
Operational notes:
- Fail-closed. Both the JWKS fetch and the JTI-revocation fetch reject the
request on failure rather than serving stale data — a Keystone or network
outage now also blocks OpenStack API calls, not just token issuance. This is
deliberate: fail-open would let an attacker who can interfere with the
middleware’s connectivity keep an already-revoked compromised key validating
for the outage’s duration. Both endpoints must be treated as load-bearing for
the whole control plane.
- JWKS cache TTL: 300s (matches
Cache-Control: max-age=300on/jwks). - Revocation list cache TTL: 60s.
- JWKS cache TTL: 300s (matches
audis domain-bound (openstack-apis:{domain_id}), never a flat cluster-wide value — a compromised domain key only forges tokens accepted within that domain’s own blast radius.- Set
keystone_expected_issuersexplicitly; claim presence ofissalone is not enough, the value is checked against this allowlist.
Migration from Fernet
Everything here is additive — Fernet issuance/validation continues unchanged. See ADR 0026 §13 for the staged migration path (Fernet interchangeability → JWS format parity → OP goes live → machine identity migration → human flow migration → Fernet sunset). Key operational gate: a service may only prefer JWTs over falling through to Fernet once its operator has explicitly accepted the 15-minute stateless revocation window (or wired back-channel introspection for high-criticality operations) — record that acceptance in your deployment’s migration runbook.
Known gaps
- Audit-log-derived JTI backfill —
--revoke-jtiis manual only. Auto-populating the revocation list from a time window against the audit trail needs a queryable audit-log store that does not exist yet. - Quorum-bypass local-emergency rotation (ADR 0028, gap noted in ADR 0026 §3) is implemented — see “Emergency signing key rotation during quorum loss” above. Deliberate scope limits: no cross-node broadcast to clear a superseded candidate, no unattended reconciliation sweep, per-node (not cluster-wide) reconciliation.
Troubleshooting
| Symptom | Likely cause |
|---|---|
/jwks or /.well-known/openid-configuration returns 404 | Domain has no signing key — run keystone-manage oauth2 ensure-signing-key --domain <id> |
429 on /token | Rate limit hit — see token_rate_limit_* config |
429 on /authorize, /device, /device/login, or /device_authorization | Global per-IP limiter hit — see [rate_limit_global_ip] |
confirm-rotate-signing-key fails with “rotation not found/expired” | The 15-minute confirmation window elapsed and the rotation auto-aborted; re-run rotate-signing-key --emergency |
| Downstream service rejects all OP tokens after Keystone/network blip | Expected fail-closed behavior — check JWKS/revocation endpoint reachability from the service |
rotate-signing-key --local-quorum-bypass refused | [local_emergency] enabled = false on that node, or leader has not been unknown long enough (leaderless_grace_period_seconds) |
reconcile-local-emergency-key fails with dual-control error | Confirming operator matches the one who staged the candidate — use a different operator |
SCIM v2 Provisioning: Administrator Guide
This page is for Keystone operators and domain managers who need to enable an enterprise Identity Provider (Okta, Entra ID, Workday, …) to push user/group lifecycle events into a Keystone domain via SCIM. For the protocol-level reference (endpoints, filter grammar, RFC 7644 compatibility matrix) aimed at whoever configures the IdP side, see RFC 7644 Compatibility. For the full design rationale, see ADR 0024.
Concept
SCIM provisioning is a distinct concern from authentication. It manages the
existence and attributes of real, persistent User/Group rows in a domain;
how those same accounts later log in (password, OIDC, passkey) is unrelated. A
domain can register any number of independent realms, each identified by a
(domain_id, provider_id) pair, so more than one authoritative source (e.g. an
Okta tenant for full-time employees and a Workday feed for contractors) can
provision into the same domain without either one able to see, rename, or delete
the other’s records or a human administrator’s manually created accounts.
Every realm is linked to a federation IdentityProvider. If a person is
provisioned ahead of time via SCIM and later authenticates for the first time
through that same IdP, the account converges onto the same User row a
federated JIT login would have created — no duplicate accounts.
Prerequisites
A SCIM realm requires an existing federation IdentityProvider in the target
domain. If you haven’t set one up yet, see Federation
first. The IdentityProvider doesn’t need to be usable for interactive login
yet — SCIM just needs its id to exist so realm registration can resolve
idp_id.
Setup walkthrough
Four steps: register the realm, grant it a role via a mapping rule, mint an API key, then hand the base URL and key to the IdP’s SCIM connector.
1. Register the SCIM realm
Requires the manager role scoped to the target domain, or admin.
POST /v4/scim_realms/
{
"scim_realm": {
"domain_id": "d1",
"provider_id": "entra-scim",
"idp_id": "entra-idp-1",
"display_name": "Entra ID SCIM provisioning"
}
}
provider_id is an arbitrary operator-chosen coordinate — it doesn’t need to
match the IdentityProvider.id, though reusing a recognizable name (as above)
keeps audit logs readable. idp_id must resolve to an existing
IdentityProvider in domain_id, checked at both create and update time (404
otherwise). Response is 201 with the created ScimRealmResource including
enabled: true.
Realms have no DELETE — see
Deprovisioning & retention for how to turn one
off.
2. Grant the realm a provisioning role
A realm authorizes SCIM traffic (the Realm Activation Gate, below), but
authorizing specific Users/Groups operations against it is separate, and
happens the same way all API-key ingress traffic is authorized: via a
mapping ruleset matched on IdentitySource::ApiClient.
ApiClientResource carries no Role/RoleAssignment at all — every role a
SCIM request is evaluated against comes entirely from
Authorization::Domain{roles} produced by this ruleset at request time.
{
"mapping_ruleset": {
"mapping_id": "entra-scim-mapping",
"domain_id": "d1",
"source": { "type": "api_client", "provider_id": "entra-scim" },
"domain_resolution_mode": "fixed",
"enabled": true,
"rules": [
{
"name": "entra-scim-rule",
"match": { "all_of": [] },
"identity": {
"user_name": "${claims.api_client.client_id}"
},
"authorizations": [
{
"type": "domain",
"domain_id": "d1",
"roles": [{ "name": "scim_provisioner" }]
}
]
}
]
}
}
The role string must be one of admin, manager, or scim_provisioner (see
Authorization below) and must resolve against an actual Role
— mapping rule create/update rejects an unresolvable RoleRef with 422.
scim_provisioner is the narrowest of the three and the recommended choice for
a machine-provisioning integration; create it once per domain if it doesn’t
already exist (POST /v4/roles).
The write-time ruleset constraint from ADR 0021/0024 applies here: since this
ruleset shares its provider_id coordinate with the realm, an
Authorization::Project rule can never be added to it — the Mapping Engine CRUD
API rejects that with 422 Unprocessable Entity. A SCIM realm’s ruleset may
only ever resolve Authorization::Domain.
3. Mint an API key
See API-Key Authentication for the full lifecycle (rotation, revocation, IP allow-listing). In short:
POST /v4/api-keys/
{
"api_key": {
"domain_id": "d1",
"provider_id": "entra-scim",
"description": "Entra ID SCIM provisioning"
}
}
The response’s token field (kscim_...) is shown once — this is the
bearer credential the IdP’s SCIM connector will use.
4. Configure the IdP’s SCIM connector
Point the connector at:
Base URL: https://<your-keystone-host>/SCIM/v2/d1
Auth type: Bearer Token
Token: kscim_9pQ...xz_1a2b3c4d
The connector should discover its capabilities from
GET {base}/ServiceProviderConfig — see
RFC 7644 Compatibility for what it will find (and what it
won’t: bulk, sort, and changePassword are all honestly advertised as
unsupported).
Realm Activation Gate
Every /SCIM/v2/{domain_id}/Users|Groups request first resolves the
authenticating API key’s provider_id and looks up the matching realm. If no
realm is registered for that coordinate, or it’s enabled: false, the request
is rejected with 403 Forbidden before touching any User/Group storage —
independent of whatever role the mapping ruleset would otherwise grant.
Realm-level activation and per-operation role authorization are two separate
gates; both must pass.
ScimRealmAuth additionally requires the resolved scope to be domain-scoped and
match the URL’s {domain_id} exactly. A key whose mapping resolves to a project
scope, or a {domain_id} mismatch, gets 403 on every /Users//Groups route
(the whoami diagnostic route is exempt).
Realm management
GET /v4/scim_realms/?domain_id=d1
GET /v4/scim_realms/{domain_id}/{provider_id}
PATCH /v4/scim_realms/{domain_id}/{provider_id}
PATCH can update idp_id, display_name, and enabled — set
enabled: false to immediately stop a realm’s traffic (see below) without
deleting anything. There is no realm DELETE; disabling is the supported way to
turn one off, keeping its provisioned resources and audit trail intact.
Authorization
Realm CRUD (POST/GET/PATCH /v4/scim_realms) is invoked by a normal
Fernet-authenticated human operator and requires manager (domain-scoped) or
admin:
identity/scim_realm/create,identity/scim_realm/list,identity/scim_realm/show,identity/scim_realm/disable,identity/scim_realm/purge
SCIM resource CRUD (Users/Groups) is invoked exclusively via API-key ingress and
evaluated against the roles the realm’s own mapping ruleset produces (step 2
above — never a real RoleAssignment):
identity/scim/user/{create,list,show,update,delete}identity/scim/group/{create,list,show,update,delete}
Each of these accepts admin, manager (domain-scoped), or scim_provisioner
(domain-scoped).
Deprovisioning & retention
-
DELETE /Users/{id}never hard-deletes: it disables the user (enabled: false), stamps the SCIM index as deprovisioned, and revokes all live sessions immediately. SubsequentGET/PUT/PATCHagainst thatidfrom the owning realm return404. -
DELETE /Groups/{id}immediately strips the group’s role assignments (closing the live authorization surface) and tombstones it the same way, but retains its membership snapshot for forensic purposes until purge. -
A background janitor permanently deletes tombstoned rows (and, for Groups, their retained membership) once
[scim_resource] janitor_deprovisioned_retention_dayshas elapsed since deprovisioning (see Configuration). -
For a verified erasure request that can’t wait for the retention window, an operator (
manager/admin) can force an immediate purge of one resource:DELETE /v4/scim_realms/{domain_id}/{provider_id}/purge/{resource_type}/{keystone_id}This refuses to purge a resource that isn’t already deprovisioned — soft-delete it via SCIM first.
Configuration
[scim_realm] in keystone.conf:
| Option | Default | Purpose |
|---|---|---|
driver | raft | Storage driver for realm records |
[scim_resource] in keystone.conf:
| Option | Default | Purpose |
|---|---|---|
driver | raft | Storage driver for the resource ownership index |
janitor_deprovisioned_retention_days | 365 | Days a tombstoned User/Group is retained before the janitor purges it |
Set janitor_deprovisioned_retention_days well below the default for
deployments under GDPR or a comparable regime that can’t justify a full year of
PII retention purely for a forensic snapshot — including near-zero, if your
compliance posture requires it. Use the operator-triggered purge-now path above
for a specific already-received erasure request rather than lowering the global
default.
Auditing
Every SCIM write emits a CADF event (Create/Update/Disable), with
target.type_uri of data/security/account (User) or data/security/group
(Group), and realm_provider_id/external_id captured on the event for
cross-referencing against the IdP’s own provisioning logs.
Correlation caveat: initiator.id is derived from the authenticating API
key’s client_id, not the realm’s provider_id. Across a zero-downtime key
rotation (rotating to a new key under the same provider_id, see
API-Key Authentication), initiator.id changes even though
the realm performing the action hasn’t. Build SIEM/alerting correlation on the
realm_provider_id attachment field, not initiator.id, for SCIM traffic.
Troubleshooting
| Symptom | Likely cause |
|---|---|
403 on every /Users//Groups request | Realm not registered for this (domain_id, provider_id), or enabled: false — check step 1 |
403 with a valid, enabled realm | API key’s mapping resolved to project scope, or {domain_id} in the URL doesn’t match the key’s scope |
401 from the SCIM connector before any SCIM request | API-key-level auth failure — see API-Key Authentication troubleshooting |
Individual operations (e.g. create) return 403/policy error | Mapping ruleset doesn’t emit a role in {admin, manager, scim_provisioner} — check step 2 |
422 when writing the mapping rule | Role name doesn’t resolve to an existing Role, or the rule tries to add an Authorization::Project entry to a realm-linked ruleset |
409 uniqueness on user/group create | userName/displayName/externalId already exists domain-wide, or under this realm — expected, not a bug |
Newly-created resource immediately 404s | Deprovisioned already (unlikely on create), or the request is coming through a different realm than the one that created it — see Compatibility: Ownership |
SCIM v2 Protocol Reference & RFC 7644 Compatibility
This page is a protocol-level reference for whoever configures the SCIM side of an Identity Provider connector (Okta, Entra ID, Workday, …) against Keystone, and a compatibility matrix against RFC 7644 for anyone evaluating whether Keystone’s SCIM implementation fits their IdP’s requirements. For how to register a realm and grant it access, see the Administrator Guide. For full design rationale, see ADR 0024.
Keystone implements a deliberately restricted subset of RFC 7644, not full
compliance — the restrictions exist to bound worst-case query/PATCH complexity
per request (the same posture applied elsewhere in this codebase to claim
mapping and rate limiting). Most enterprise IdPs, including Okta and Entra ID,
tolerate a narrower filter grammar and bulk.supported: false without issue;
check the matrix below against your specific connector before assuming a feature
works.
Base URL & Authentication
https://<host>/SCIM/v2/{domain_id}/...
Authorization: Bearer kscim_...
Every request is scoped to one domain and authenticates with a bearer API key
(see API-Key Authentication) — never a Fernet token. The key
must belong to an active, registered realm for that (domain_id, provider_id)
coordinate, or every request gets 403 regardless of role (see
Realm Activation Gate).
Content Negotiation
Request bodies (POST/PUT/PATCH carrying a payload) must declare
Content-Type: application/scim+json or, for connectors that only speak plain
JSON, application/json — either is accepted. Anything else, or a missing
header on a request that does carry a body, is rejected with
415 Unsupported Media Type. Every response carries
Content-Type: application/scim+json, including error responses.
Discovery
GET /SCIM/v2/{domain_id}/ServiceProviderConfig
GET /SCIM/v2/{domain_id}/Schemas
GET /SCIM/v2/{domain_id}/ResourceTypes
Unauthenticated within the SCIM sub-router (bearer auth is still accepted, just
not required) — most connectors probe these before presenting credentials.
ServiceProviderConfig honestly advertises what’s not supported rather than
claiming full compliance:
{
"patch": { "supported": true },
"bulk": { "supported": false, "maxOperations": 0, "maxPayloadSize": 0 },
"filter": { "supported": true, "maxResults": 200 },
"changePassword": { "supported": false },
"sort": { "supported": false },
"etag": { "supported": true },
"authenticationSchemes": [{ "type": "oauthbearertoken", "primary": true }]
}
Resource Endpoints
POST /SCIM/v2/{domain_id}/Users GET .../Users GET .../Users/{id}
PUT /SCIM/v2/{domain_id}/Users/{id} PATCH .../Users/{id} DELETE .../Users/{id}
POST /SCIM/v2/{domain_id}/Groups GET .../Groups GET .../Groups/{id}
PUT /SCIM/v2/{domain_id}/Groups/{id} PATCH .../Groups/{id} DELETE .../Groups/{id}
An HTTP method not mapped for a given path (e.g. POST .../Users/{id}, or any
method on /ServiceProviderConfig other than GET) returns
405 Method Not Allowed.
Ownership fencing
A resource is only visible to the realm that created it. GET/PUT/PATCH/
DELETE against an id owned by a different realm — or a same-ID resource that
doesn’t exist at all — both return an identical 404, by design: this prevents
realm-boundary probing via response-shape differences.
POST — required fields, schemas validation
The request body’s schemas array must contain the resource’s core schema URI
(urn:ietf:params:scim:schemas:core:2.0:User / ...:Group) — a missing or
mismatched schemas array is rejected with 400 invalidValue. For Users,
externalId is additionally mandatory (400 if empty/absent) and drives
deterministic id derivation so a later federated JIT login for the same IdP
sub claim converges onto the same account rather than creating a duplicate.
Response meta
Every response carries meta.location (an absolute URL), and 201 Created
responses on POST additionally carry an HTTP Location header matching it.
RFC 7644 doesn’t mandate the header outside 201; the body field is present on
every response regardless.
Attribute Mapping (User)
| SCIM attribute | Keystone field |
|---|---|
id | deterministic (domain_id + externalId) |
externalId | realm-scoped ownership index |
userName | name |
active | enabled |
name.givenName / name.familyName | extension attributes |
emails[primary eq true].value | extension attribute |
displayName | extension attribute |
Attribute Mapping (Group)
| SCIM attribute | Keystone field |
|---|---|
id | server-assigned |
externalId | realm-scoped ownership index |
displayName | name |
members | resolved membership, capped at 1000 entries, must reference Users owned by the same realm |
A members entry referencing a user owned by a different realm, or a
manually-created user with no SCIM ownership record at all, is rejected with
400 invalidValue on both PUT and PATCH add.
DELETE semantics
Neither Users nor Groups are hard-deleted by DELETE. A User is disabled and
its sessions revoked; a Group has its role assignments immediately stripped
(closing live authorization) while its membership snapshot is retained for a
forensic window. Both become invisible to subsequent GET/PUT/PATCH/List
(404) immediately. See
Deprovisioning & retention for the
retention window and operator purge-now path — this deviates from a strict
reading of RFC 7644 §3.6, which doesn’t distinguish soft- from hard-delete.
Filtering
filter := term (LOGICAL_OP term)* # "and"/"or" MUST NOT be mixed in one filter string
term := ATTR OP value
OP := eq | ne | co | sw | pr
No nested/parenthesized expressions, no complex-attribute filters
(emails[type eq "work"]). A filter string over 512 bytes or 8 terms is
rejected. Violations return 400 invalidFilter.
| User attribute | Allowed operators |
|---|---|
userName | eq, ne, co, sw, pr |
externalId | eq, ne, pr |
id | eq, pr |
active | eq, pr |
| Group attribute | Allowed operators |
|---|---|
displayName | eq, ne, co, sw, pr |
externalId | eq, ne, pr |
id | eq, pr |
Pagination
startIndex (1-based, default 1), count (default and max 200). No
cursor/continuation token — a bounded scan over the realm’s own resource set,
excluding deprovisioned entries.
PATCH
Operations: [{op, path, value}], add/replace/remove only, restricted to
these top-level scalar paths:
| Resource | Patchable paths |
|---|---|
| User | active, userName, displayName, externalId, name.givenName, name.familyName |
| Group | displayName, externalId, members (add/remove only — no replace) |
id and meta are real SCIM attributes but always immutable — a PATCH naming
either returns 400 mutability (distinct from a path that isn’t a recognized
attribute at all, which returns 400 invalidPath). Any other unrecognized path,
an array-index path, or a complex filter expression
(emails[type eq "work"].value) also returns 400 invalidPath. PUT performs
a full declarative replace, including a full membership resync for Groups.
ETags & Concurrency
GET/PUT/PATCH/POST responses carry a weak ETag: W/"<version>". Send
If-Match: W/"<version>" on PUT/PATCH to get an atomic compare-and-swap; a
stale version returns 412 Precondition Failed (no response body). This closes
the lost-update race for concurrent push-group syncs from a single IdP without
needing a distributed lock.
/Bulk and /Me
Both return 501 Not Implemented with a SCIM-shaped error body rather than a
generic 404 — RFC 7644 clients commonly probe these before falling back.
Neither is planned for this subset (see
Explicitly out of scope below).
Errors
Standard envelope:
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:Error"],
"status": "409",
"scimType": "uniqueness",
"detail": "userName already exists within this domain"
}
| Condition | HTTP status | scimType |
|---|---|---|
| Realm not registered / disabled | 403 | (no body) |
| Resource not owned by caller’s realm | 404 | (no body) |
userName/displayName/externalId collision | 409 | uniqueness |
Missing/wrong request schemas, cross-realm membership reference, invalid PATCH value | 400 | invalidValue |
| Disallowed filter attribute/operator/mixed chain/oversized | 400 | invalidFilter |
Disallowed or unrecognized PATCH path | 400 | invalidPath |
PATCH targeting a real but immutable path (id, meta) | 400 | mutability |
| Malformed JSON request body | 400 | invalidSyntax |
Unsupported/missing Content-Type on a bodied request | 415 | (no body) |
| Unmapped HTTP method on a mapped path | 405 | (no body) |
If-Match version mismatch | 412 | (no body) |
/Bulk, /Me | 501 | (no body) |
noTarget, tooMany, invalidVers, and sensitive are defined by RFC 7644
§3.12 but never emitted — see the compatibility matrix below for why.
Explicitly out of scope
/Bulk, sortBy/sortOrder, arbitrary filter/PATCH path expressions
(emails[type eq "work"]), and multi-valued complex-attribute addressing.
Extending any of these requires a ratifying ADR 0024 revision given their
DoS/complexity surface.
RFC 7644 Compatibility Matrix
| RFC 7644 § | Feature | Status | Notes |
|---|---|---|---|
| §3.1 | HTTP methods, Content-Type, Location header | Supported | Accepts application/json in addition to application/scim+json |
| §3.2 | Discovery (ServiceProviderConfig/Schemas/ResourceTypes) | Supported | Honestly advertises unsupported features rather than over-claiming |
| §3.3 | POST (create) | Supported | schemas and (User) externalId are mandatory |
| §3.4.2 | Filtering | Partial | Restricted attribute/operator allowlist, homogeneous and/or only, no nesting — see Filtering |
| §3.4.2.2 | Alternative search (POST .search) | Not supported | — |
| §3.4.3 | Pagination | Supported | Offset/count only, no continuation token |
| §3.4.4 | Sorting | Not supported | sort.supported: false in discovery |
| §3.4.5 | attributes/excludedAttributes query params | Not supported | Always returns the full mapped resource |
| §3.5.1 | PUT (replace) | Supported | Full declarative replace, incl. Group membership resync |
| §3.5.2 | PATCH (modify) | Partial | Scalar top-level path allowlist only — see PATCH |
| §3.6 | DELETE | Partial | Soft-delete/tombstone semantics, not hard delete — see DELETE semantics |
| §3.7 | Bulk operations | Not supported | Explicit 501, not a bare 404 |
| §3.11 | /Me | Not supported | Explicit 501 — no “current resource” concept for an API-key-authenticated client |
| §3.12 | Error response format | Partial | Envelope always present; only 6 of 10 defined scimTypes are emitted — see Errors |
| §3.14 | ETag / conditional requests | Supported | Weak ETags, atomic compare-and-swap on If-Match |
| §4 (core schema) | User resource attributes | Partial | Core identity + name/email/displayName only — see Attribute Mapping |
| §4 (core schema) | Group resource attributes | Partial | displayName + members only — see Attribute Mapping |
| §4 (core schema) | Extension schemas (Enterprise User, etc.) | Not supported | — |
| §7 (multi-tenancy) | Tenant isolation | Supported (different mechanism) | Domain + realm scoping instead of a tenant attribute — see Ownership fencing |
| §8 (security) | Bearer token auth | Supported | Domain-scoped API keys (ADR 0021), not OAuth2 |
Kubernetes TokenReview Authentication (k8s_auth)
The Kubernetes authentication method validates service account tokens and resolves identities through the unified mapping engine. It eliminates the need for plain-text OpenStack credentials in Kubernetes workloads by leveraging the Kubernetes TokenReview endpoint for JWT verification.
This method replaces the legacy K8sAuthRole + TokenRestriction pattern with
a rules-based approach that supports complex claim matching, template
interpolation, and multi-tenant authorization — all mediated by the mapping
engine. See ADR-0020 for the design rationale.
Architecture
Keystone validates the presented Kubernetes JWT in one of two modes:
-
Local token as reviewer JWT: Keystone uses a service account token read from its own filesystem alongside the cluster CA certificate. Requires that Keystone and the client application run in the same Kubernetes cluster.
-
Client JWT as reviewer JWT: Keystone uses the client’s JWT to call the TokenReview endpoint. Enables remote cluster verification but requires the client service account to hold the
auth-delegatorrole.
Ingress Flow
The ingestion adapter performs cryptographic validation and claim flattening before handing off to the mapping engine:
sequenceDiagram
App->>Keystone: POST /v4/k8s_auth/instances/{id}/auth (JWT + optional rule_name)
Keystone->>Keystone: Pre-flight JWT validation (expiration check)
Keystone->>Kubernetes: TokenReview API call
Kubernetes->>Keystone: TokenReview response (username, groups)
Keystone->>Keystone: Flatten claims map
Keystone->>Keystone: Evaluate ruleset via mapping engine
Keystone->>FjallDB: Shadow registry upsert
Keystone->>App: Keystone token
Claims Flattening
After TokenReview succeeds, the ingress adapter extracts the username
(system:serviceaccount:<namespace>:<name>) and flattens it into a claims map
for the mapping engine per ADR-0020 §11.2:
| Claim Key | Source |
|---|---|
k8s.serviceaccount.name | Parsed from TokenReview username |
k8s.serviceaccount.namespace | Parsed from TokenReview username |
k8s.aud | JWT aud claim (if present) |
The unique workload ID invariant is
<serviceaccount_name>:<serviceaccount_namespace>, used for deterministic
virtual user ID derivation via
HMAC-SHA256(cluster_salt, workload_id || provider_id).
Authentication Paths
The POST /v4/k8s_auth/instances/{instance_id}/auth endpoint validates the
Kubernetes JWT via the TokenReview API and delegates identity resolution to the
unified mapping engine.
Mapping-Engine Path
This path validates the JWT, flattens claims, and delegates identity resolution
to a MappingRuleSet with IdentitySource::K8s.
Request:
{
"jwt": "<jwt_from_service_account_token_volume>",
"rule_name": "ci-pipeline-admin"
}
The optional rule_name field hints at a specific rule to evaluate first. If
the named rule matches, authentication succeeds immediately. If it does not
match, standard first-match-wins iteration proceeds.
Example mapping ruleset:
{
"mapping_id": "b2c3d4e5-6789-01bc-def0-23456789abcd",
"domain_id": "domain_infra",
"source": { "type": "k8s", "cluster_id": "eks-prod-cluster-01" },
"domain_resolution_mode": { "type": "fixed" },
"enabled": true,
"rules": [
{
"name": "ci-pipeline-admin",
"match": {
"all_of": [
{
"type": "condition",
"equals": {
"claim": "k8s.serviceaccount.namespace",
"value": "ci-pipeline"
}
},
{
"type": "condition",
"any_of": {
"claim": "k8s.serviceaccount.name",
"values": ["build-runner", "deploy-agent"]
}
}
]
},
"identity": {
"user_name": "svc-k8s-${claims.k8s.serviceaccount.name}"
},
"authorizations": [
{
"type": "project",
"project_id": "550e8400-e29b-41d4-a716-446655440010",
"project_domain_id": "domain_infra",
"roles": [{ "id": "admin", "name": "admin" }]
}
],
"groups": []
},
{
"name": "monitoring-reader",
"match": {
"all_of": [
{
"type": "condition",
"equals": {
"claim": "k8s.serviceaccount.namespace",
"value": "monitoring"
}
},
{
"type": "condition",
"matches_regex": {
"claim": "k8s.serviceaccount.name",
"regex": "^prometheus-.*$"
}
}
]
},
"identity": {
"user_name": "svc-k8s-${claims.k8s.serviceaccount.name}"
},
"authorizations": [
{
"type": "project",
"project_id": "550e8400-e29b-41d4-a716-446655440010",
"project_domain_id": "domain_infra",
"roles": [{ "id": "reader", "name": "reader" }]
}
],
"groups": [
{
"group_id": "550e8400-e29b-41d4-a716-446655440030",
"group_name": "Monitoring-Agents",
"group_domain_id": "domain_infra",
"strategy": { "type": "get" }
}
]
}
]
}
Token scope derivation. When the mapping engine resolves a match, the first authorization from the matched rule is used as the token scope. Project, Domain, and System authorizations are all supported. The complete set of authorizations is stored on the shadow virtual user record and re-evaluated during token verification.
Shadow virtual user. The mapping engine creates a deterministic virtual user
in the shadow registry. The virtual user record captures identity bindings,
snapshotted authorizations, and the content-aware ruleset_version (SHA-256 of
the live ruleset). During subsequent token verification, the engine performs a
TOCTOU check: if the live ruleset version differs from the shadow record, the
token is rejected.
API
Cluster and Instance Management
| Action | Endpoint | Description |
|---|---|---|
| Create cluster | POST /v4/k8s_auth/ | Register a new Kubernetes cluster |
| Manage instance | GET/PATCH/DELETE /v4/k8s_auth/instances/{auth_instance_id} | Get, update, or remove cluster config |
Mapping Ruleset Management
For the mapping-engine path, rulesets are managed via the unified mapping API. See Identity Mapping Engine for the full reference.
| Action | Endpoint | Description |
|---|---|---|
| Create ruleset | POST /v4/mapping | Create a ruleset with source: { type: "k8s", cluster_id: "..." } |
| List rulesets | GET /v4/mapping | Filter by domain_id, source, enabled |
| Show ruleset | GET /v4/mapping/{mapping_id} | Full rule definitions |
| Update ruleset | PUT /v4/mapping/{mapping_id} | Toggle enabled, update allowed_domains, or replace rules |
| Delete ruleset | DELETE /v4/mapping/{mapping_id} | Remove ruleset |
| Mutate rules | POST /v4/mapping/{mapping_id}/rules/mutate | Atomic insert/update/delete of individual rules |
Authentication Endpoint
| Action | Endpoint | Description |
|---|---|---|
| Authenticate | POST /v4/k8s_auth/instances/{auth_instance_id}/auth | Exchange K8s SA token for Keystone token |
Authentication Request
{
"jwt": "<jwt_from_k8s_service_account_token_volume>",
"rule_name": "ci-pipeline-admin"
}
| Field | Type | Required | Description |
|---|---|---|---|
jwt | string | yes | Kubernetes service account JWT token |
rule_name | string | null | no | Mapping-engine: optional named rule to evaluate first |
Identity Mapping Engine
The Identity Mapping Engine is a unified framework used by Keystone to resolve external identities (from various authentication sources) into internal Keystone principals and security contexts. Instead of hardcoding authentication logic for each provider, the mapping engine uses a set of configurable rules to determine how an external identity should be mapped.
Concept
The mapping engine decouples the authentication phase (verifying who the user is) from the authorization phase (determining what the user is in Keystone).
When an authentication request is received, the identity provider extracts “claims” (attributes about the identity) and passes them along with the source of the identity to the Mapping Engine. The engine evaluates these against a set of Rulesets. The first matching rule defines:
- The Principal (user or group) to be mapped to.
- The Scope and Role associations for the resulting token.
This allows administrators to dynamically change how external identities are mapped to internal users without changing code or reconfiguring the authentication providers.
Rule Structure
A MappingRuleSet contains an ordered list of MappingRules. The engine
iterates through these rules in sequence and applies the first rule that
satisfies its match criteria. Each rule consists of:
- Match Criteria: A nested boolean expression tree evaluating claims.
- Identity Binding: Defines the target Keystone user (
user_name,user_id,user_domain_id) using optional template interpolation. - Authorizations: Roles granted at the System, Domain, or Project level.
- Group Assignments: Groups the identity should be mapped to, with configurable resolution strategy.
Match Criteria
Match criteria define the boolean logic for evaluating conditions:
AllOf: All conditions must match.AnyOf: At least one condition must match.AllOfStrict: All conditions must match, with an optionalrequire_all_keysflag. When enabled, the engine verifies that all claim keys referenced in the criteria are present in the claims map. This prevents claim-suppression attacks where an attacker omits claims to bypass higher-priority rules.
Match Conditions
A match condition can be either:
Condition: A leaf-level claim assertion (see Claim Conditions below).Nested: A sub-group ofMatchCriteria, allowing complex boolean nesting.
Claim Conditions
A claim condition evaluates a specific claim key against a target value. Each
condition must specify claim (the key to look up in the claims map):
Equals: The claim value must equal the specifiedvalue. JSON primitives (numbers, booleans) are normalized to strings for comparison.AnyOf: The claim value must match at least one value in thevaluesarray.MatchesRegex: The claim value must match the givenregexpattern. Regex patterns are cached with a 1024-entry LRU limit.
Identity Binding & Template Interpolation
The identity binding defines the resulting Keystone principal:
user_name(required): String identifying the user. Supports template interpolation.user_id(optional): Explicit user identifier. Supports template interpolation.user_domain_id(optional): Domain context for the user. Supports template interpolation, subject toDomainResolutionModeconstraints.is_system(default false): When true, grants system-level privileges. Rulesets containing system bindings are treated as immutable system mappings after creation.
Template Interpolation
Identity fields support two template tokens:
${claims.<key>}: Replaces with the first value from the claims map for the given key. Unresolved keys leave the token intact.${enclosing_domain_id}: Replaces with the ruleset’s enclosing domain ID.
Templates are limited to 256 characters after resolution and cannot reference
reserved keys (e.g., enclosing_domain_id is blocked in ${claims.*} syntax to
prevent context shadowing).
Authorizations
Roles are assigned at one of three scope levels:
System: Grants roles at the system scope (requiresis_system: true). Requiressystem_id(typically"all") androles.Domain: Grants roles on a specific domain. Requiresdomain_idandroles.Project: Grants roles on a specific project. Requiresproject_id,project_domain_id, androles.
Role references support both id and name lookups.
Group Assignments
Group assignments map the external identity to localized Keystone groups:
group_id(required): Immutable group identifier.group_name(required): Group name, supports template interpolation.group_domain_id(optional): Domain containing the group.strategy(defaultCreateOrGet):CreateOrGet: Creates the group if it does not exist, or retrieves the existing one.Get: Only retrieves an existing group; fails if the group is missing.
Domain Resolution Modes
Each ruleset specifies how the domain context for the resolved principal is determined:
Fixed(default): Domain is fixed to thedomain_idon the ruleset.user_domain_idtemplates cannot reference${claims.*}tokens.ClaimsOnly: Domain is resolved exclusively from claims. At least one rule in the ruleset must use a${claims.*}template inuser_domain_id.ClaimsOrMapping: Domain is resolved fromuser_domain_idtemplate first, falling back to the rulesetdomain_id. Both claim templates and static values are permitted.
Authentication Providers
SPIFFE
SPIFFE (Secure Production Identity Framework for Everyone) provides a way to identify workloads across heterogeneous environments using SVIDs (SPIFFE Verifiable Identity Documents), typically x509 certificates.
Concept
SPIFFE authentication extracts the SVID from the mTLS x509 certificate and
flattens it into a claims map for the mapping engine. The source type is
spiffe, identified by the trust_domain field.
Claims Contract
The following claims are produced for SPIFFE identities:
spiffe.id: Full SPIFFE ID URI (e.g.,spiffe://example.org/spiffe/test-workload).spiffe.trust_domain: Trust domain from the SPIFFE ID (e.g.,example.org).
Mapping Rule Examples
Example 1: Map specific SPIFFE ID to a System Admin
Grants system-level privileges to a specific administrative workload.
{
"name": "system-admin-workload",
"match": {
"all_of": [
{
"type": "condition",
"equals": {
"claim": "spiffe.id",
"value": "spiffe://example.org/spiffe/admin-tool"
}
}
]
},
"identity": {
"user_name": "admin-workload",
"is_system": true
},
"authorizations": [
{
"type": "system",
"system_id": "all",
"roles": [{ "id": "admin", "name": "admin" }]
}
],
"groups": []
}
Example 2: Map Trust Domain to a Project Role
Broadly maps any workload from a specific trust domain into a project with a specific role.
{
"name": "trust-domain-mapping",
"match": {
"all_of": [
{
"type": "condition",
"equals": {
"claim": "spiffe.trust_domain",
"value": "example.org"
}
}
]
},
"identity": {
"user_name": "external-workload-user"
},
"authorizations": [
{
"type": "project",
"project_domain_id": "default",
"project_id": "project-123",
"roles": [{ "id": "member", "name": "member" }]
}
],
"groups": [
{
"group_id": "external-group",
"group_name": "External Workloads",
"strategy": { "type": "create_or_get" }
}
]
}
Example 3: Regex-based workload pattern matching
Maps workloads matching a path pattern using regex.
{
"name": "dev-workloads-regex",
"match": {
"all_of": [
{
"type": "condition",
"matches_regex": {
"claim": "spiffe.id",
"regex": "^spiffe://example.org/dev/.+$"
}
}
]
},
"identity": {
"user_name": "dev-workload-user"
},
"authorizations": [
{
"type": "project",
"project_domain_id": "default",
"project_id": "dev-project",
"roles": [{ "id": "member", "name": "member" }]
}
],
"groups": []
}
Example 4: AllOfStrict with trust domain and specific path
Prevents claim-suppression by requiring both claims to be present.
{
"name": "strict-namespace-mapping",
"match": {
"all_of_strict": {
"conditions": [
{
"type": "condition",
"equals": {
"claim": "spiffe.trust_domain",
"value": "example.org"
}
},
{
"type": "condition",
"equals": {
"claim": "spiffe.id",
"value": "spiffe://example.org/production/api"
}
}
],
"require_all_keys": true
}
},
"identity": {
"user_name": "production-api"
},
"authorizations": [
{
"type": "project",
"project_domain_id": "default",
"project_id": "prod-project",
"roles": [{ "id": "member", "name": "member" }]
}
],
"groups": []
}
Usage Instructions
- Configure SPIFFE Trust: Ensure the Keystone service is configured to trust the SPIFFE trust domain.
- Create Mapping Rules: Create a mapping ruleset that targets
spiffesource type with the appropriatetrust_domain. The rules should match againstspiffe.idorspiffe.trust_domainclaims. - Admin Shortcut: To bypass the mapping engine for administrative tasks,
configure
admin_svidin the admin interface configuration. Requests presenting this SVID over the admin interface are granted system-admin privileges.
Federation
Federation-based identity sources (e.g., OAuth 2.0, OIDC, SAML) authenticate via an IdP and extract claims from the resulting security token.
Concept
The ingress adapter performs the cryptographic validation of the federation
token (signature verification, CRL checks, remote TokenReview calls) and
flattens the claims into a map. The mapping engine then resolves the identity
using the same rule-based matching as other providers. The source type is
federation, identified by the idp_id field.
Claims Contract
Claims are provider-dependent and vary by federation protocol. Common claims include:
sub: Subject identifier (unique to the IdP).preferred_username: Human-readable username.groups: Group memberships from the IdP.email: Email address.
Mapping Rule Examples
Example 1: Map Federation User by Subject
Resolve a specific federation user by their sub claim.
{
"name": "federation-subject-mapping",
"match": {
"all_of": [
{
"type": "condition",
"equals": {
"claim": "sub",
"value": "federation-user-123"
}
}
]
},
"identity": {
"user_name": "${claims.preferred_username}",
"user_domain_id": "${enclosing_domain_id}"
},
"authorizations": [
{
"type": "project",
"project_domain_id": "default",
"project_id": "project-123",
"roles": [{ "id": "member", "name": "member" }]
}
],
"groups": [
{
"group_id": "federation-users",
"group_name": "Federation Users",
"strategy": { "type": "create_or_get" }
}
]
}
Example 2: Map Federation Group Membership
Resolve identities based on the groups claim.
{
"name": "federation-group-mapping",
"match": {
"all_of": [
{
"type": "condition",
"any_of": {
"claim": "groups",
"values": ["engineering", "operations"]
}
}
]
},
"identity": {
"user_name": "${claims.preferred_username}"
},
"authorizations": [
{
"type": "project",
"project_domain_id": "default",
"project_id": "shared-project",
"roles": [{ "id": "member", "name": "member" }]
}
],
"groups": []
}
Kubernetes
Kubernetes authentication validates service accounts via the Kubernetes API
server’s TokenReview endpoint and delegates identity resolution to the unified
mapping engine. For detailed ingress flow, API, and configuration, see
Kubernetes TokenReview Authentication.
The source type is k8s, identified by the cluster_id field.
Authentication Flow
The K8s authenticator validates the JWT via TokenReview, flattens claims,
and delegates to the mapping engine for identity resolution, shadow registry
upsert, and authorization. The optional rule_name field allows the client to
target a specific rule within the ruleset.
Claims Contract
The mapping-engine path produces the following claims from the TokenReview
response and JWT payload:
k8s.serviceaccount.name: Service account name (e.g.,"build-runner").k8s.serviceaccount.namespace: Kubernetes namespace (e.g.,"ci-pipeline").k8s.aud: JWT audience claim, if present in the token.
The unique workload ID invariant is
<serviceaccount_name>:<serviceaccount_namespace>, used for deterministic
virtual user ID derivation.
Mapping Rule Examples
Example 1: Map by namespace and service account name
Grants access to a specific CI/CD pipeline service account.
{
"name": "ci-pipeline-admin",
"match": {
"all_of": [
{
"type": "condition",
"equals": {
"claim": "k8s.serviceaccount.namespace",
"value": "ci-pipeline"
}
},
{
"type": "condition",
"any_of": {
"claim": "k8s.serviceaccount.name",
"values": ["build-runner", "deploy-agent"]
}
}
]
},
"identity": {
"user_name": "svc-k8s-${claims.k8s.serviceaccount.name}"
},
"authorizations": [
{
"type": "project",
"project_domain_id": "default",
"project_id": "infra-project",
"roles": [{ "id": "admin", "name": "admin" }]
}
],
"groups": []
}
Example 2: Regex-based monitoring agents
Matches any Prometheus-related service account in the monitoring namespace.
{
"name": "monitoring-reader",
"match": {
"all_of": [
{
"type": "condition",
"equals": {
"claim": "k8s.serviceaccount.namespace",
"value": "monitoring"
}
},
{
"type": "condition",
"matches_regex": {
"claim": "k8s.serviceaccount.name",
"regex": "^prometheus-.*$"
}
}
]
},
"identity": {
"user_name": "svc-k8s-${claims.k8s.serviceaccount.name}"
},
"authorizations": [
{
"type": "project",
"project_domain_id": "default",
"project_id": "monitoring-project",
"roles": [{ "id": "reader", "name": "reader" }]
}
],
"groups": [
{
"group_id": "k8s-monitoring-group",
"group_name": "Monitoring-Agents",
"strategy": { "type": "get" }
}
]
}
Example 3: AllOfStrict for namespace-scoped binding
Prevents claim-suppression attacks by requiring both namespace and name claims to be present.
{
"name": "strict-k8s-binding",
"match": {
"all_of_strict": {
"conditions": [
{
"type": "condition",
"equals": {
"claim": "k8s.serviceaccount.namespace",
"value": "openstack"
}
},
{
"type": "condition",
"equals": {
"claim": "k8s.serviceaccount.name",
"value": "nova-compute"
}
}
],
"require_all_keys": true
}
},
"identity": {
"user_name": "svc-nova-compute"
},
"authorizations": [
{
"type": "project",
"project_domain_id": "default",
"project_id": "compute-project",
"roles": [{ "id": "admin", "name": "admin" }]
}
],
"groups": []
}
Mapping API
The mapping engine is managed via the Keystone API (v4).
RuleSet Management
| Action | Endpoint | Description |
|---|---|---|
| Create | POST /v4/mapping | Create a new ruleset. Requires source, domain_resolution_mode, enabled, and rules. |
| List | GET /v4/mapping | List rulesets. Filterable by domain_id, enabled, limit, and marker. |
| Show | GET /v4/mapping/{mapping_id} | Get detailed rule definitions for a ruleset. |
| Update | PUT /v4/mapping/{mapping_id} | Toggle enabled state, update allowed_domains, or replace the entire rules array. Immutable fields (domain_id, source, domain_resolution_mode) cannot be changed. |
| Delete | DELETE /v4/mapping/{mapping_id} | Remove a ruleset. |
Imperative Rule Mutation
To avoid replacing the entire rules array, the API supports atomic mutations:
Insert: Add a rule at a specific position (beforeoraftera named anchor rule).Update: Replace an existing rule by itsname.Delete: Remove a rule by itsname.
Create Request Example
{
"mapping": {
"mapping_id": "unique-ruleset-id",
"domain_id": "default-domain",
"source": {
"type": "spiffe",
"trust_domain": "example.org"
},
"domain_resolution_mode": {
"type": "fixed"
},
"enabled": true,
"rules": [
{
"name": "admin-workload",
"match": {
"all_of": [
{
"type": "condition",
"equals": {
"claim": "spiffe.id",
"value": "spiffe://example.org/spiffe/admin-tool"
}
}
]
},
"identity": {
"user_name": "admin-workload",
"is_system": true
},
"authorizations": [
{
"type": "system",
"system_id": "all",
"roles": [{ "id": "admin", "name": "admin" }]
}
],
"groups": []
}
]
}
}
Security Considerations
- Immutable System Mappings: Rulesets containing
is_system: trueare locked after creation to prevent privilege escalation via rule mutations. - AllOfStrict Claim Suppression Prevention: The
require_all_keysflag inAllOfStrictprevents attackers from bypassing high-priority rules by suppressing specific claims in lower-trust assertions. - Template Safety: Templates cannot reference
enclosing_domain_idas a claim to prevent shadowing the ruleset’s domain context. Interpolated values are capped at 256 characters. - Regex Cache Limits: Compiled regex patterns are cached with a 1024-entry cap and 100-entry LRU eviction to prevent adversarial cache partitioning. Regex evaluation is limited to claim values under 4 KiB.
Configuration
Cluster Salt (Required)
The mapping engine requires cluster_salt to be configured before any
authentication provider (SPIFFE, Kubernetes, Federation) can use the mapping
engine. Without it, authentication requests that route through the mapping
engine fail with:
MappingEngine("HMAC-SHA256 virtual user ID derivation failed: cluster_salt not configured")
Purpose
The mapping engine derives deterministic virtual user IDs via
HMAC-SHA256(cluster_salt, workload_id || provider_id). This ensures that the
same external workload (e.g., a Kubernetes service account or SPIFFE SVID)
always maps to the same virtual user across Keystone service restarts and pod
redeployments. The salt binds the virtual user namespace to a specific Keystone
cluster — two clusters using the same salt would produce colliding virtual user
IDs, which is why each cluster should use a unique value.
How to Configure
Set mapping.cluster_salt in the Keystone configuration (e.g., via ConfigMap,
environment variable, or secrets manager):
mapping:
cluster_salt: "<random-secret>"
Generate the salt with a cryptographically secure random value:
openssl rand -hex 32
Operational Notes
- Uniqueness: Each Keystone cluster should use a distinct
cluster_saltto prevent virtual user ID collisions across clusters. - Stability: Changing
cluster_saltafter virtual users exist breaks ID stability — workloads that previously authenticated successfully will be assigned new virtual user IDs, orphaning existing shadow registry records. If a change is necessary, plan for the impact on existing virtual users and their role assignments. - Secret Management: Although
cluster_saltis a salt rather than a cryptographic key, it should be treated as a secret and managed through your secret management infrastructure (e.g., Kubernetes Secrets, Vault) rather than plain ConfigMaps.
Administrator Guide
This guide covers Keystone configuration, monitoring, operations, and how to add custom authentication plugins.
Configuration
Keystone configuration follows OpenStack conventions. The main configuration
file is keystone.conf in INI format.
Core Sections
[DEFAULT] - Global settings
[DEFAULT]
use_stderr = false
debug = true
log_dir = /var/log/keystone
[database] - Backend persistence
[database]
# SQLite (dev only)
connection = sqlite:///var/lib/keystone/keystone.db
# PostgreSQL (production)
connection = postgresql://keystone:password@db.example.com/keystone
[distributed_storage] - OpenRaft cluster for high-availability replication
[distributed_storage]
# Local storage path (created if missing)
path = /var/lib/keystone/raft/db
# This node's cluster peer address (for replication)
node_cluster_addr = https://ks1.example.com:50051
# Address to listen on for incoming cluster replication
node_listener_addr = 0.0.0.0:50051
# Unique identifier for this node in the cluster
node_id = 0
# TLS for inter-node communication
tls_cert_file = /etc/keystone/tls/keystone.crt
tls_key_file = /etc/keystone/tls/keystone.key
tls_client_ca_file = /etc/keystone/tls/ca.crt
# Development mode - single-node cluster without strict consensus
# NEVER use in production
dev_mode = false
[api_policy] - Authorization via OPA (Open Policy Agent)
[api_policy]
enable = true
opa_base_url = http://opa.example.com:8181
opa_policies_path = policy/
# Optional: unix socket for local OPA
opa_base_url = unix:///var/run/opa.sock
[auth] - Available authentication methods
[auth]
# Built-in methods: password, token, openid, application_credential,
# x509, webauthn, k8s, trust, admin, mapped
# Plus any registered dynamic auth plugins (see "Dynamic Auth Plugins" below)
methods = password,token,openid,application_credential,my_custom_sso
# Optional: map method names to friendly display names for user-facing interfaces
method_display_names = password:Username/Password,openid:OIDC,my_custom_sso:Corporate SSO
[fernet_tokens] / [fernet_receipts] - Token encryption keys
[fernet_tokens]
key_repository = /etc/keystone/fernet-keys/tokens
[fernet_receipts]
key_repository = /etc/keystone/fernet-keys/receipts
[mapping] - Federation mapping engine (see ADR 0020)
[mapping]
# Cluster-wide salt for hashing external identities
cluster_salt = "fbb27433d07ab307cc1fc899d0e174cf197fd398fbcff7285a63fe2f94eec2fe"
[audit] - CADF audit logging
[audit]
spool_dir = /var/spool/keystone/audit
[local_emergency] - Node-local quorum-bypass emergency rotation (ADR 0028)
[local_emergency]
# Disabled by default. Must be explicitly opted in per-node.
enabled = false
# How long the Raft leader must be unknown before the guardrail unlocks
# local-only writes (avoids tripping on a transient election blip).
leaderless_grace_period_seconds = 30
# Interval between best-effort gossip fan-out attempts to reachable peers
# while partitioned.
gossip_interval_seconds = 10
See “Quorum-Bypass Emergency Rotation” below and OAuth2 admin guide for the operational procedure.
Dynamic Auth Plugins
Register custom authentication plugins via [auth_plugins] and per-plugin
[auth_plugin.NAME] sections (see “Dynamic Auth Plugins” section below).
Monitoring & Observability
Health Checks
Local health endpoint (always available without authentication)
curl http://keystone:8080/health
# Returns: "OK"
Admin socket health (requires local socket access)
curl --unix-socket /var/run/keystone.sock http://localhost/health
Logging
Keystone uses structured JSON logging. All logs include:
level:debug,info,warn,error,criticalmsg: Human-readable messagetime: ISO 8601 timestamp- Domain-specific fields (e.g.,
user_id,req_id,req_method,resp_status)
Log destinations controlled by [DEFAULT] section:
log_dir: Write to files in this directoryuse_stderr: Also write to stderr
Example log filtering:
# Watch for errors in real-time
tail -f /var/log/keystone/keystone.log | grep '"level":"error"'
# Count 401 authentication failures
grep 'resp_status":401' /var/log/keystone/keystone.log | wc -l
Metrics & Observability
Keystone emits structured metrics via hooks; integration with Prometheus, Grafana, or similar is operator-configured.
Key metrics to alert on:
keystone_api_request_duration_seconds- Request latencykeystone_auth_failure_total- Auth failures (including rate limits)keystone_auth_plugin_load_failure- Plugin load failures (see “Plugin Errors” below)keystone_audit_queue_depth- Audit spool backlog
Troubleshooting Common Issues
Token validation failures
- Check
[fernet_tokens] key_repository- keys must be readable, not writable by others - Verify all nodes share the same current key (key rotation must be coordinated)
- Check token expiry:
keystone token show <token>
Authentication method not available
- Verify the method is listed in
[auth] methods - For OIDC/K8s: check provider configuration and connectivity
- For auth plugins: check logs for
keystone_auth_plugin_load_failurealerts
OPA policy failures
- Verify
[api_policy] opa_base_urlis reachable - Check OPA logs for policy compilation errors
- Confirm policy files exist under
[api_policy] opa_policies_path
Cluster consensus stuck
- Check inter-node network connectivity (port 50051 by default)
- Verify TLS certificates in
[distributed_storage] - Review cluster membership:
keystone-manage cluster list
Cluster Operations
Multi-Node Deployment
Keystone uses OpenRaft for distributed storage. Each node needs:
- Unique
node_idin[distributed_storage] - Reachable cluster address via
node_cluster_addr(https only, TLS required) - Synchronized time (NTP) - consensus relies on clock accuracy
- Same fernet key repository - keys must be identical across all nodes
Example 3-node cluster:
# Node 1: ks1.example.com
[distributed_storage]
node_id = 0
node_cluster_addr = https://ks1.example.com:50051
node_listener_addr = 0.0.0.0:50051
tls_cert_file = /etc/keystone/tls/ks1.crt
tls_key_file = /etc/keystone/tls/ks1.key
tls_client_ca_file = /etc/keystone/tls/ca.crt
# Node 2: ks2.example.com
[distributed_storage]
node_id = 1
node_cluster_addr = https://ks2.example.com:50051
node_listener_addr = 0.0.0.0:50051
tls_cert_file = /etc/keystone/tls/ks2.crt
tls_key_file = /etc/keystone/tls/ks2.key
tls_client_ca_file = /etc/keystone/tls/ca.crt
# Node 3: ks3.example.com
[distributed_storage]
node_id = 2
node_cluster_addr = https://ks3.example.com:50051
node_listener_addr = 0.0.0.0:50051
tls_cert_file = /etc/keystone/tls/ks3.crt
tls_key_file = /etc/keystone/tls/ks3.key
tls_client_ca_file = /etc/keystone/tls/ca.crt
Node Management
Add a new node:
# 1. Generate TLS cert/key for the new node
# 2. Add to cluster configuration on existing nodes (restart required)
# 3. Start the new node with unique node_id
# 4. Verify it joined: keystone-manage cluster list
Remove a node:
# 1. Gracefully shut down the node
# 2. Remove from all peer configurations
# 3. Restart remaining nodes
Cluster status:
keystone-manage cluster status
keystone-manage cluster list
Quorum-Bypass Emergency Rotation (ADR 0028)
When Raft has lost quorum, the ordinary emergency rotation paths (OAuth2
signing-key emergency rotation, DEK emergency rotation) cannot commit – they’re
themselves Raft proposals. [local_emergency] provides a node-local fallback:
an operator writes a rotation candidate straight to that node’s local Fjall
keyspace (never touched by Raft’s apply()), bypassing quorum entirely.
Guardrail: refused unless [local_emergency] enabled = true on that node
and the Raft leader has been unknown for at least
leaderless_grace_period_seconds – this is not a general-purpose quorum-skip,
only a last resort while genuinely partitioned.
Two subsystems use this path: OAuth2 domain signing keys (see OAuth2 admin guide) and the cluster DEK (below).
Gossip. A background sweep (every gossip_interval_seconds) best-effort
pushes each locally-originated candidate to every other reachable Raft peer over
the same inter-node mTLS channel Raft itself uses. This only makes candidates
visible cluster-wide (conflicted: true if a peer reports a different active
candidate for the same subsystem/scope) – it does not reconcile or auto-resolve
anything.
Reconciliation. Once quorum returns, an operator lists candidates on each
node that may have been reached during the outage and explicitly picks one
rotation_id to promote into Raft-replicated state. Reconciliation is strictly
per-node (run against the specific node holding the candidate, not cluster-wide)
and dual-control (confirming operator must differ from the one who staged it).
DEK local-quorum-bypass rotation and reconciliation (via
ClusterAdminService gRPC, same mTLS/operator-role boundary as rotate-dek):
# During quorum loss, on a guardrail-enabled node:
keystone-manage storage rotate-dek \
--local-quorum-bypass --justification "suspected KEK compromise, quorum lost"
# After quorum returns, on every node possibly reached during the outage:
keystone-manage storage list-dek-local-emergency-candidates
# A different operator promotes the chosen candidate on the node that holds it:
keystone-manage storage reconcile-dek-local-emergency --rotation-id <id>
Reconciliation installs the DEK via the normal Raft transaction path and refuses
(FailedPrecondition) if the DEK version has advanced past what the candidate
expected – i.e. another rotation already committed while this candidate sat
staged.
Known scope limits (deliberate, see ADR 0028 “Implementation Status”):
- No cross-node broadcast to clear a candidate once superseded elsewhere – gossip gives visibility, not cleanup.
- No automatic/unattended reconciliation sweep; an operator must pick a
rotation_idexplicitly, per node. - Up to one
gossip_interval_secondsof propagation delay after staging (no immediate post-stage push).
Dynamic Auth Plugins
Custom authentication logic can be added without recompiling Keystone via WebAssembly (WASM) plugins. See Plugins: Auth for the developer guide.
Requires distributed storage. A full_auth plugin’s
(plugin_name, external_id) -> user_id identity-binding index
([auth_plugin_identity] driver, defaults to raft) is backed by
[distributed_storage], decoupled from whichever IdentityBackend is
configured. There is currently no SQL driver alternative - full_auth-mode
plugins using provision_user/find_user are not available in a deployment
without distributed storage configured. mapping- and route-mode plugins have
no such requirement, since they never call those host functions.
Plugin Configuration
Plugins are configured in keystone.conf under [auth_plugins] and per-plugin
sections.
Minimal example (full_auth mode - plugin authenticates users):
[auth_plugins]
plugins = my_plugin
[auth_plugin.my_plugin]
path = /etc/keystone/plugins/my_plugin.wasm
sha256 = 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
mode = full_auth
capabilities = http_fetch,provision_user,find_user
provision_domain_id = default
timeout_ms = 750
fuel_limit = 50000000
memory_limit_mb = 32
invocation_rate_limit_per_source_per_minute = 20
invocation_rate_limit_per_minute = 300
max_concurrent_invocations = 16
Add to [auth] methods:
[auth]
methods = password,token,my_plugin
Plugin Modes
full_auth (default) - Plugin is the authentication authority
- Can call
provision_user,find_user,assign_role - Suitable for custom SSO bridges, risk-scoring auth, proprietary protocols
- Must admin-link pre-existing users via
POST /v4/auth_plugins/{plugin_name}/identity_links
mapping - Plugin produces claims; Mapping Engine decides identity
- Cannot call provisioning functions (config error if listed)
- Safe way to authenticate pre-existing (e.g., SCIM) users without special linking
- Plugin claims fed to
MappingRuleSetrules for final identity resolution - Requires
MappingRuleSetrules exist underprovider_id = "wasm:{plugin_name}"
route - Plugin redirects requests to other handlers (pre-dispatch)
- Runs before method dispatch; can rewrite method names and payloads
- Cannot call any identity/provisioning functions (config error if listed)
- Used for clients that always send a fixed method name (e.g.,
application_credential) - Does NOT authenticate; target method still performs full verification
Plugin Capabilities
Plugins opt into capabilities; unlisted functions are not available to the guest:
http_fetch- Make HTTP calls to external services (SSRF-protected)provision_user- Create new users in the configured domainfind_user- Look up existing provisioned usersassign_role- Grant roles to provisioned users (config-bounded toassign_role_allowed)
Audit logging is always enabled; it cannot be disabled.
Configuration Reference
| Key | Mode | Default | Description |
|---|---|---|---|
path | All | Required | Filesystem path to .wasm plugin binary |
sha256 | All | Required | SHA-256 checksum of plugin file (verified at startup) |
mode | All | full_auth | Operating mode: full_auth, mapping, or route |
capabilities | All | Empty | Comma-separated host functions: http_fetch, provision_user, find_user, assign_role |
exposed_headers | All | Empty | HTTP headers plugin may access (comma-separated); hard-denied: Authorization, Cookie, X-Auth-Token, X-Subject-Token, Proxy-Authorization |
allowed_hosts | All | Required if http_fetch used | Hostname allowlist for http_fetch calls (comma-separated) |
http_fetch_auth_header | All | Optional | Header name to attach auth secret (e.g., Authorization) |
http_fetch_auth_secret_env | All | Optional | Environment variable containing auth secret (never enters guest memory) |
http_fetch_follow_redirects | All | false | Allow HTTP redirects (each hop re-validated against allowlist) |
provision_domain_id | full_auth | Required if provisioning | Single domain where plugin may create users; find_user revalidates on every call |
allowed_provision_domains | full_auth | Alternative | Comma-separated list of domains; use if plugin must span multiple domains |
assign_role_allowed | full_auth | Required if assign_role used | Comma-separated role names plugin may grant (e.g., member,reader) |
inspect_methods | route | Required | Comma-separated identity methods that trigger this plugin (e.g., application_credential) |
route_targets | route | Required | Comma-separated allowlist of methods this plugin may route to; admin and trust forbidden |
timeout_ms | All | 1000 | Wall-clock timeout for plugin invocation, including any http_fetch calls (the whole redirect chain shares this one budget, not one per hop) |
fuel_limit | All | 10000000 | Instruction budget (protects against infinite loops) |
memory_limit_mb | All | 16 | Linear-memory limit for plugin heap |
invocation_rate_limit_per_source_per_minute | All | 20 | Per-source-IP rate limit (sliding window) |
invocation_rate_limit_per_minute | All | 300 | Per-plugin global rate limit |
max_concurrent_invocations | All | 16 | Maximum simultaneous invocations |
valid_since | full_auth | None (never rejects) | RFC 3339 timestamp; a token whose issued_at predates this is rejected (PluginVersionMismatch) on re-verification. Bump alongside sha256 for a security fix. Not enforceable for mapping-mode tokens today (ADR 0025 §4/§8) |
Plugin Loading & Errors
Plugins are loaded at process startup. If a plugin’s file is missing or its SHA-256 does not match:
- That plugin only is disabled (not available for auth)
- A
CRITICAL-level log and metrickeystone_auth_plugin_load_failure{plugin_name}are emitted - All other plugins and auth methods start normally
This is fail-closed-at-request-level: a load error for one plugin does not block the cluster, but that specific auth method is unavailable on that node. A hash mismatch across nodes creates temporary inconsistency until resolved.
To fix:
# 1. Verify the file exists and matches the pinned hash
sha256sum /etc/keystone/plugins/my_plugin.wasm
# 2. If hash is wrong, update keystone.conf with the correct hash
# 3. Restart Keystone
systemctl restart keystone
Plugin Operations
Admin-authorized identity linking (full_auth mode only):
# Link a pre-existing (e.g., SCIM-provisioned) user to a plugin
curl -X POST http://keystone:5000/v4/auth_plugins/{plugin_name}/identity_links \
-H "X-Auth-Token: $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"identity_link": {
"external_id": "sso_user_123",
"user_id": "existing-keystone-uuid"
}
}'
RBAC-tiered: system-scope admin may link any user; a domain-scoped
admin/manager may link only a non-system user in their own domain.
Re-linking an already-linked external_id returns 409 Conflict - DELETE the
existing link first.
Note: SCIM convenience fields (
scim_provider_id,scim_external_id) are documented in ADR 0025 §4 but not yet implemented. Track as follow-up work.
Bulk revocation (on plugin compromise or update):
# Disables all users provisioned by (or admin-linked to) the plugin, deletes
# identity links, and revokes tokens for every affected user. System-admin
# only. Idempotent - a second call against an already-cleaned-up plugin is a
# no-op (all-zero counts).
curl -X POST http://keystone:5000/v4/auth_plugins/{plugin_name}/revoke_all \
-H "X-Auth-Token: $ADMIN_TOKEN"
# Response: { "revoke_all": { "users_disabled": N, "links_deleted": N } }
This does NOT revoke role assignments the plugin granted via assign_role -
attributing a stored grant to the plugin that created it would require
per-record origin bookkeeping this ADR deliberately avoids. Disabling the
account already denies all access; review a re-enabled user’s remaining
assignments against the CADF audit trail (plugin_name recorded on every
assign_role event) and revoke any you deem compromised via the ordinary
per-grant revocation API before re-enabling.
Plugin Errors & Troubleshooting
Plugin fails to load
- Check logs:
grep keystone_auth_plugin_load_failure /var/log/keystone.log - Verify file exists:
ls -la /etc/keystone/plugins/my_plugin.wasm - Check SHA-256:
sha256sum /etc/keystone/plugins/my_plugin.wasm - Update config and restart
Plugin invocation fails (401 or 429)
-
401: Plugin denied the login or returned invalid response- Check plugin logs and external service logs (if using
http_fetch) - Verify plugin logic matches expected credential format
- Check the audit trail: CADF events (
wasm_plugin.*, ADR 0025 §6.E) are spooled to[audit] spool_dir, not queryable via an HTTP API - grep the spool for the plugin’sTarget.id(the plugin name)
- Check plugin logs and external service logs (if using
-
429: Rate limit exceeded- Check configured limits:
invocation_rate_limit_per_source_per_minute,invocation_rate_limit_per_minute,max_concurrent_invocations - Increase limits in config if legitimate traffic
- Check for DDoS or misconfigured clients
- Check configured limits:
Plugin behavior unexpectedly changes
- Plugin was patched and Keystone restarted with the new
sha256- there is no hot reload (ADR 0025 §5); a running process never picks up a changed.wasm/sha256without a restart - Mapping rules changed (for
mappingmode) - verifyMappingRuleSetconfig - Identity links modified - check audit trail for admin changes
Plugin consuming too much memory/CPU
- Increase
fuel_limitortimeout_msif legitimate - Check plugin code for memory leaks or inefficient algorithms
- Reduce
max_concurrent_invocationsif CPU-bound - Review plugin logs and
http_fetchexternal service performance
Maintenance & Upgrades
Keystone Upgrade
- Test in non-prod first - authentication changes are high-risk
- Backup fernet keys -
[fernet_tokens] key_repository,[fernet_receipts] key_repository - Stop all nodes - graceful shutdown prevents data loss
- Upgrade binary - pull new image or build from source
- Verify schema migrations -
keystone-manage db upgrade - Start nodes one at a time - wait for Raft consensus on each
- Monitor auth failures - transient failures are normal for 1-2 minutes
Plugin Update
When updating a plugin:
- Compute new SHA-256 of the updated
.wasm - Update config with new hash and new
pathif needed - If this update fixes a security issue, also bump
valid_sinceto the deployment instant. Updatingsha256alone does not invalidate outstanding tokens - version binding is a separate, explicitvalid_sincecutoff compared against each token’sissued_at(full_authmode only; see the Configuration Reference table below). Forgetting this step leaves tokens minted by the previous (vulnerable) plugin version valid until they expire naturally. - Restart Keystone (all nodes, one at a time)
- Optional: Run
POST .../revoke_allif the plugin had a security fix, to also disable/unlink/revoke everything the compromised version provisioned or granted -valid_sincealone only stops new uses of already-issued tokens, not cleanup of persistent state
Disaster Recovery
Lost fernet keys?
- All existing tokens are invalidated immediately
- Users must re-authenticate
- No data loss (keys are only for token encryption, not persistence)
Database corruption?
- From backup: Stop all nodes, restore DB, restart one node, let others rejoin Raft cluster
- Fresh start: Remove
[distributed_storage] pathdirectory, restart (single-node cluster forms)
Cluster lost quorum?
- For a 3-node cluster: can lose at most 1 node
- If 2+ nodes down: Raft cannot proceed; start any 1 node with
dev_mode = truetemporarily to unblock (high-risk, last resort)
Security Best Practices
- Run Keystone as non-root - separate unprivileged user
- Protect fernet keys - restrictive file permissions (
0600) - TLS for cluster communication - inter-node replication is encrypted
- Audit logging enabled - retention policy required (compliance)
- Rate limiting tuned - prevent brute force / DDoS
- Policy-driven authz - OPA policies reviewed, tested, audited
- Plugin vetting - review
capabilitiesandallowed_hostsbefore loading - Secret management -
http_fetch_auth_secret_envnever hardcoded in config
See Security Model for detailed threat model and invariants.
Auth Plugin Development Guide
This guide covers designing, building, and deploying dynamic authentication plugins for Keystone using WebAssembly (WASM).
Overview
Dynamic auth plugins extend Keystone’s authentication without recompiling or forking:
- Compile once to
.wasm, distribute to every Keystone node - Three operating modes: full authentication authority, claims transformer, or request router
- Curated host functions - plugins cannot access arbitrary storage or network, only what’s explicitly granted
- Namespace-scoped identity - plugins can only authenticate users they themselves provision (except via admin-authorized linking)
All code in this guide is derived from the real, compiled reference plugin
fixture used by Keystone’s own test suite
(crates/auth-plugin-runtime/tests/fixtures/reference-plugin/src/lib.rs) and
the actual wire contract types
(crates/auth-plugin-runtime/src/{auth_contract,mapping_contract,route_contract}.rs).
If your plugin’s JSON doesn’t round-trip against those types, the host rejects
it as malformed (ADR 0025 §7) - there is no leniency in the decoder.
See ADR 0025 for the complete threat model and design rationale.
Quick Start: Hello-World Plugin
Build and test a minimal full_auth plugin.
1. Setup
# Add Rust target for WebAssembly
rustup target add wasm32-unknown-unknown
# Create a new Rust library
cargo new --lib my_plugin
cd my_plugin
2. Dependencies
Edit Cargo.toml:
[package]
name = "my_plugin"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib"] # REQUIRED: produces .wasm, not .rlib
[dependencies]
extism-pdk = "1"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
3. Plugin Code
Every host function Keystone exposes takes exactly one JSON-encoded string
argument and returns exactly one JSON-encoded string - never multiple typed
arguments. The #[host_fn] extern "ExtismHost" block below is how you declare
that ABI to extism-pdk; you build/parse the JSON yourself with serde_json.
Edit src/lib.rs:
#![allow(unused)]
fn main() {
use extism_pdk::{host_fn, plugin_fn, FnResult, Json};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
// Host functions Keystone registers for this plugin, gated by its
// `capabilities` config entry (ADR 0025 §6). One JSON string in, one JSON
// string out - this is the actual ABI, not the typed multi-arg signature a
// higher-level SDK might expose in other languages.
#[host_fn]
extern "ExtismHost" {
fn provision_user(request_json: String) -> String;
fn find_user(external_id_json: String) -> String;
}
// The `payload` shape is whatever your plugin's own config block declares
// under `identity.<method_name>` in the client's auth request - you define
// this struct to match what your clients will send.
#[derive(Debug, Deserialize)]
pub struct MyPayload {
pub username: String,
}
#[derive(Debug, Deserialize)]
pub struct AuthPluginRequest {
pub payload: MyPayload,
#[serde(default)]
pub headers: HashMap<String, String>,
#[serde(default)]
pub remote_addr: Option<String>,
}
// Wire-format requirement: internally tagged on "decision", snake_case.
// `{"decision":"allow","resolved_identity":"...","claims":{...}}` /
// `{"decision":"deny","reason":"..."}` - any other shape is rejected as
// malformed before your plugin's logic is even considered to have run.
#[derive(Debug, Serialize)]
#[serde(rename_all = "snake_case", tag = "decision")]
pub enum AuthPluginResponse {
Allow {
resolved_identity: String,
claims: HashMap<String, serde_json::Value>,
},
Deny {
reason: String,
},
}
/// Authenticate a user (full_auth mode).
#[plugin_fn]
pub fn authenticate(req: Json<AuthPluginRequest>) -> FnResult<Json<AuthPluginResponse>> {
let payload = req.0.payload;
// Simple demo: accept any non-empty username. In a real plugin, verify
// against an external service, check a signature, etc. *before* calling
// provision_user - provision_user/find_user only bind an already-verified
// external identity to a Keystone user, they perform no verification of
// their own.
if payload.username.is_empty() {
return Ok(Json(AuthPluginResponse::Deny {
reason: "empty username".to_string(),
}));
}
// provision_user's request body: {"external_id": "...", "user": {...}}.
// `user` accepts only `domain_id`, `name`, `enabled` (optional), `extra`
// (optional map) - an intentionally narrow allowlist (ADR §6.B "Field
// sanitization"); there is no `id`, `password`, or admin-ish option
// field a plugin can set.
let provision_request = serde_json::json!({
"external_id": payload.username,
"user": {
"domain_id": "default",
"name": payload.username,
},
});
// The host function itself takes/returns a single JSON string - encode
// the request, decode the response yourself.
let handle_json = unsafe { provision_user(provision_request.to_string())? };
// provision_user's success response is a bare JSON string (the opaque
// handle) - NOT `{"handle": "..."}`.
let resolved_identity: String = serde_json::from_str(&handle_json)?;
let mut claims = HashMap::new();
claims.insert(
"external_username".to_string(),
serde_json::Value::String(payload.username),
);
Ok(Json(AuthPluginResponse::Allow {
resolved_identity,
claims,
}))
}
}
4. Build
cargo build --release --target wasm32-unknown-unknown
ls -la target/wasm32-unknown-unknown/release/my_plugin.wasm
5. Deploy
Compute the SHA-256 checksum:
sha256sum target/wasm32-unknown-unknown/release/my_plugin.wasm
# Output: 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08 my_plugin.wasm
Add to Keystone config:
[auth_plugins]
plugins = my_plugin
[auth_plugin.my_plugin]
path = /etc/keystone/plugins/my_plugin.wasm
sha256 = 9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08
mode = full_auth
capabilities = provision_user,find_user
provision_domain_id = default
timeout_ms = 750
fuel_limit = 50000000
memory_limit_mb = 32
[auth]
methods = password,token,my_plugin
Copy the .wasm to every Keystone node and restart:
cp target/wasm32-unknown-unknown/release/my_plugin.wasm /etc/keystone/plugins/
systemctl restart keystone
6. Test
curl -X POST http://keystone:5000/v3/auth/tokens \
-H "Content-Type: application/json" \
-d '{
"auth": {
"identity": {
"methods": ["my_plugin"],
"my_plugin": {
"username": "alice"
}
}
}
}'
# On success: HTTP 201, X-Subject-Token header
Operating Modes Deep Dive
full_auth: Plugin Authenticates Users
Plugin is the terminal authentication authority. It decides who is allowed and optionally provisions users.
Entry point: authenticate(AuthPluginRequest) -> AuthPluginResponse
When to use:
- Custom SSO bridges (proprietary token formats, legacy directory protocols)
- Real-time risk scoring or step-up decisions
- Non-standard credential verification (e.g., certificate chains)
Capabilities available:
http_fetch- call external servicesprovision_user- create new users (first login)find_user- look up provisioned users (idempotent login)assign_role- grant roles to users
Identity binding (security):
- Plugin cannot assert arbitrary
user_idvalues provision_userandfind_useroperate on(plugin_name, external_id)namespace- External ID must come from verified credential (not plugin-chosen)
- Only users the plugin itself provisioned are reachable
- Pre-existing users require admin-authorized linking (see “Admin Identity Linking” below)
Example: OIDC-like SSO
#![allow(unused)]
fn main() {
use extism_pdk::{host_fn, plugin_fn, FnResult, Json};
use std::collections::HashMap;
#[host_fn]
extern "ExtismHost" {
fn http_fetch(request_json: String) -> String;
fn provision_user(request_json: String) -> String;
}
#[plugin_fn]
pub fn authenticate(req: Json<AuthPluginRequest>) -> FnResult<Json<AuthPluginResponse>> {
let token = req.0.payload.get("token").and_then(|v| v.as_str()).ok_or("No token")?;
// http_fetch's request body: {"method": "GET", "url": "...", "headers": {...}, "body": null}.
// Authentication to the upstream service (if any) is injected by the
// host from `http_fetch_auth_header`/`http_fetch_auth_secret_env`
// config - it is never something this plugin supplies itself.
let fetch_request = serde_json::json!({
"method": "GET",
"url": "https://idp.example.com/userinfo",
"headers": {"Authorization": format!("Bearer {token}")},
});
let response_json = unsafe { http_fetch(fetch_request.to_string())? };
// http_fetch's response: {"status": 200, "headers": {...}, "body": "..."}
// - body is the raw response text; parse it yourself.
let response: serde_json::Value = serde_json::from_str(&response_json)?;
let status = response.get("status").and_then(|v| v.as_u64()).unwrap_or(0);
if status != 200 {
return Ok(Json(AuthPluginResponse::Deny {
reason: format!("IDP returned {status}"),
}));
}
let body: serde_json::Value = serde_json::from_str(
response.get("body").and_then(|v| v.as_str()).unwrap_or("{}"),
)?;
let sub = body.get("sub").and_then(|v| v.as_str()).ok_or("No 'sub' claim")?;
let provision_request = serde_json::json!({
"external_id": sub,
"user": {
"domain_id": "default",
"name": body.get("name").and_then(|v| v.as_str()).unwrap_or(sub),
},
});
let handle_json = unsafe { provision_user(provision_request.to_string())? };
let resolved_identity: String = serde_json::from_str(&handle_json)?;
let mut claims = HashMap::new();
if let Some(email) = body.get("email") {
claims.insert("email".to_string(), email.clone());
}
if let Some(groups) = body.get("groups") {
claims.insert("groups".to_string(), groups.clone());
}
Ok(Json(AuthPluginResponse::Allow {
resolved_identity,
claims,
}))
}
}
mapping: Plugin Produces Claims, Mapping Engine Decides
Plugin generates claims; the Mapping Engine (not the plugin) makes the identity decision via configured rules.
Entry point: mapping(AuthPluginRequest) -> MappingResponse
When to use:
- Plugins that transform existing credentials into claims (header/JWT parsing, protocol adapters)
- Authenticating pre-existing SCIM-provisioned users (no namespace scoping needed)
- Plugins that don’t make terminal
Allow/Denydecisions
No identity binding: Plugin cannot provision or name users. Mapping Engine
uses claims to match against MappingRuleSet rules, resolving to real users if
rules fire.
A __keystone_workload_id claim is required. Every mapping-mode
response’s claims map must include a string-valued __keystone_workload_id
key - it is the Mapping Engine’s unique_workload_id (ADR 0020 §3), which has
no dedicated field on MappingResponse::Claims. A response missing it, or where
it isn’t a string, is rejected as malformed (MissingWorkloadId) before the
Mapping Engine ever sees it. Unlike every other __keystone-prefixed key, this
one is left in the claims map so mapping rules can also reference it directly.
Admin setup required:
- Deploy plugin with
mode = mapping - Create
MappingRuleSetrules withprovider_id = "wasm:{plugin_name}" - Rules decide identity resolution (e.g., match claims to existing user by email)
- Plugin claims are namespaced: no risk of injecting privilege-relevant claims
Example: Parse custom header and produce claims
#![allow(unused)]
fn main() {
use extism_pdk::{plugin_fn, FnResult, Json};
use std::collections::HashMap;
// Wire shape mirrors AuthPluginResponse's tagging convention:
// {"decision":"claims","claims":{...}} / {"decision":"deny","reason":"..."}.
// There is no `Allow` variant - a mapping-mode plugin cannot terminate
// authentication, only feed the engine that does.
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "snake_case", tag = "decision")]
pub enum MappingResponse {
Claims { claims: HashMap<String, serde_json::Value> },
Deny { reason: String },
}
#[plugin_fn]
pub fn mapping(req: Json<AuthPluginRequest>) -> FnResult<Json<MappingResponse>> {
// Extract custom header (must be in this plugin's exposed_headers config
// - anything not explicitly allowlisted there is simply absent here,
// never silently forwarded).
let header_value = req
.0
.headers
.get("X-Custom-Auth")
.ok_or("missing X-Custom-Auth header")?;
let parts: Vec<&str> = header_value.split(':').collect();
if parts.len() < 2 {
return Ok(Json(MappingResponse::Deny {
reason: "malformed header".to_string(),
}));
}
let mut claims = HashMap::new();
// Required on every mapping-mode response - see note above.
claims.insert(
"__keystone_workload_id".to_string(),
serde_json::Value::String(parts[1].to_string()),
);
claims.insert("realm".to_string(), serde_json::Value::String(parts[0].to_string()));
claims.insert(
"email".to_string(),
serde_json::Value::String(format!("{}@example.com", parts[1])),
);
Ok(Json(MappingResponse::Claims { claims }))
}
}
Corresponding Mapping Engine rule (POST /v4/mappings, ADR 0020 §9.A):
{
"mapping": {
"domain_id": "default",
"source": { "type": "wasm_plugin", "plugin_name": "my_mapping_plugin" },
"domain_resolution_mode": { "type": "fixed" },
"enabled": true,
"rules": [
{
"name": "any-claim",
"match": {
"all_of": [
{
"type": "condition",
"matches_regex": { "claim": "realm", "regex": ".*" }
}
]
},
"identity": {
"user_name": "{realm}-{email}",
"user_domain_id": "default",
"is_system": false
},
"authorizations": [],
"groups": []
}
]
}
}
route: Plugin Routes Requests Pre-Dispatch
Plugin sees the raw, pre-dispatch request before method dispatch; can redirect to a different method or pass through unchanged. It never authenticates anyone.
Entry point: route(RouteRequest) -> RouteResponse
When to use:
- Clients that always send a fixed method name (can’t be changed)
- Conditional routing based on credential content (e.g.,
application_credentialthat might be different handler-specific formats) - Credential-shape-based dispatching without authentication
Important: Plugin does NOT authenticate. Target method still performs full verification against whatever payload it receives.
Constraints (enforced by host):
- Cannot touch
scope(project/domain/system) -RouteResponsecarries noscopefield at all, only relabels which method handles the request - Can only route to methods in this plugin’s
route_targetsallowlist - a response naming any other method is rejected as malformed, not corrected - Can never target
adminortrustmethods, regardless ofroute_targets - Single-shot - a request already routed once is never re-routed, by the same router or a different one
- Target method receives exactly the payload this plugin specifies - re-verification by the target is what makes that safe, not any assertion this plugin makes
Example: application_credential routing
#![allow(unused)]
fn main() {
use extism_pdk::{plugin_fn, FnResult, Json};
use std::collections::HashMap;
// `methods`, not `requested_methods` - the actual field name.
#[derive(Debug, serde::Deserialize)]
pub struct RouteRequest {
pub methods: Vec<String>,
#[serde(default)]
pub payloads: HashMap<String, serde_json::Value>,
#[serde(default)]
pub headers: HashMap<String, String>,
#[serde(default)]
pub remote_addr: Option<String>,
}
#[derive(Debug, serde::Serialize)]
#[serde(rename_all = "snake_case", tag = "decision")]
pub enum RouteResponse {
Passthrough,
Route {
target_method: String,
payload: serde_json::Value,
},
Deny {
reason: String,
},
}
#[plugin_fn]
pub fn route(req: Json<RouteRequest>) -> FnResult<Json<RouteResponse>> {
// `payloads` only ever contains blocks for methods this plugin's
// `inspect_methods` config declared - a router configured to look at
// `application_credential` never sees an unrelated `password` block,
// even on a request carrying both.
let Some(payload) = req.0.payloads.get("application_credential") else {
return Ok(Json(RouteResponse::Passthrough));
};
let Some(cred_id) = payload.get("application_credential_id").and_then(|v| v.as_str()) else {
return Ok(Json(RouteResponse::Passthrough));
};
if let Some(rest) = cred_id.strip_prefix("tf-") {
return Ok(Json(RouteResponse::Route {
target_method: "hacked_appcred_handler".to_string(),
payload: serde_json::json!({ "external_id": rest }),
}));
}
// Every other application_credential request passes through unmodified.
Ok(Json(RouteResponse::Passthrough))
}
}
Config:
[auth_plugin.tf_router]
mode = route
inspect_methods = application_credential
route_targets = hacked_appcred_handler
capabilities = # empty; add http_fetch if the router needs to query an external service
Host Functions API
Request Objects
Every entry point receives a request whose exact shape depends on the mode:
authenticate/mapping:AuthPluginRequest { payload, headers, remote_addr }payload: serde_json::Value- rawidentity.<method>block from the client’s auth request, exactly as received; deserialize it into whatever shape your plugin expectsheaders: HashMap<String, String>- allowlisted subset of inbound HTTP headers (only names in this plugin’sexposed_headersconfig; a fixed denylist -Authorization,Cookie,X-Auth-Token,X-Service-Token,X-Subject-Token,Proxy-Authorization- can never appear here regardless of config)remote_addr: Option<String>- trusted client address (resolved via[auth_plugins].trusted_proxies), never a raw, spoofableX-Forwarded-Forvalue;Noneif no trusted address could be established
route:RouteRequest { methods, payloads, headers, remote_addr }- see theroutemode section above; runs pre-dispatch on the client’s fullidentity.methodslist, not a single method’s isolated payload
HTTP Fetching
Capability: http_fetch.
Request/response shape (single JSON string in, single JSON string out, like every host function):
// Request
{
"method": "GET", // GET, POST, PUT, PATCH, DELETE, HEAD
"url": "https://...", // host must be in this plugin's allowed_hosts
"headers": { "Content-Type": "application/json" },
"body": null, // UTF-8 string, or omit/null for no body
}
// Response
{
"status": 200,
"headers": { "content-type": "application/json" },
"body": "...", // UTF-8 (lossy) response body
}
Constraints:
- Only
allowed_hostsare reachable (config-time allowlist) - All resolved IPs are checked against private/loopback/link-local/multicast/ cloud-metadata ranges (no SSRF), re-resolved at connect time (no DNS rebinding)
- Auth secrets are injected by the host from
http_fetch_auth_header+http_fetch_auth_secret_envconfig, applied after your headers and replacing any header of the same name you supplied - the secret is never visible to your plugin’s code or the distributed.wasmfile - No redirects by default; opt in per-plugin with
http_fetch_follow_redirects = true, and even then the whole redirect chain shares onetimeout_msbudget, not one budget per hop
Example:
#![allow(unused)]
fn main() {
let fetch_request = serde_json::json!({
"method": "POST",
"url": "https://idp.example.com/validate",
"headers": {"Content-Type": "application/json"},
"body": serde_json::json!({"token": token}).to_string(),
});
let response_json = unsafe { http_fetch(fetch_request.to_string())? };
let response: serde_json::Value = serde_json::from_str(&response_json)?;
let status = response.get("status").and_then(|v| v.as_u64()).unwrap_or(0);
if status != 200 {
return Ok(Json(AuthPluginResponse::Deny {
reason: format!("IDP returned {status}"),
}));
}
}
Provisioning Users
full_auth mode only.
Request:
{
"external_id": "...", // plugin-derived identifier, never a Keystone user_id
"user": {
"domain_id": "...", // must be in provision_domain_id / allowed_provision_domains
"name": "...",
"enabled": true, // optional
"extra": {}, // optional
},
}
Response: a bare JSON string - the opaque handle. Not
{"handle": "..."}.
Semantics:
- Creates a new Keystone
User(if not already created) - Records mapping
(plugin_name, external_id) -> user_id - Returns opaque handle (not the real
user_id) - present it back verbatim inAllow.resolved_identity - Idempotent: same
external_idon repeat calls returns a handle to the same user - Atomic: race-safe with concurrent requests
- Domain-scoped:
user.domain_idmust be in this plugin’sprovision_domain_idorallowed_provision_domains; onlydomain_id,name,enabled,extraare accepted - there is noidor password field a plugin can set
Use case: First login for a new external identity
Finding Users
full_auth mode only.
Request: a bare JSON string - the external_id.
Response: the handle as a JSON string, or null if not found.
Semantics:
- Looks up user by
(plugin_name, external_id)only - Not a general username search
- Cannot reach users provisioned by other plugins or via other methods
- For admin-linked identities: domain restriction is re-checked on every call (prevents stale links after a user is moved to a different domain)
Use case: Returning user login (idempotent after first provision)
Assigning Roles
full_auth mode only.
Request:
{
"resolved_identity": "...", // a handle THIS invocation's provision_user/find_user produced
"role": "member",
"target": { "scope": "project", "project_id": "..." },
// or: { "scope": "domain", "domain_id": "..." }
}
Response: an empty JSON value on success (traps/errors on rejection - see below).
Constraints (host-enforced):
rolemust be in this plugin’sassign_role_allowedlist- Target project/domain must be in
provision_domain_id/allowed_provision_domains - System scope is never allowed - there is no
systemvariant oftargetat all, so a plugin has no way to even express that request resolved_identitymust be a handle this exact invocation’s ownprovision_user/find_usercall produced - the same anti-impersonation constraint as identity binding itself
Use case: Grant initial roles on first user provisioning
Error Handling
A host function call returns Err (traps the guest call via ?) on any
violation of the constraints above - a disallowed domain, a role outside
assign_role_allowed, a host outside allowed_hosts, an invalid
resolved_identity, and so on. The failure reason is not returned to your
plugin in a structured, inspectable way - it fails the whole invocation closed
(ADR 0025 §7). Design your plugin to check what it can up front (e.g. only call
assign_role with roles you know are configured) rather than relying on host
errors for control flow.
Admin Identity Linking
full_auth plugins can authenticate pre-existing users (e.g., SCIM-provisioned)
via admin-authorized linking, without the plugin provisioning them.
API
Create link:
curl -X POST http://keystone:5000/v4/auth_plugins/{plugin_name}/identity_links \
-H "X-Auth-Token: $ADMIN_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"identity_link": {
"external_id": "sso_user_123",
"user_id": "existing-keystone-uuid"
}
}'
RBAC-tiered (ADR §4): system-scope admin may link any user; a domain-scoped
admin/manager may link only a non-system user in their own domain.
Re-linking an external_id that already has an entry is rejected
(409 Conflict) - DELETE the existing link first.
Note: SCIM convenience fields (
scim_provider_id,scim_external_id) are documented in ADR 0025 §4 but not yet implemented. Only the direct{external_id, user_id}body is accepted today.
Delete link:
curl -X DELETE http://keystone:5000/v4/auth_plugins/{plugin_name}/identity_links/{external_id} \
-H "X-Auth-Token: $ADMIN_TOKEN"
Also revokes the unlinked user’s live tokens.
Bulk revocation (on plugin compromise):
curl -X POST http://keystone:5000/v4/auth_plugins/{plugin_name}/revoke_all \
-H "X-Auth-Token: $ADMIN_TOKEN"
System-admin only. Disables every user the plugin provisioned or was linked to, deletes those identity links, and revokes their tokens. It does not revoke role assignments the plugin granted - review those separately against the CADF audit trail before re-enabling any disabled account.
Plugin Logic
No change needed in plugin code - find_user(external_id) works the same
whether the entry was created by provision_user or by an admin link.
Response Bounds & Safety
Claims Size
- Max 64 entries in the
claimsmap - Max 256 bytes per key
- Max 4 KiB per value
- Total response JSON capped at 64 KiB (rejected unparsed if exceeded)
Claims Namespacing
All full_auth-mode plugin claims are automatically namespaced under
plugin_claims.<plugin_name>.<key> in the policy input - never merged into the
top level. This prevents injection of privilege-relevant top-level keys.
#![allow(unused)]
fn main() {
// Plugin returns (Allow.claims):
// {"email": "user@example.com", "groups": ["admin"]}
// OPA sees (input.credentials):
// {"plugin_claims": {"my_plugin": {"email": "...", "groups": ["admin"]}}}
// A policy can read: input.credentials.plugin_claims.my_plugin.email
// It cannot inject top-level keys like is_system, effective_roles, etc. -
// those simply aren't where plugin claims live.
}
mapping-mode claims are not namespaced this way - they flow into the Mapping
Engine’s rule matching instead (ADR 0020), which has its own,
separately-reviewed guarantee that claim values only ever drive rule matching,
never become privilege directly.
Reserved Keys
A claim key named exactly plugin_claims, or prefixed with __keystone, is
rejected (the whole response fails closed) - except __keystone_workload_id in
mapping mode, which is required (see the mapping mode section above).
Testing & Debugging
Local Unit Tests
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_authenticate_valid() {
let req = AuthPluginRequest {
payload: MyPayload { username: "alice".to_string() },
headers: Default::default(),
remote_addr: Some("192.0.2.1".to_string()),
};
// Exercise your plugin's logic directly - `authenticate` itself
// requires a live extism host context for its `unsafe { provision_user(..) }`
// call, so unit-test the decision logic your plugin builds around it,
// not the wasm entry point end to end. End-to-end coverage belongs in
// integration tests (below) against a real Keystone instance.
assert!(!req.payload.username.is_empty());
}
}
}
Integration Testing
Test against a real Keystone instance (see Admin Guide - Plugins):
# 1. Build and copy plugin
cargo build --release --target wasm32-unknown-unknown
SHA=$(sha256sum target/wasm32-unknown-unknown/release/my_plugin.wasm | cut -d' ' -f1)
cp target/wasm32-unknown-unknown/release/my_plugin.wasm /etc/keystone/plugins/
# 2. Update keystone.conf with plugin config and SHA
# 3. Restart Keystone
systemctl restart keystone
# 4. Test authentication
curl -X POST http://keystone:5000/v3/auth/tokens \
-H "Content-Type: application/json" \
-d '{
"auth": {
"identity": {
"methods": ["my_plugin"],
"my_plugin": {"username": "test"}
}
}
}'
Debugging
Plugin load failures - keystone_auth_plugin_load_failure{plugin_name} on
/metrics, plus a CRITICAL log line naming the plugin and the mismatch:
grep "keystone_auth_plugin_load_failure" /var/log/keystone/keystone.log
Rate limit hits - check if the plugin is being rate-limited:
grep "rate_limited" /var/log/keystone/keystone.log
Timeouts/fuel/memory - a resource-bound violation fails the specific
invocation closed, audited via the plugin’s CADF trail (wasm_plugin.* events,
ADR §6.E) rather than a distinct log grep target - check the audit event
outcome/reason for the plugin’s authenticate/mapping/route calls.
Real-World Example: OAuth2 Provider Validation
Complete plugin that validates tokens from an external OAuth2 provider, provisioning a local user on first login and reusing it on subsequent ones.
#![allow(unused)]
fn main() {
use extism_pdk::{host_fn, plugin_fn, FnResult, Json};
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[host_fn]
extern "ExtismHost" {
fn http_fetch(request_json: String) -> String;
fn provision_user(request_json: String) -> String;
fn find_user(external_id_json: String) -> String;
}
#[derive(Debug, Deserialize)]
pub struct OAuthPayload {
pub access_token: String,
}
#[derive(Debug, Deserialize)]
pub struct AuthPluginRequest {
pub payload: OAuthPayload,
#[serde(default)]
pub headers: HashMap<String, String>,
#[serde(default)]
pub remote_addr: Option<String>,
}
#[derive(Debug, Serialize)]
#[serde(rename_all = "snake_case", tag = "decision")]
pub enum AuthPluginResponse {
Allow {
resolved_identity: String,
claims: HashMap<String, serde_json::Value>,
},
Deny { reason: String },
}
#[plugin_fn]
pub fn authenticate(req: Json<AuthPluginRequest>) -> FnResult<Json<AuthPluginResponse>> {
let token = req.0.payload.access_token;
let introspect_request = serde_json::json!({
"method": "POST",
"url": "https://oauth.example.com/introspect",
"headers": {"Accept": "application/json"},
"body": serde_json::json!({"token": token}).to_string(),
});
let fetch_response_json = unsafe { http_fetch(introspect_request.to_string())? };
let fetch_response: serde_json::Value = serde_json::from_str(&fetch_response_json)?;
let status = fetch_response.get("status").and_then(|v| v.as_u64()).unwrap_or(0);
if status != 200 {
return Ok(Json(AuthPluginResponse::Deny {
reason: format!("introspect endpoint returned {status}"),
}));
}
let body: serde_json::Value = serde_json::from_str(
fetch_response.get("body").and_then(|v| v.as_str()).unwrap_or("{}"),
)
.map_err(|e| format!("failed to parse introspect response body: {e}"))?;
let is_active = body.get("active").and_then(|v| v.as_bool()).unwrap_or(false);
if !is_active {
return Ok(Json(AuthPluginResponse::Deny {
reason: "token is not active".to_string(),
}));
}
let sub = body
.get("sub")
.and_then(|v| v.as_str())
.ok_or("no 'sub' claim in introspect response")?;
// Idempotent lookup first - avoids re-provisioning on every login.
let find_result_json = unsafe { find_user(serde_json::to_string(sub)?)? };
let existing: Option<String> = serde_json::from_str(&find_result_json)?;
let resolved_identity = if let Some(handle) = existing {
handle
} else {
let username = body
.get("preferred_username")
.and_then(|v| v.as_str())
.unwrap_or(sub);
let provision_request = serde_json::json!({
"external_id": sub,
"user": { "domain_id": "default", "name": username },
});
let handle_json = unsafe { provision_user(provision_request.to_string())? };
serde_json::from_str(&handle_json)?
};
Ok(Json(AuthPluginResponse::Allow {
resolved_identity,
claims: extract_claims(&body),
}))
}
fn extract_claims(token_claims: &serde_json::Value) -> HashMap<String, serde_json::Value> {
let mut claims = HashMap::new();
for key in ["email", "groups", "realm_access"] {
if let Some(value) = token_claims.get(key) {
claims.insert(key.to_string(), value.clone());
}
}
claims
}
}
Best Practices
- Keep plugins single-purpose - one auth logic, not multiple concerns
- Fail closed - when in doubt, deny the login, log the reason
- Validate external data - never trust HTTP responses or plugin input
- Use http_fetch carefully - SSRF protection is automatic, but credential handling must be reviewed
- Set reasonable timeouts -
timeout_msshould be shorter than your external service’s SLA + overhead, and covers the whole invocation including any redirect chain - Test failure paths - network timeouts, malformed responses, slow services
- Document claims - which external attributes map to which plugin claims
- Version plugins - git tag releases, pin SHA-256 checksums in deployments,
and bump
valid_sincealongsidesha256when a change should invalidate outstanding tokens (full_authmode only - see ADR §4 “Plugin Version Binding”) - Audit identity changes - every host-function call and
authenticate/mapping/routeoutcome is CADF-audited (wasm_plugin.*events, ADR §6.E) - Monitor rate limits - tune
invocation_rate_limit_per_minuteandmax_concurrent_invocationsbased on load
References
- ADR 0025 - Dynamic Auth Plugins - Threat model, design rationale, all constraints
- Admin Guide - Plugins - Deployment, configuration, operations
crates/auth-plugin-runtime/tests/fixtures/reference-plugin/src/lib.rs- the real, compiled reference plugin this guide’s examples are derived from- Extism PDK - Language SDKs (Rust, Go, Python, JS, C, Zig, …)
- Security Model - Authentication and authorization invariants
API
Performance comparison
TODO
Contributor Documentation
Running and testing the Keystone locally requires additional components (DB,
OpenPolicyAgent, etc). Easiest way to achieve it is to use the docker-compose or
the skaffold to deploy components into the small Kubernetes cluster.
Skaffold
When a kubernetes is available for the local development and testing the skaffold can be used to deploy Keystone, OPA, database and the python keystone together. This is very helpful for being able to test the compatibility between Keystone implementations and running the API tests that require the system to be up and running.
It is necessary to have any sort of the image registry running that can be accessed by the K8 to pull images from. It depends heavily on the concrete K8 implementation since some may come with the built-in registry that the local docker can push directly to or that the K8 accesses the images directly from the local docker. When not available the registry can be easily deployed locally.
Skaffold can be invoked with the following command to build and push images, deploy kubernetes manifests and watch for the changes for reload:
skaffold dev --default-repo localhost:5000 -p local
It might be useful to add --cleanup=false flag to the above command to prevent
skaffold from tearing down all resources when the process is stopped.
Currently the manifests are built to expose the Kestone within the K8 cluster
under: http://keystone.local (with certain routes pointing to the python
version and others to the rust), http://keystone-rs.local (rust version) and
http://keystone-py.local (the python version correspondingly). Depending on
the how the K8 is being deployed and access the Keystone may be directly
accessible from the localhost when i.e. the routes are added in the /etc/hosts
file. The manifests are not currently designed to be used for production
deployment.
With the keystone deployed in the Kubernetes running API tests can be performed with the following command:
KEYSTONE_URL=http://keystone-rs.local cargo nextest run --test api
The same can be also performed with the skaffold itself with test suites
packaged into containers and deployed into the Kubernetes. This is very helpful
to do a real functional/integration test for the Kubernetes based authentication
that requires a fully functional cluster. The same is true for the federation
tests where it is necessary to have a running IdP that can be integrated with
the Keystone.
# Build all container images saving the metadata into the `build.artifacts` file
skaffold build --profile local --default-repo localhost:5000 --output-file build.artifacts
# Deploy Keystone with database and OPA to K8s
skaffold deploy -a build.artifacts
# Run the tests inside the K8s
skaffold verify -a build.artifacts
The skaffold config is split into 2 modules: keystone and infra allowing
quicker redeployment of keystone only without touching the
keycloak/dex/selenium and co (skaffold deploy -a build.artifacts -m keystone). This is required to workaround a “feature” of skaffold attaching
tracking labels to all resources created from local manifests (including helm
files).
OpenStackClient (OSC)
Deploying Keystone in the Kubernetes makes it also possible to verify the
authentication flows using the osc that brings client support for all
authentication methods.
Depending on the Kubernetes cluster the address under which Keystone is
reachable may differ. As described above corresponding names should be added
into the /etc/hosts file.
clouds:
keystone-skaff:
auth:
auth_url: http://keystone-rs.local
username: admin
password: password
user_domain_name: Default
project_domain_name: Default
project_name: admin
domain_id: default
This way authentication using the passkey can be verified manually.