Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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:

  1. List operation MUST receive the all query parameters of the operation in the target.

  2. For Show operation the policy MUST receive the current record as the target (fetch the record and pass it into the policy engine).

  3. Update operation 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).

  4. Create operation works similarly as current oslo.policy with the desired state passed to the policy engine.

  5. Delete operation 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, None as 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, None as existing
  • Delete: Pass serde_json::to_value(stored_object)? as target, None as existing
  • List: Pass serde_json::to_value(query_params)? as target, None as 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.existing field is Value::Null when passed as None from the handler, not an absent key. Policies can check input.existing == null.

  • The input.target field is never null except deliberately (e.g., when no target object is relevant). For operations where the object is the existing stored resource, target carries 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": { ... } }

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_credential table describes the authenticators of the user (user_id as the primary key).

  • webauthn_state table 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_xxx should 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_id is populated with the first entry of the token audit_ids list. When this list is empty an error is being returned.
  • issued_before is set to the current time with the UTC timezone.
  • revoked_at is 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_ids property is compared against the database record. When this list is empty an error is being returned.
  • token.project_id is compared against the database record when present.
  • token.user_id is compared against the database record when present.
  • token.trustor_id is compared against the database record user_id when present.
  • token.trustee_id is compared against the database record user_id when present.
  • token.trust_id is compared against the database record trust_id when present.
  • token.issued_at is compared against the database record with revocation_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_id is set it is compared against token.domain_id and token.identity_domain_id.
  • When revocation_event.role_id is present it is compared against every of the token.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_buffer
  • oldest = utc.now() - expire_delta
  • DELETE 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_duration and conf.security_compliance.lockout_failure_attempts are not set the account is NOT locked.

  • When user_options.IGNORE_LOCKOUT_ATTEMPT is set user account is NOT locked

  • When user.failed_auth_count >= conf.security_compliance.lockout_failure_attempts the 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_count attribute.

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_at attribute 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 User structure 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 the password.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_OPT option enabled the current date time plus the conf.security_compliance.password_expires_days time is persisted as the password.expires_at_int property.

  • 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_verified to the current timestamp.

  • Effective role assignments of the user are taking into the consideration expiring group memberships through the list_user_groups respecting the expiring membershipts (independent of the idp_id) as expiring_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:

  1. Trust Establishment: Keystone is configured to trust a Kubernetes API server’s JWT issuer.
  2. Role Mapping: A Kubernetes Service Account (and namespace) is mapped to a specific Keystone Project/Role.
  3. 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.

ColumnTypeDescription
idString(64)Primary Key (UUID).
domain_idString(64)Domain ID (UUID).
enabledBooleanEnabled flag.
nameString(255)Unique name for this K8s backend configuration.
hostString(255)The URL of the Kubernetes API server (e.g., https://10.0.0.1:6443).
token_reviewer_jwtTextA long-lived JWT used by Keystone to access the K8s TokenReview API.
ca_certTextPEM encoded CA cert for the K8s API (optional for self-signed).
disable_local_ca_jwtBooleanEnable/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).

ColumnTypeDescription
idString(64)Primary Key (UUID).
auth_instance_idString(64)Foreign Key to kubernetes_auth_instance.id.
enabledBooleanEnabled flag.
token_restriction_idString(64)Foreign Key to token_restriction.id.
bound_service_account_namesTextList of allowed SAs (comma-separated or JSON).
bound_service_account_namespacesTextList of allowed Namespaces (comma-separated or JSON).
bound_audienceString(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

  1. Lookup: Keystone receives the request, identifies the role. It fetches the associated kubernetes_auth_instance config.
  2. Verification: Keystone calls the Kubernetes API (host) at the /apis/authentication.k8s.io/v1/tokenreviews endpoint using the token_reviewer_jwt or the user specified jwt when token_reviewer_jwt is unset. In the later case it is required that the service account has the system:auth-delegator ClusterRole. It can be granted with
kubectl create clusterrolebinding client-auth-delegator \
  --clusterrole=system:auth-delegator \
  --group=group1 \
  --serviceaccount=default:svcaccount1 ...
  1. Validation:
  • K8s returns the status of the JWT.
  • Keystone verifies that the kubernetes.io/serviceaccount/service-account.name and namespace claims match the bound_service_account_names and namespaces in the kubernetes_auth_role table.
  1. Token Issuance: If valid, Keystone issues a scoped token for the token_restriction_id defined 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.

  1. 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.

  1. 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.

  1. 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_id in HKDF info (§3.1)
  • F3 MEDIUM: Backup manifest AD now includes dek_version_u32 to 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-SHA256 to HKDF-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

LayerComponentRole
Consensusopenraft (Rust)Log replication, cluster membership, linearizability
LSM-TreefjallState machine and log store (SSD-optimized)
Serializationrmp-serde (MessagePack)Compact binary log entries
TransportgRPC over mTLSIntra-cluster Raft RPC (SPIFFE or Custom PKI)
Managementkeystone-manage CLICluster 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_KEK may supply a hex-encoded KEK. The process refuses to start unless --dev-mode and KEYSTONE_ALLOW_ENV_KEK=1 are explicitly set. After reading KEYSTONE_DEV_KEK, the process must immediately unset it via std::env::remove_var and 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_u32 into 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 the u64 type 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 the 2^31 rotation threshold.
  • Fjall State Machine (State DEK): Nonce is derived via HKDF-Expand(StateDek, info=PrimaryKey || version_u32, L=12). The version field starts at 0 for 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. u32 is 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.

ContextAssociated Data BindingAttack Prevented
Raft log entryterm ++ index (16 bytes, big-endian)Index-substitution (replay attacks)
Fjall state entry1b_tier_marker ++ domain_id ++ primary_keyKey-substitution and Read-Tier tampering
Metadata entryb"keystone-meta-v1" ++ meta_key_bytesMetadata confused with app data
Backup envelopeb"keystone-backup-v1" ++ snapshot_utc_epoch_be_u64 ++ dek_version_u32Time-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 --keygen or softhsm2-util --init-token for development), documented in the operator guide. The provider MAY auto-generate the key via CKM_AES_KEY_GEN on first startup if pkcs11_key_label is 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_file into a SecretSlice (zeroized on Drop), used for C_Login. It is held for the duration of the init_storage call — not scrubbed the instant C_Login returns, 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 existing tls_key_file/tls_cert_file convention (§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 (via tpm2_create/tpm2_evictcontrol or 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 from tpm_auth_file into a SecretSlice (zeroized on Drop) and used to authorize the TPM session. As with the PKCS#11 PIN (§2.5.1), it is held for the duration of the init_storage call 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 omit tpm_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.

TierLabelAllowed Read ModesExamples
0PUBLICLocal Read, LinearizableFeature flags, role display names
1INTERNALLocal Read, LinearizableDisplay attributes, internal configuration markers
2SENSITIVELinearizable Only (ReadIndex)Group memberships, active session tokens, API keys
3SECRETLinearizable 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 with PERMISSION_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 CRITICAL log 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:

  1. Generate a fresh DEK in memory with dek_version = current + 1.
  2. Wrap under the KEK and write to _meta:dek:pending via a Raft proposal, recording the committed Raft index as rotation_index in the same proposal. This guarantees the pending DEK and its version exist before any write can reference them.
  3. rotation_index serves as an unambiguous boundary: log entries at index < rotation_index use the retired DEK; entries at index ≥ rotation_index use the pending DEK.
  4. All new Raft writes at or after rotation_index use the pending DEK.
  5. 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.
  6. During rotation, both DEKs are active. Reads use the per-record dek_version to select the correct key deterministically — they never fall back via tag verification failure. If the dek_version is missing or ambiguous, the record is treated as corrupt and quarantined rather than probing both keys. Recovery path: Records with missing or ambiguous dek_version can be recovered from the Raft log by replaying the entry with the known rotation_index boundary 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 run keystone-manage storage recover --record to trigger recovery, which emits an audit log entry. For state entries, the per-record version field is incremented during re-encryption to produce a fresh nonce.
  7. 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.
  8. Post-rotation verification: A verification pass reads each record’s stored dek_version and 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:

  1. 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-operator role) and dual-control confirmation via ConfirmRotateDek from 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 a rotation_id timestamp and ignores _meta:dek:pending entries older than 5 minutes that were never confirmed, preventing an aborted emergency rotation from being resumed on restart as a normal rotation.
  2. 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 (not retired) in _meta:dek:revoked:<timestamp>, preventing its reuse for any decryption operation.
  3. Impact assessment: The operator queries the per-record dek_version to identify all records encrypted under the compromised DEK epoch. This determines the scope of potentially exposed data.
  4. Re-encryption: The background task re-encrypts all affected records under the new DEK following the standard CAS-on-version flow (§6 step 5).
  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.
  6. 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:

  1. Access to the Backup DEK (backup_dek role) in the KMS.
  2. Valid node identity credentials (SPIFFE SVIDs or Intermediate CA certs) for the new nodes before they join the cluster.
  3. 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):

  1. Core dumps must be disabled (RLIMIT_CORE = 0). The startup pre-flight verifies this and refuses to start if the limit is nonzero.
  2. Set PR_SET_DUMPABLE = 0 (Linux) to prevent ptrace and /proc/<pid>/mem access from co-located processes. The startup pre-flight verifies this and refuses to start if it fails.
  3. 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 deny rules reject any storage dependency that transitively stores key material without implementing ZeroizeOnDrop.
  • Key material types MUST NOT derive Debug or Display to 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 denies ptrace syscalls, and the PR_SET_DUMPABLE = 0 setting from the startup pre-flight prevents attach. Enforcement: (1) release builds strip profiling symbols (strip --strip-debug); (2) a seccomp profile denies ptrace syscalls (PR_SET_NO_NEW_PRIVS = 1 + SECCOMP_MODE_FILTER); (3) AppArmor deny rules block /proc/<pid>/mem access.

10. Security Invariants

Any code change violating the following is rejected at review:

  1. No plaintext on disk: Every byte is encrypted with AES-256-GCM before the write call returns.
  2. No DEK in plaintext outside mlock’d RAM: The DEK lives only in KMS-wrapped form on disk, and inside mlock’d Zeroizing buffers in memory.
  3. Strict mTLS: Auto-join is permanently disabled. SVID or SAN URI patterns are mandatory.
  4. No stale reads for sensitive data: Tier 2 and Tier 3 data must exclusively utilize the ReadIndex protocol.
  5. 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’s ClearQuarantine can 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 run keystone-manage storage clear-quarantine on the affected node, which requires storage-operator RBAC 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 a WARN log and increments a metric; two failures within 60 seconds emit an ERROR log and trigger an alert. Per-source-IP and per-identity rate limits apply (see §1): RotateDek limited to 2/hour, ClearQuarantine to 10/hour. Emergency rotation (RotateDekRequest{emergency: true}) requires dual-control approval from a second storage-operator via ConfirmRotateDek within 5 minutes.
  6. No environment-variable KEK in production: The --dev-mode flag and KEYSTONE_ALLOW_ENV_KEK=1 are explicitly required to start with an environment-provided KEK.
  7. 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 processing add_learner requests.
  8. DEK bootstrap ordering: DEK generation MUST target an already-mlock’d allocation; it MUST NOT be generated into an unlocked buffer and subsequently copied.
  9. Per-record write rate guard: When the per-record version counter exceeds a configurable threshold (default 2^30 updates 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 the u32 nonce space within a single DEK epoch.
  10. 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.
  11. Deployment validation: A pre-flight CI/CD check or Kubernetes admission webhook must reject any production service definition containing --dev-mode or KEYSTONE_ALLOW_ENV_KEK. This prevents silent flag smuggling via systemd, container images, or manifests.
  12. Startup pre-flight: At process startup (before KEK/DEK are loaded), the node must verify RLIMIT_CORE == 0 and PR_SET_DUMPABLE == 0. If either check fails, the node must refuse to start in production mode.
  13. 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 with fixedTPM | fixedParent and no duplication attribute. A KekProvider implementation that can export raw key bytes from the token/TPM is non-compliant regardless of how it is otherwise used.
  14. 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-mode KEYSTONE_DEV_KEK path (invariant 6).
  15. Authenticate-before-decrypt for Encrypt-then-MAC contexts: Any KekProvider that 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:

  1. SecurityContext::validate() validates the principal (identity, domain, user data), then checks authentication-context-specific constraints (Trust/AppCred user_id match).
  2. PrincipalInfo::validate() checks domain_id length, then delegates to IdentityInfo::validate().
  3. 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.
  4. ScopeInfo::validate() — checks domain/project/trust project enabled status.
  5. SecurityContext::fully_resolved() — calls validate() + 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 are pub(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; only Deref is implemented (no DerefMut), so the wrapped context is externally immutable.
  • AuthenticationResult — produced by a single auth method, may carry authorization from parent token
  • AuthzInfo — scope + roles extracted at authentication time. scope is pub, roles is pub(crate) with setters (set_roles, roles, try_set_roles)
  • ScopeInfo — enum capturing the authorization scope: Domain, Project, System, TrustProject, Unscoped. The TrustProject variant 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 constructing SecurityContext in test fixtures. Replaces positional for_testing() with named setters
  • IntoAuthContext (core) — conversion trait that forces the caller to provide context information when converting a provider error into AuthenticationError::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:

  1. SecurityContext::try_from(AuthenticationResult) constructs the raw context from authentication results
  2. ValidatedSecurityContext::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 from scope, calls ctx.validate_scope_boundaries(&scope) to guard scope override
    • If ctx.authorization() is None, calls ctx.set_authorization_scope(scope)
    • Populates user_domain for IdentityInfo::User by querying the resource provider — required before xvalidate()
    • Calls ctx.validate() to check principal integrity (user enabled, appcred/ trust user_id match)
    • Checks token expiration: if ctx.expires_at() < Utc::now(), returns AuthTokenExpired
    • Runs auth-context-specific validation:
      • ApplicationCredential: verifies user_id match and AC expiration
      • Trust: 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

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"))]:

  1. 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.

  2. SecurityContextTestingBuilder (accessed via SecurityContext::test_build()) — a named-setter builder that replaces the positional for_testing() approach. Required fields are authentication_context and principal; optional fields are token, authorization, expires_at, and token_restriction. The builder derives auth_methods from authentication_context.methods(). Both compile-time tests (#[cfg(test)]) and the optional mock feature 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:

  1. Extension injection (tests only): When ValidatedSecurityContext is present in request extensions, the extractor calls vsc.fully_resolved()? to verify the context is complete, then returns Auth(vsc). This path is #[cfg(any(test, feature = "mock"))]-guarded.

  2. Token header flow (production): The extractor reads X-Auth-Token, calls state.provider.get_token_provider().authorize_by_token(), which builds the context, resolves roles via ValidatedSecurityContext::new_for_scope(), and returns the validated context. The extractor then calls vsc.fully_resolved()?, and returns Auth(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:

  • authorization is None — returns Err(SecurityContextNotResolved): authorization has not been bound from the parent token or request scope
  • authorization is Some(AuthzInfo { scope: Unscoped, roles: None }) — passes: unscoped is valid with no roles
  • authorization is Some(AuthzInfo { scope: Scoped, roles: None }) — returns Err(SecurityContextNotResolved): scoped authorization must have roles
  • authorization is Some(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 AuthnPrincipalMismatch is 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 Credentialsauth_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’s project_id
  • AuthenticationContext::Trust → the trust’s project_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. Only Deref is implemented (no DerefMut), so there is no path to obtain &mut SecurityContext from ValidatedSecurityContext. Clone produces an independent copy that does not share state.
  • Private fields on SecurityContext: All 8 fields are pub(crate) in core-types. The ValidatedSecurityContext type in core is a different crate, so it also cannot mutate the fields directly — only through the getter/setter API. After wrapping, no &mut reference is reachable.
  • Explicit getter/setter API: All field access goes through getters (returning &T or Option<&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 in new_for_scope calls validate_scope_boundaries() when the requested scope differs from the context’s existing scope.
  • Test/production separation: test_new() and SecurityContextTestingBuilder are 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 AuthzInfo from FernetToken by fetching scope objects from the database (project, domain, project_domain). The role set is then re-queried by new_for_scope() for effective assignments (which may differ from token-frozen roles due to interim role removal).
  • Revoked token expansion: The authorize_by_token path expands token role data from database before checking revocation. This is necessary since the token may be considered expired by project_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

  • ValidatedSecurityContext adds one indirection layer to all handlers using Auth. The Deref implementation 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:
    1. Implementing the provider’s authentication logic
    2. Producing an AuthenticationResult (with authorization if applicable)
    3. Ensuring AuthenticationContext::methods() returns the correct method names
    4. Verifying validate_scope_boundaries() handles the new context variant
    5. 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 ScopeInfo variant requires updating validate_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::TrustProject variant boxes its payload as Box<TrustProjectInfo> to keep the enum size reasonable. Adding fields to TrustProjectInfo only 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:

CratePurpose
openstack-keystone-identity-driver-sqlSQL backend for identity
openstack-keystone-k8s-auth-driver-sqlSQL backend for Kubernetes auth
openstack-keystone-k8s-auth-driver-raftRaft backed backend for Kubernetes auth
openstack-keystone-spiffe-driver-raftRaft 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.rs discovers it automatically.
  • Compile-time enforcement: The build fails if any openstack-keystone-* dependency is missing pub 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.rs code generation, include!()).

Negative

  • Every openstack-keystone-* crate has a one-line no-op function. This is negligible overhead.
  • build.rs adds 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 generation
  • crates/keystone/src/lib.rsinclude!() of generated anchors
  • crates/cli-manage/src/db.rs — binary-side _INVENTORY_LINK reference

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 alembic control).
  • 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-compatible access and secret keys. 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_blob using 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_hash is 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:

ColumnTypeNullableDescription
idString(64)NoPrimary Key. (For EC2: SHA-256 hex of the access key, per §1 ID Generation).
user_idString(64)NoForeign key to the user who owns the credential.
project_idString(64)YesProject association (Mandatory for EC2 credentials).
encrypted_blobTextNoThe Fernet-encrypted secret string.
typeString(255)NoCredential type (e.g., 'ec2', 'totp').
key_hashString(64)NoSHA-1 hex digest of the primary key used for encryption (see §4).
extraTextYesExtensible 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 an access key.
  • Optional Fields:
    • project_id: Required if type is '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 if user_id is also omitted from the request body the server must reject the request with 400 Bad Request rather 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.

Read (GET /v3/credentials & GET /v3/credentials/{id})

  • Returns the credential reference.
  • Security: The encrypted_blob and key_hash are stripped from the response; the blob is decrypted to plaintext before serialisation.
  • Wire format of blob: Python Keystone returns blob as a JSON-encoded string (the same string form that was originally submitted on create), not as a nested JSON object — clients are expected to json.loads() it themselves. Keystone-NG must serialise the blob field in its API response the same way (a string value), not as a parsed/nested object, or existing SDKs and clients that call json.loads(cred["blob"]) will break against Keystone-NG.
  • List filtering — two-phase policy check (required to address CVE-2019-19687): The GET /v3/credentials endpoint first enforces the identity:list_credentials policy and applies driver-level hints (e.g. user_id, type query parameters). It then iterates the returned set and re-enforces identity:get_credential on 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 when enforce_scope is 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. Evaluating identity:get_credential against 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.

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 on input.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 assert project_id == delegated_project_id for delegated callers as a scope-drift tripwire that fails closed.
  • Show/Delete/Update: a delegated caller may only act on a credential whose project_id equals delegated_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_id outside the delegation’s own (delegated_project_id) project.
  • Create: a delegated caller’s new credential must set project_id equal to delegated_project_id; delegated callers cannot create unscoped credentials.
  • List: unaffected directly — the delegation boundary is enforced entirely by the per-item identity/credential/show re-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 own TrustProject scope) 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/null for every other auth method).
  • identity/credential/create denies when auth_type == "application_credential", unrestricted is falsy, and the target credential’s type == "ec2"; every other credential type is unaffected.
  • identity/os_ec2/create_credential applies the same restricted-app-cred denial unconditionally, since every credential created through that endpoint is ec2-typed.

Update (PATCH /v3/credentials/{id})

  • Updatable: type, blob, project_id.
  • Immutable: user_id and project_id may not be changed to point at a user or project the acting user has no access to (CVE-2020-12691). Within the blob, the following fields are additionally immutable: access (the EC2 access key — changing it would desynchronize the record from its SHA-256-derived id), trust_id, app_cred_id, and access_token_id.
  • Process: Updating the blob triggers automatic re-encryption with the current Primary Key and updates key_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: GET calls list_credentials_for_user filtered by type='ec2'. Results are flattened from the blob into explicit access and secret fields.
  • Automatic Creation: POST can automatically generate access and secret keys via UUIDs if they are omitted from the request.
  • Plaintext ID Lookup: For GET and DELETE operations, the credential_id provided 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 TOTP auth plugin calls list_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_user must 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 blob output is flattened into explicit access and secret fields.
  • 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 to fernet.
    • [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_setup and credential_rotate are 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 by key_hash tracking, 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):

    1. Setup (credential_setup) creates the first key as 0.tmp $\rightarrow$ 0 (staged; not yet used for encryption).
    2. On rotation (credential_rotate), the staged key 0 is renamed to (current_primary_index + 1) — e.g. if 1 is the current primary, 0 is renamed to 2. This renamed file is the new Primary. The old primary (1) is left in place, unchanged, and is still used for decryption.
    3. A fresh key is generated and written as the new staged 0.tmp $\rightarrow$ 0, ready for the next rotation cycle.
    4. 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.

  • Security:

    • Directory must not be world-readable.
    • Files are created with umask 0o177 and 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 to false) 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 credential table; a key_hash written by one service must be interpretable by the other’s credential_migrate and credential_rotate commands.

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_migrate and credential_rotate against an existing database.
  • The output is a lowercase hex string (40 characters), stored in the key_hash String(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.

  1. credential_setup: Populates the key_repository with initial keys. Must be run once during deployment.
  2. credential_migrate: Identifies credentials encrypted with older keys (where key_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) with COMMIT between batches. Safe to run concurrently with active auth — reads are unaffected, writes are idempotent.
  3. credential_rotate:
    • Safety Check: Verifies that all credentials are already encrypted with the current Primary Key (i.e. all key_hash values 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).

Encryption/Decryption Workflow

  • Encryption: Use Primary Key $\rightarrow$ Encrypt $\rightarrow$ Store encrypted_blob and SHA-1(primary key file bytes as UTF-8) as key_hash.
  • Decryption: Use MultiFernet (all active keys in repo, up to MAX_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:

  1. credential_migrate: Decrypts all credentials and re-encrypts them with the current Primary Key, updating key_hash to the SHA-1 of the new primary.
  2. 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.

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:

  1. Read params["SignatureVersion"] from the credentials dict.

  2. If SignatureVersion == "0" → use Version 0 algorithm.

  3. If SignatureVersion == "1" → use Version 1 algorithm.

  4. If SignatureVersion == "2" → use Version 2 algorithm.

  5. If SignatureVersion is 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 SignatureVersion field from the SigV4 spec. SigV4 requests therefore never carry SignatureVersion in params; the _v4_creds detection path is not a fallback — it is the primary detection mechanism for SigV4.
  6. 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():

  1. Instantiate the signer with the decrypted secret key.
  2. Call signer.generate(credentials) to produce the expected signature.
  3. If credentials["signature"] is absent → raise 401 (“EC2 signature not supplied”).
  4. Perform a constant-time string comparison between the client-supplied credentials["signature"] and the generated signature. Use hmac.compare_digest (or equivalent) to prevent timing attacks.
  5. 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.
  6. If either comparison succeeds → proceed to token issuance.
  7. 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"] or credentials["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:

  1. Request Parsing: Parse the JSON body; extract the credentials object. Accept the body under either the top-level "credentials" key, or a legacy "ec2Credentials" key (Python Keystone accepts both for backwards compat).
  2. Policy Enforcement: Enforce identity:ec2tokens_validate RBAC. Note: As of CVE-2025-65073, this endpoint now requires the caller to be authenticated (a user in the service group). Earlier versions treated /v3/ec2tokens as fully unauthenticated. Keystone-NG must enforce this policy and not mark the endpoint as @unenforced_api.
  3. Credential Lookup: Query the credential table using SHA-256(access) as the record ID. Return 401 if not found. Type guard: reject (401) any record whose type != "ec2". The lookup keys on SHA-256(access) == id, an invariant only established for ec2-type credentials at creation (see §1, “Automatic Creation”); without this guard a credential mislabelled to a non-ec2 type — thereby dodging the ec2-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.
  4. 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). Return 401 for any disabled entity.
  5. Secret Decryption: Decrypt encrypted_blob via the Fernet provider to recover the plaintext secret key.
  6. Timestamp Validation: Extract the timestamp per the version-dependent rules above. Reject with 401 if outside the [ec2] auth_ttl window.
  7. Signature Verification: Run the version-detection dispatch and _check_signature procedure described above.
  8. Token Issuance: On success, issue a standard Keystone token scoped to the credential’s project_id and user_id. Return the token in the response body and in the X-Subject-Token header.
  9. Failure: Return 401 Unauthorized for 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

StatusCondition
401Access key not found, signature mismatch, missing signature, or timestamp expired
401Owning user, domain, or project is disabled
403Policy check fails (identity:ec2tokens_validate)
409EC2 access key hash collision (create)
422Invalid blob JSON structure or missing fields
404User 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. The credential_id is 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 unrestricted to manage other credentials.
  • EC2 Project Match: When creating EC2 credentials via an application credential, the project_id must 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_uris migrated from mapping to IdentityProvider. 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:

  1. 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.
  2. 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.
  3. 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.
  4. 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_restriction payload 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 the SecurityContext when 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::Principal is 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".

VariantEvaluation Semantics
EqualsThe claim key must exist in the claims map, and at least one value must match the target after JSON-to-string normalization
AnyOfThe claim key must exist, and at least one claim value must match at least one target value in values
MatchesRegexThe 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 MatchesRegex evaluation runs with a 2-second deadline. If a single regex match exceeds the timeout, it short-circuits to false and emits a CADF access event with RegexMatchTimeout outcome. 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:

CriteriaSemantics
AllOfEvery child MatchCondition within the vector must evaluate to true
AnyOfAt least one child MatchCondition within the vector must evaluate to true
AllOfStrictIdentical 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:

ConditionSemantics
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:

  1. Enable gate. If ruleset.enabled == false, evaluation terminates immediately, returning None.
  2. Match gate. Evaluate rule.match criteria against the claims map. If it does not match, proceed to the next rule.
  3. Domain resolution. Determine user_domain_id according to the active DomainResolutionMode:
  • If identity.user_domain_id contains 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_mode is Fixed and the interpolated value does not match ruleset.domain_id exactly, 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_id to prevent domain escape.
    • Domain whitelist check. If ruleset.allowed_domains is present and the interpolated user_domain_id is not contained within it, fall back to ruleset.domain_id. This prevents a compromised IdP from injecting arbitrary domain identifiers to redirect principal resolution.
    • If no user_domain_id template exists, default to ruleset.domain_id.
  1. 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.
  2. user_id resolution. If identity.user_id is present, interpolate it. Empty result falls back to the enclosing domain (permitted unlike user_name).
  3. Group binding resolution. For each GroupAssignment, interpolate group_name using the truncating variant (overflow is acceptable for display fields). Emit a ResolvedGroupBinding containing only the resolved anchor UUID, domain, and strategy — the interpolated display name is discarded.
  4. 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_name causes the rule to be skipped during evaluation.
  • The enclosing_domain_id macro 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:

  1. System-service shortcut convergence. If ctx.is_admin(), set the is_system flag on the context. This unifies local loopback credentials with remote service accounts.

  2. Principal dispatch. For IdentityInfo::Principal, proceed with shadow registry lookup. For IdentityInfo::User, use the existing role resolution path (unchanged).

  3. Shadow registry bridge. Look up VirtualUserMetadata using the virtual user_id from the token. If missing or disabled, return an authentication error.

  4. System flag propagation. If shadow_meta.is_system == true, set the system service flag on the context, enabling control-plane shortcut bypasses.

  5. Live ruleset fetch. Use shadow_meta.mapping_id to resolve the index key index:mapping_id:<mapping_id>, which yields (domain_id, provider_id) coordinates, then fetch the live MappingRuleSet. If disabled, abort.

  6. 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 with RulesetVersionMismatch containing both shadow and live versions for incident response audit trail.

  7. Rule existence check. Verify that shadow_meta.matched_rule_name still exists in the live ruleset. If the rule was removed, abort with MappingRuleNoLongerExists.

  8. 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.
  9. Group role resolution. For each resolved_group_binding in the shadow record, resolve group roles from the assignment provider:

    • On success, extend effective roles.
    • On failure with GroupStrategy::Get, abort with GroupNotFound.
    • 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.
  10. Token restriction application. If context has a token restriction, narrow the effective roles accordingly.

  11. Empty role check. If no roles were accumulated and the scope is not unscoped and the context is not a system service, return ActorHasNoRolesOnTarget.

  12. 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 ClaimsOrMapping domain 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 Fixed domain resolution with nested AnyOf match 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)

  1. The Nova compute driver forwards its signed SPIFFE SVID certificate context to keystone-rs.
  2. The SPIFFE ingress provider validates the cryptographic signature using the matching SpiffeTrustResource trust bundle PEM. It flattens the workload metrics into clean text claims (spiffe.id, spiffe.trust_domain) and hands them to the Mapping Engine.
  3. The engine matches the rule "nova-to-neutron-control-plane" and reads its explicit identity properties, picking up the is_system: true instruction.
  4. The system derives the persistent HMAC-SHA256 user_id for the workload. It executes an atomic transactional upsert against the shadow virtual user registry (detailed in Section 7.2 below), recording mapping_id (to anchor the live ruleset), matched_rule_name: "nova-to-neutron-control-plane", capturing resolved_user_name and resolved_group_bindings from the live claims map, and immutably recording is_system: true into the registry row. Once set, this flag is preserved across all subsequent upserts — the is_system privilege cannot be revoked by rule modification alone.
  5. The token engine compiles a native SystemScope Fernet Token payload variant directly containing the virtual user_id. The token is returned to the Nova driver.

Phase B: Token Verification (Roundtrip)

  1. The Nova driver calls Neutron to wire up a VM interface, attaching its token. Neutron hands the token back to Keystone’s authorize_by_token endpoint for verification.
  2. The token provider decrypts the Fernet payload, matching the SystemScope layout and pulling the virtual user_id.
  3. The engine builds an unverified, raw SecurityContext initializing the identity as an unbacked IdentityInfo::Principal.
  4. The handler calls ValidatedSecurityContext::new_for_scope(). Inside calculate_effective_roles(), the engine reads the shadow user profile from FjallDB using the user_id. It uses mapping_id to look up the index key index:mapping_id:<mapping_id> which resolves the (domain_id, provider_id) coordinates, then fetches the live MappingRuleSet from data:mapping:v1:<domain_id>:<provider_id>. It encounters the active is_system: true flag.
  5. The method calls ctx.set_system_service_flag(true), applying the control-plane shortcut bypass directly to the context memory segment. When projected into Credentials for policy analysis, the OPA engine registers is_system: true and 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:

  1. 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. The cluster_salt is 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 knowing provider_id and workload_id cannot reverse the salt or enumerate shadow registry keys. Identical principals always resolve to the same user_id within a cluster, while cross-cluster correlation is blocked by the per-cluster salt.

  2. Domain existence validation. The effective_domain is determined from MatchResult.user_domain_id:

    • If user_domain_id is a valid UUID, check index:auth:domain_id:<uuid> for existence. If the domain exists, use it.
    • If user_domain_id is a human-readable slug, check index: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_id is absent or the above checks fail, fall back to ruleset.domain_id (the enclosing domain).
  3. Read existing shadow record. Attempt to fetch from user:v1:virtual:<user_id>.

  4. Merge or create.

    • Update path (existing record): Refresh mapping_id, matched_rule_name, resolved_user_name, resolved_group_bindings, and snapshotted authorizations. Update ruleset_version from the match result and last_authenticated_at to current timestamp. Set enabled: true — a successful match indicates the principal is active; if the record was previously deactivated, successful authentication reactivates it. The created_at timestamp is immutably preserved from initial creation. The is_system flag 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. Revoking is_system requires setting enabled: false (deactivation) via the provider API.
    • Insert path (new record): Create a fresh VirtualUserMetadata populated with all fields from the match result, the validated effective_domain, enabled: true, and the current timestamp for created_at and last_authenticated_at.
  5. Persist. Write the merged or new record atomically to the shadow keyspace.

7.3. Validation Error Code Reference

Error VariantHTTP StatusJSON Detail KeyTriggering Condition
SystemTokenShadowing(key)422"detail.system_token_shadowing"Template references ${claims.enclosing_domain_id}
DomainClaimRequired422"detail.domain_claim_required"ClaimsOnly mode without ${claims.*} in user_domain_id
DomainOverrideInFixedMode422"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
ShadowRegistryConflict409"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
MappingRuleNoLongerExists403"detail.mapping_rule_removed"Shadow record references a rule name missing from the live ruleset (rule name omitted from error response)
MappingDisabled403"detail.mapping_disabled"Ruleset enabled flag is false during verification
MappingNotFound404"detail.mapping_not_found"No ruleset exists at the computed keyspace coordinate
SystemMappingIsImmutable422"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
SystemScopeRequiresIsSystem422"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)
RulesetVersionMismatch401"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 PurposeKey Namespace PatternValue Payload
Global Domain Slug Indexindex:auth:domain_slug:<domain_slug>String domain_id (UUIDv4)
Scoped Mapping Indexindex:auth:mapping:<domain_id>:<mapping_name>String provider_id (Slug)
Global Mapping ID Indexindex:mapping_id:<mapping_id>{"domain_id": "...", "provider_id": "..."}
Global JWT Invariant Indexindex:auth:jwt:<sha256(iss+"\0"+aud)>{"domain_id": "...", "provider_id": "..."}
Global Client Indexindex:oauth2:client:<client_id>{"domain_id": "...", "provider_id": "..."}
Virtual User Shadow Recordsdata:user:virtual:<user_id_hmac>VirtualUserMetadata (Struct object)
OIDC Crypto Resourcedata:federation:oidc:<domain_id>:<provider_id>OidcProviderResource (Struct)
K8s Crypto Resourcedata:k8s_auth:<domain_id>:<provider_id>K8sClusterResource (Struct object)
SPIFFE Crypto Resourcedata:spiffe:<domain_id>:<provider_id>SpiffeTrustResource (Struct object)
OAuth2 Client Resourcedata:oauth2:client:<domain_id>:<provider_id>OAuth2ClientResource (Struct object)
Unified ABAC Rulesetdata: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.rules contains at least one target where identity.is_system == true, and ctx.is_admin() == false (no SystemAdmin credentials), the call is rejected with HTTP 403 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:
  • Domain confinement — for Authorization::Project, project_domain_id must match the operator’s effective domain UUID; for Authorization::Domain, domain_id must match the operator’s domain. Cross-domain mappings are rejected with 403 Forbidden (CrossDomainMapping).
    • Role grant parity — for every Authorization, the operator must hold each role listed in roles on the target scope. Failure returns 403 Forbidden (RoleGrantUnauthorized).
    • System scope restrictionAuthorization::System requires is_system: true on the mapping rule and is_admin() on the operator. Non-admin operators are rejected with 422 Unprocessable Entity (SystemScopeRequiresIsSystem).
    • Group assignment authority — for every GroupAssignment, the operator must hold admin on the target group. Failure returns 403 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.

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 Entity response. System mappings can never be updated; changes require an explicit DELETE statement followed by a fresh POST initialization block to preserve clean audit separation.
  • Domain Immutability: The domain_id field is structurally immutable upon ruleset creation. Any PUT request attempting to modify domain_id to a different value is rejected with HTTP 422 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 Entity if 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 deactivated VirtualUser record
    • Effect: Sets enabled: false, triggers token revocation pipeline (revocation:v1:user:<user_id>), preserves forensic record for audit trail
  • Enable (Reactivate) Virtual User:

    • HTTP Method / Path: PATCH /v4/virtual_users/{user_id}/enable
    • Response: 200 OK — returns the reactivated VirtualUser record
    • Effect: Sets enabled: true, re-activates the principal for future authentication
  • Get Virtual User Profile:

    • HTTP Method / Path: GET /v4/virtual_users/{user_id}
    • Response: 200 OK — returns full VirtualUser metadata including snapshotted authorizations, resolved groups, and activity timestamps

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:

  1. Passes regex_syntax::Parser AST validation — detects invalid syntax at parse time
  2. Lacks nested quantifiers(a+)+, (a*)*, (a{2,})* are rejected by recursive AST walk detecting Repetition nodes that directly contain another Repetition or alternation group as their child expression
  3. Lacks unbounded alternation under quantifiers(a|a)+, (a|b|c)* with overlapping branches are rejected
  4. 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::System variant, the parent rule must have identity.is_system == true. Otherwise, reject with SystemScopeRequiresIsSystem.
  • Domain confinement. For Authorization::Project, project_domain_id must match the operator’s effective domain UUID. For Authorization::Domain, domain_id must match the operator’s domain. Cross-domain mappings are rejected with CrossDomainMapping or DomainMappingUnauthorized.
  • Role grant parity. For every role listed in an authorization’s roles vector, 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 returns RoleGrantUnauthorized.
  • Group assignment authority. For every GroupAssignment, the operator must hold admin on the target group_id. Failure returns GroupAssignmentUnauthorized.

Mapping Definition Validation

The validate_mapping_definition function orchestrates all write-time guards:

  1. Template claim extraction. Extract every ${claims.<key>} reference from user_name, user_id, and user_domain_id templates. If any key equals enclosing_domain_id, reject with SystemTokenShadowing to prevent domain context shadowing.

  2. Regex safety. For every MatchesRegex condition reachable via walk_all_claim_conditions(), invoke ReDoS validation. Any failure short-circuits with RegexSafetyViolation.

  3. Authorization bounds. Invoke the two-tier authorization validation against the operator’s SecurityContext, project roles, domain roles, and group roles.

  4. Domain resolution mode enforcement. Non-admin operators are strictly prohibited from creating ClaimsOrMapping or ClaimsOnly rulesets, as these modes enable domain escape and cross-domain virtual user creation. If a non-admin operator specifies these modes, reject with DomainResolutionModeRequiresAdmin.

  5. Mode-internal consistency.

    • ClaimsOnly: The user_domain_id template must contain at least one ${claims.*} reference. Otherwise, reject with DomainClaimRequired.
    • Fixed: The user_domain_id must NOT contain any ${claims.*} reference. Otherwise, reject with DomainOverrideInFixedMode.
    • 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 with 413 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$ Timestamp
  • revocation: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 TypeTriggering Condition
controlMapping CRUD operations (create, update, delete, mutate)
accessFailed authentication attempts, RulesetVersionMismatch rejections
maintenanceJanitor shadow record deactivations, archive cleanup deletions, virtual user enable/disable, token revocation pipeline activations
privilegedAdmin 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 FieldNew Model Location
r#type (Oidc/Jwt)Dropped. IdentitySource::Federation covers both lines.
bound_audiencesClaimCondition::AnyOf { claim: "aud", values: [...] }
user_name_claimidentity.user_name = "${claims.<user_name_claim>}"
user_id_claimidentity.user_id = Some("${claims.<user_id_claim>}")
groups_claimgroups.push(GroupAssignment { group_id: compute_sha256_uuid(&claims.<groups_claim>), group_name: "fed_grp:<provider_id>:${claims.<groups_claim>}", strategy: CreateOrGet })
token_project_idAuthorization::Project — Project UUID is taken directly from legacy token_project_id.
token_restriction_idObsolete. Whittled role targets migrate directly into Authorization::Project.roles.

11.2. Claim Flattening Per Provider (Ingress Adapter Contract)

ProviderSource DataFlattening ConventionUnique Workload Key Invariant
OIDC / JWTJWT ID token claimsFlat string mappings via dotted pathways (email, user.profile.id)Value string of the sub claim element
KubernetesK8s TokenReview JWTk8s.serviceaccount.name, k8s.serviceaccount.namespace, k8s.audFormatted invariant: <serviceaccount_name>:<serviceaccount_namespace>
SPIFFESPIFFE SVID certspiffe.id, spiffe.trust_domainFull 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 MappingProvider trait exposing create, get, update, delete, enable, disable, and list operations against FjallDB.
  • Implement the Raft driver for mapping mutations: serialize MappingRuleSet payloads, enforce Raft consensus ordering, and handle snapshot/compaction.
  • Implement write-time validation pipeline: regex ReDoS safety, rule name uniqueness, template safety, allowed_domains intersection 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, and POST endpoints under /v4/mappings/ (§9).
  • Implement POST /v4/mappings/{mapping_id}/rules/mutate for imperative rule adjustments with relative anchoring.
  • Wire MappingEngine into the ingestion pipeline: ingest flattened claims, iterate ruleset, produce MatchResult.
  • 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 SpiffeTrustResource authenticator to emit flattened claims, invoke the mapping engine, and consume MatchResult.
  • 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_system principals issued via shadow registry.

Phase 4: Kubernetes Auth Provider Migration

Migrate the K8s TokenReview authenticator to the mapping engine.

  • Rewrite K8sClusterResource authenticator to flatten TokenReview claims and invoke the mapping engine.
  • Create K8s rulesets via the mapping provider, demonstrating nested match criteria and AllOfStrict guards.
  • Enable shadow registry upsert for K8s service account principals.
  • Deprecate legacy K8s_auth role.
  • 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 OidcProviderResource authenticator to invoke the mapping engine.
  • DONE: flatten_federation_claims() helper with per-claim (4096 bytes) and total map (64 KiB) size caps in crates/keystone/src/federation/api/common.rs.
  • DONE: OIDC callback handler rewritten to use flatten_federation_claimsMappingAuthRequestauthenticate_by_mapping pattern in crates/keystone/src/federation/api/oidc.rs (replaced 145+ lines of manual FederationBuilder/UserCreateBuilder/list_groups/create_group/set_user_groups_expiring CRUD).
  • 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 Mapping migration utility (mapping_to_ruleset_create) in crates/core/src/mapping/migration.rs using field translation table (§11.1).
  • DONE: ClaimsOrMapping and ClaimsOnly domain resolution modes enabled for Federation scenarios (engine handles all modes uniformly for all IdentitySource variants).
  • DONE: IdentityMode::Local path in authenticate_local (crates/core/src/mapping/service.rs) with find_or_create_federated_user and sync_user_groups (group membership sync on every login).
  • Remove token_restriction payload generation for federated principals; all scoping is handled natively by Authorization fields in MatchResult.
  • DONE: token_restriction_id field removed from Mapping, MappingUpdate, and related SQL drivers (federation-driver-sql). The OIDC flow now uses Authorization::Project from 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, MappingUpdate types removed from core-types and api-types. mapping_id removed from AuthState core type and FederatedAuthState DB entity. MappingNotFound, MappingTokenProjectDomainUnset, MappingTokenUserDomainUnset error variants removed from FederationProviderError. oidc_scopes moved from legacy Mapping into IdentityProvider core and API types. Auth flow now requires default_mapping_name to be set on the IDP; no longer accepts mapping_id or mapping_name override in IdentityProviderAuthRequest. Database tables federated_mapping and mapping are retained for backward compatibility but no longer managed by active CRUD code.
  • DONE: Allowed redirect URIs migrated to IdentityProvider. The allowed_redirect_uris field was moved from the legacy Mapping to the IdentityProvider core/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 previous mapping.allowed_redirect_uris path.
  • Deliverable: All federation authentication fully mediated by the unified mapping engine; legacy token_restriction pattern eliminated.

13. Implementation Deviations from ADR Spec

This section documents decisions made during implementation that deviate from the original specification.

D1. MappingRuleSetprovider_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. DomainResolutionModeallowed_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 FixedClaimsOrMapping (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. MappingRuleprovider_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_idString 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_audiences is converted to a single ClaimCondition::AnyOf on the "aud" claim, rather than individual equality checks per audience.
  • groups_claim generates a single GroupAssignment with group_id: None and strategy: CreateOrGet, deferring group resolution to authentication time.
  • token_project_id generates an Authorization::Project with an empty roles vector — the operator must populate roles post-migration.
  • bound_claims entries are added as individual ClaimCondition::Equals conditions within an AllOf criteria, preserving their original semantics.
  • r#type (Oidc/Jwt) is dropped since IdentitySource::Federation covers both flows.
  • token_restriction_id is dropped as obsolete; scoping is now handled by Authorization fields.
  • allowed_redirect_uris and oidc_scopes are 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: None semantics specified as “no restriction” (Invariant 5);
  • F4 LOW: compute_deterministic_user_id input contract specified as client_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 PurposeKey Namespace PatternValue Payload
API Client Crypto Resourcedata: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:

  1. lookup_hash is computed as SHA-256(entropy) to serve as the database index.
  2. secret_hash is computed as Argon2id(entropy) for cryptographic verification.
  3. 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

  1. 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).
  2. The middleware computes the fast SHA-256(entropy) to derive the lookup_hash.
  3. 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

  1. The middleware queries FjallDB for data:api_client:v1:<domain_id>:<lookup_hash>.
  2. It verifies enabled: true and current_utc_seconds < expires_at.
  3. 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_header chain (X-Forwarded-For by default, or RFC 7239 Forwarded by explicit opt-in), then walk right-to-left, returning the first address that is not in the statically configured trusted_proxies CIDR 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 in trusted_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 against allowed_ips CIDR blocks. If allowed_ips is None, 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_at is 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:create
  • identity:api_key:list
  • identity:api_key:update
  • identity:api_key:revoke
  • identity: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. Sets enabled: false, stamps revoked_at and revoked_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::update MUST reject (409 Conflict) any patch that sets enabled: true on a key whose revoked_at is 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 prevent client_id leakage in proxy access logs).
  • Authentication Required: Strictly requires DomainManager or SystemAdmin credentials 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_hash to protect legitimate shared IdP egress IPs. Argon2id verification is constrained to a bounded spawn_blocking pool that sheds load (503 Service Unavailable) if saturated.

B. Argon2id Parameters & Timing Side-Channels

  • Measures: Argon2id parameters are globally defined in keystone.conf with 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 the argon2 crate’s verify_password (not a separate manual subtle::ConstantTimeEq step – verify_password already provides this property, so a second comparison would be redundant).

C. Write-Time is_system Prohibition

  • Measures: Allowing an API Key to hold ScopeInfo::System is highly dangerous. To prevent silent failures during auth-time, the prohibition is shifted to rule creation. If a DomainManager attempts to create or update a mapping rule where the provider_id belongs to an IdentitySource::ApiClient, and any authorization grants is_system: true or Authorization::System, the Mapping Engine CRUD API rejects it immediately with 422 Unprocessable Entity. The same write-time guard also enforces a domain-scope-only allowlist for IdentitySource::ApiClient rulesets: once is_system/Authorization::System is excluded, every remaining authorization MUST be Authorization::Domain – API Keys are domain-owned machine identities (§2) – so Authorization::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).
  1. The client_id is excluded from the token format, neutralizing capability oracle attacks.
  2. The Axum middleware actively scrubs the Authorization header 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_header chain (X-Forwarded-For by default; RFC 7239 Forwarded requires explicit opt-in), then the chain is walked right-to-left and the first address not in trusted_proxies is 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 bypassing allowed_ips by routing through an untrusted intermediate proxy that prepends a spoofed originating IP to the forwarding chain before a trusted proxy. allowed_ips: None means no IP restriction is applied.

F. Janitor Disablement, Asynchronous Drift & Physical Reclamation

  • Finding: Asynchronous last_used_at writes 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.
  1. Physical Reclamation: To prevent unbounded keyspace bloat, the janitor executes a secondary garbage-collection phase. Any ApiClientResource containing a revoked_at timestamp older than 365 days is permanently purged from FjallDB.

  2. 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.

  1. No-authorizations → authentication failure. hydrate_ephemeral_context MUST return Err(AuthenticationError::NoAuthorizationsFound) when the UME resolves zero authorizations for a key. It MUST NOT produce a ValidatedSecurityContext with ScopeInfo::Unscoped and an empty role set.

  2. Single-scope enforcement. A key MUST NOT authenticate if its UME mapping resolves to more than one authorization entry (MultipleScopesForbidden).

  3. System scope prohibited at ingress. An Authorization::System match in hydrate_ephemeral_context MUST return SystemScopeForbiddenForApiKey. 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.

  1. 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.

  2. allowed_ips: None means unrestricted, not deny-all. When allowed_ips is absent from an ApiClientResource, the IP check MUST be skipped entirely. Implementations MUST treat a missing field and Some([]) identically (no restriction).

  3. compute_deterministic_user_id MUST be derived from client_id. The ephemeral user_id is computed from the key’s unique client_id UUID, not from provider_id. Two distinct keys sharing a provider_id MUST produce different user_ids so their audit records are not conflated.

  4. Dummy-hash timing parity. When no ApiClientResource is found for a given lookup_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.

  5. 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.

  6. Revocation is irreversible via the update surface. ApiKeyApi::update MUST reject with a conflict error any patch that would set enabled: true on an ApiClientResource whose revoked_at is Some. A revoked key MUST NOT become authenticatable again through PUT /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_system prohibition (§6.C), plus the domain-scope-only allowlist (Invariant 3a): both hydrate_ephemeral_context (crates/core/src/api/api_key_auth.rs) and the write-time mapping validation (crates/core/src/mapping/validation.rs) accept only Authorization::Domain for IdentitySource::ApiClient rulesets, since API Keys are domain-owned machine identities (§2). Authorization::Project is rejected as a consequence of the allowlist, not as a named special case. The simulate-access dry-run endpoint (§5.E) mirrors this and reports matched: false for any non-domain match.

    • The storage layer and internal ApiKeyApi/ApiKeyBackend traits (§2, §5.D) with a Raft-backed implementation, including the janitor’s cross-domain list_all and hard-delete purge operations (§6.F).

    • Rate limiting (§6.A).

    • The OPA policies for §5.A (policy/identity/api_key/), plus a show policy for the GET /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. update rejects (409 Conflict) re-enabling a revoked key (Invariant 9, §5.C), enforced in ApiKeyService::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-access endpoint (§5.E). Deviates from the literal request shape in one way: the payload also carries domain_id alongside client_id, because this implementation’s storage partitions ApiClientResource by domain (§2.A), making a client_id-only lookup impossible without it. The same constraint applies to show/update/revoke, which take domain_id as a query parameter rather than encoding it in the (flat, ADR-specified) URL path. It also does not call MappingApi::authenticate_by_mapping – that path may provision a real user row for IdentityMode::Local rules, an unacceptable side effect for a dry-run endpoint – and instead evaluates the ruleset and reads the matched Authorization’s roles directly.

    • The janitor (§6.F): an in-process, leader-gated tokio::time::interval sweep (mirroring the storage crate’s existing emergency-rotation confirmation-timeout sweeper in crates/storage/src/app.rs) that disables keys inactive beyond janitor_inactive_days + janitor_grace_days, purges tombstones older than janitor_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 in JanitorReport::errors, not propagated, so it cannot stall the rest of the pass.

      Action strings are intentionally more specific than this ADR’s earlier control/maintenance category wording (revoke/disable_inactive rather 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 through openstack_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 DomainManager role’s formal ratification in ADR 0002. In the interim, the OPA policies enforce the equivalent scoped privilege using this codebase’s existing manager role (§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.conf INI 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 (governor directly).
  • Allows operators to define quotas or selectively disable limits entirely via the standard INI application configuration file.
  • Is explicitly invoked within the axum handlers rather than globally.
  • Emits standardized, RFC-compliant HTTP 429 error responses with Retry-After without 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:

ParameterMinimumMaximum
burst_size1100,000
replenish_rate_per_second1100,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 from governor’s NotUntil error 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:

  1. No Hardcoded Limits: burst_size and replenish_rate MUST originate from the keystone.conf INI configuration state.
  2. Fail-Hard Initialization: If a governor is configured with enabled = true but cannot be initialized (e.g., invalid parameters such as replenish_rate_per_second = 0), the application MUST fail to start. Silent fallback to no-limiting is a security regression.
  3. 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-After header. No additional headers that expose the rate-limit key or any identifying information should be present, to prevent user enumeration via rate-limit probing.
  4. 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.
  5. Distinct Buckets: Different operational contexts MUST use physically separate RateLimiter instances in the application state. Multiplexing different entity types into the same keyed store is prohibited.
  6. Monotonic Clock: Rate limiters MUST use governor::clock::MonotonicClock to prevent NTP backward shifts from resetting quota windows.
  7. 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. Latin a) 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.
  8. 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.
  9. 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_proxies is non-empty, the client IP MUST be extracted from the first untrusted hop in exactly the configured trusted_header (X-Forwarded-For by default; RFC 7239 Forwarded by explicit opt-in), and only when the direct peer IP falls within a configured trusted CIDR range. When trusted_proxies is 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.
  10. Minimum Retry-After: The Retry-After header 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: governor uses in-memory DefaultKeyedStateStore. Each keystone-rs pod 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 /64 network prefix, which corresponds to the subnet allocated to a single host’s privacy extensions. Per-/128 precision 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: DefaultClock uses SystemTime, which can be adjusted backward by NTP drift, resetting quota windows. Using MonotonicClock (required by Invariant 6) prevents this. The tradeoff is that MonotonicClock has 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_proxies empty 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 configure trusted_proxies to match their load balancer’s CIDR(s) before enabling the global IP limiter in proxied deployments. The startup log SHOULD emit a warning when global_ip_limiter is enabled and trusted_proxies is 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 alice to trip the limit (429), but requests for alice_nonexistent return 401, the attacker learns that alice is 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 DefaultKeyedStateStore performs 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/tokens does 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 governor crate must be added to the workspace Cargo.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_id CSPRNG requirement, initiator.host sanitization rules, refresh_hmac_key version-collision fix, HMAC key retention policy, cross-node spool tamper detection, and map_event_to_action dangling-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.

  1. Ingress (Auth Extractor): The Axum middleware already injects a request-id header (UUIDv4). If the client already sent a request-id header, the middleware must strictly ignore its value and overwrite it with a fresh UUIDv4 (prevent client-controlled correlation spoofing). correlation_id is derived from this header value, not from the request token. Extracts Initiator from fully resolved ValidatedSecurityContext, token parse failure: outcome: "failure", Initiator is all "unknown" (no partial data from untrusted payload). On partial validation failure (token parsed but policy/scope failed): uses VerifiedFernetToken from vsc.verified_token() to extract sanitized initiator. For endpoints with pre-auth identity signals (EC2 access key, federation idp_id), include non-PII identifiers as initiator.host or a custom attachment — these don’t require a validated context and don’t risk PII leakage.

  2. Completion (Middleware): Post-handler extracts CorrelationId and ReadOnlyInitiator, 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: Initiator has only UUIDs. Human-readable fields (usernames, emails) are excluded by design.
  • PII Redaction: username, display_name, email_address, project_name, domain_name excluded. Future fields require opaque wrappers.
  • Outcome Isolation: outcome_reason limited to sanitized variant name.
  • HMAC Signing: CadfEvent wraps (CadfEventPayload, signature) via serde(flatten). Private fields prevent unsigned construction. Per-node signing key derived via:
    HKDF-Expand(KEK, info="keystone-audit-hmac-v1:{node_id_utf8}", L=32)
    
    The node_id suffix 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 uses node_id_u64_be for 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 as ArcSwap<(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 by refresh_hmac_key itself) 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 whose observer.node_id does 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 (unique node_id:uuid).
  • Attempt Reconciliation: SIEM treats an Attempt with no corresponding Success/Failure within 300s as outcome: unknown and 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 only VerifiedFernetToken (constructible post-verification via _score). Partial context failure = authorization issue, not crypto issue.
  • Provider Error Sanitization: extract_provider_name uses 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."

  • 0016-v2: HMAC key from KEK. KEK rotation calls refresh_hmac_key().
  • 0017: ValidatedSecurityContext in 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

  1. Provider wrapper traits: Rejected — 30+ enum expansions. AuditHook is a single subscription point.
  2. Mutable context propagation: Rejected — Rust ownership enforces integrity.
  3. Single-channel dispatch: Rejected — dual channels provide QoS isolation.
  4. 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_critical with compensating local log fallback for dual-delivery. KeystoneAuditPostauditDrops alert 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

PurposeKey PatternValue
SCIM Realmdata:scim_realm:v1:<domain_id>:<provider_id>ScimRealmResource
Resource Ownership Anchordata: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 Lookupindex: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:

  1. Fetch data:scim_resource:v1:<domain_id>:<provider_id>:<type>:<id> using the caller’s own provider_id from ScimRealmContext.
  2. If absent, return 404 Not Found — indistinguishable from “the resource does not exist,” even when a same-ID or same-userName resource exists under a different realm or was created manually. This prevents realm confusion / IDOR without leaking existence information across realms.
  3. If deprovisioned_at is set, GET/list treat it as absent (404); repeat DELETE is idempotent (404 per 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 core User/Group tables in identity-driver-sql, with the SCIM create path treating the resulting constraint-violation error as the 409 Conflict trigger 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
idid (generate_public_id(domain_id, externalId, "user"); deterministic, not server-random — see above)
externalIdScimResourceIndex.external_id (mandatory on create — see above)
userNamename
activeenabled
name.givenName / name.familyNameextra["scim_given_name"] / extra["scim_family_name"]
emails[primary eq true].valueextra["scim_primary_email"]
displayNameextra["scim_display_name"]
SCIM Attribute (Group)Keystone Group field
idid
externalIdScimResourceIndex.external_id
displayNamename
membersresolved 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 advertising bulk.supported: false, sort.supported: false, and describing the restricted filter grammar below (Okta/Entra ID both tolerate a filter.supported: true with 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
userNameeq, ne, co, sw, pr
externalIdeq, ne, pr
ideq, pr
activeeq, pr
Attribute (Group)Allowed operators
displayNameeq, ne, co, sw, pr
externalIdeq, ne, pr
ideq, 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:

  1. Sets User.enabled = false.
  2. Stamps ScimResourceIndex.deprovisioned_at.
  3. Triggers the existing token revocation pipeline (revocation:v1:user:<user_id>, ADR 0020 §9.F) so live sessions die immediately.
  4. Emits a CADF disable event (§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:

  1. 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.
  2. Stamps ScimResourceIndex.deprovisioned_at and hides the group from all SCIM GET/List responses (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.
  3. Retains the Group shell 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/delete
  • identity/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 ConditionHTTP StatusscimType
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 collision409uniqueness
Disallowed filter attribute/operator/mixed chain400invalidFilter
Disallowed PATCH path400invalidPath
Cross-realm/manual-user membership reference400invalidValue
If-Match version mismatch412(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_hash token-bucket rate limiter as authentication (ADR 0021 §6.A); a second, write-specific limiter keyed on provider_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/PUT is capped at 1000 members entries; 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.id changes across a zero-downtime SCIM key rotation (§9); mitigated by requiring downstream correlation on realm_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 existing User/Group schemas for the fallback path in §3.D; the preferred path (a UNIQUE(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_id field 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_id is bound to an active ScimRealmResource may not contain Authorization::Project entries.
  • POST /Users/Groups now performs a domain-wide userName/displayName existence check (§3.D) against core Identity, in addition to the realm-scoped externalId check — 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-realms and 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-now path for erasure requests (§6.C) that bypasses the configured retention window.
  • ScimResourceIndex.version writes (§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 the anchor() / build.rs convention (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 variants Password, Admin, Token, ApplicationCredential, Oidc, K8s, Trust, WebauthN, Mapping. crates/core/src/api/auth.rs matches this enum exhaustively to build a SecurityContext. The [auth] methods config value (crates/config/src/auth.rs:22-27) is a Vec<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:

  1. 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.
  2. Perform outbound HTTP calls (e.g. call a third-party risk API or legacy directory as part of the decision).
  3. Call a curated set of internal Keystone operations when necessary - most importantly, provision a local user on first login.
  4. 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.
  5. 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 - route Mode.”

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:

  1. 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.”
  2. 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_fetch calls at will, and bound the damage that’s possible from that alone (resource exhaustion, SSRF, credential exposure).
  3. Widened credential exposure from route mode specifically - a full_auth/mapping plugin 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. A route-mode plugin breaks that property: it must run on every request whose identity.methods matches its inspect_methods list (§4 “Guest Contract - route Mode”), 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, every application_credential attempt 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, no scope access - still bound that), but it does mean the plugin is a wider observation surface: it is positioned to see, and potentially exfiltrate via a compromised http_fetch call, credential material belonging to logins it has no legitimate reason to act on. inspect_methods scoping (§4) and the headers/payloads allowlists 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

AxisDecision
WASM runtimeExtism (host runtime built on wasmtime)
Hook pointFull custom auth method - a plugin is a peer of password/openid/k8s
Tenancy / trust scopeCluster-global; installed only by system admins via keystone.conf
DistributionLocal filesystem path, pinned by a SHA-256 checksum in keystone.conf
Failure handlingFail closed - any plugin error rejects the login attempt
Internal-call capabilityCurated, per-plugin host-function allowlist (no generic RPC)
Resource limitsFuel metering + wall-clock deadline + linear-memory cap, all configurable
Identity bindingHost-issued handles into a plugin-owned (plugin_name, external_id) namespace - never a raw user_id, never a lookup over existing accounts
Provisioning domain scopeConfig-declared provision_domain_id; create_user rejects any other domain
ClaimsReserved-key denylist + size/count caps, enforced by the host
Sensitive headersHard denylist (Authorization, Cookie, …) - never exposable via config
Invocation rate limitingPer-source-IP token bucket fronting a per-plugin token bucket + global concurrency cap (mirrors ADR 0020 §7.2)
Load-time checksum mismatchThat plugin only is disabled + a critical alert is raised; the node and every other configured auth method still start
Plugin-compromise cleanupBulk admin revoke_all endpoint, scoped per plugin_name - disables provisioned users, revokes granted roles, deletes identity links, revokes tokens
http_fetch SSRF policyConnect-time IP re-validation against allowed_hosts; no redirects by default
Outbound secretsHost-injected from config/env; never placed in guest memory
assign_role scopeConfig-declared role allowlist; system-scope grants always forbidden
WASI importsNone registered - only the curated host functions in §6
Token/plugin-version bindingPer-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 collisionsPlugin names reserved-word-checked against builtins at load time
Pre-existing (e.g. SCIM) users, low-privilege pathmapping mode - plugin transforms claims; Mapping Engine (ADR 0020) remains the terminal identity authority, no binding needed
Pre-existing (e.g. SCIM) users, full-authority pathfull_auth mode + admin-authorized external identity linking - never plugin-self-service
Coarser “any user in my domain” resolutionRejected - 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-Plugin instance, so a plugin simply cannot see a host function it wasn’t given.
  • A built-in HTTP allow-list (allowed_hosts on extism::Manifest) for the guest-initiated HTTP use case (§6.A), instead of keystone-rs having to hand-roll a WASI-sockets bridge.
  • It already wraps wasmtime for fuel metering, wall-clock timeouts (Plugin::new builder 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 calls provision_user/ find_user and returns Allow/Deny itself. 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 its IdentityMode::Local path. Because the plugin never asserts an identity itself, no ResolvedIdentityHandle, 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 - route Mode” 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. route mode 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:

  1. ADR 0020’s IdentitySource enum (crates/core-types/src/mapping/resolution.rs, per ADR 0020 §3) gains a variant: WasmPlugin { plugin_name: String }, alongside the existing Federation, K8s, Spiffe.
  2. Each mapping-mode plugin is automatically assigned provider_id = "wasm:<plugin_name>" in the Mapping Engine’s coordinate space. An operator writes ordinary MappingRuleSet rules under that provider_id (POST /v4/mappings, ADR 0020 §9.A) exactly as they would for a real OIDC provider - including IdentityMode::Local rules that resolve to real, pre-existing users (SCIM-provisioned or otherwise) by matching claims the plugin produced against whatever attributes identify that user.
  3. The plugin’s Claims(...) response is passed to the Mapping Engine’s existing evaluator unmodified - the same engine, the same domain whitelist (0020 §3 allowed_domains), the same regex/size bounds (0020 §5.1), the same TOCTOU ruleset_version check (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.
  4. If no MappingRuleSet exists under wasm:<plugin_name> for the target domain, evaluation returns MappingNotFound (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 hardcoded identity, authorizations, and is_system fields - not the raw claims - determine the outcome. A plugin cannot inject a claim that becomes is_system: true the way an unbounded claims map could in full_auth mode (§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. A mapping-mode plugin’s claims are only ever evaluated against rules filed under its own wasm:<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:

  1. Target-method allowlist. A route_targets list (§5) is required at config load; a Route response 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_targets itself is subject to the same reserved-name check as plugin registration (§4 “Reserved Auth-Method Names”) - a router can never be configured to target admin, trust, or any other method capable of reaching system scope.
  2. scope is immutable to the router. The host constructs the re-dispatched request by replacing only the named identity.<target_method> block; RouteResponse carries no scope field 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 a mapping-mode target) ultimately handles the rerouted request.
  3. 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.
  4. 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. payload may only carry values the plugin was actually able to read out of payloads/headers in 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).
  5. Trigger scoping. inspect_methods (§5) bounds not just what the plugin sees but whether it is invoked at all: a request whose identity.methods contains none of the configured inspect_methods entries 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.
  6. 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 a full_auth/mapping plugin (every request matching inspect_methods, not just requests already addressed to this plugin by name), its invocation_rate_limit_per_minute/ max_concurrent_invocations budget (§7) is tracked independently of the target method’s own budget - a saturated router degrades only routing decisions for its inspect_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:

  1. provision_user(external_id, user) creates (or, on a repeat call with the same external_id, returns the existing) real User row via IdentityBackend::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 a user_id that 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.

  2. Both return an opaque ResolvedIdentityHandle unrelated to the real user_id, from which the host can recover (user_id, domain_id) on the Allow.resolved_identity echo below.

    Implementation deviation: a signed token, not a per-Store map. The original intent was an in-memory handle -> (user_id, domain_id) map scoped to the single Store created for that invocation - random, unguessable, and structurally impossible to reuse across invocations because the map itself doesn’t outlive one. The actual implementation (CoreHostFunctions in crates/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): the extism::Function closures are shared across every concurrent invocation of a plugin’s compiled module, not instantiated fresh per Store, 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 prior provision_user/find_user call in this plugin’s namespace actually returned - without relying on state scoped to a single invocation. The expires_at is set generously past a plugin’s own timeout_ms budget (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_id re-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.

  3. If the guest’s Allow.resolved_identity verifies 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 no provision_user/find_user call was ever made - the request is rejected exactly like any other malformed response (§7), and the mismatch itself is audited as a suspicious event.

  4. A plugin granted neither provision_user nor find_user (§6) has no way to produce a valid handle at all, and can therefore only ever Deny - this is enforced at config-load time: registering a plugin as an [auth] methods entry 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:
  1. Disables (does not delete) every user_id the plugin provisioned via provision_user, and every user_id reachable 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.
  2. Deletes every remaining identity_links entry for that plugin - the batched equivalent of the existing per-external_id DELETE, above.
  3. 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-identity DELETE already 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_hosts is 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/16 including 169.254.169.254, etc.) - regardless of what the configured hostname was. The socket then connects to the exact IpAddr that 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 both allowed_hosts and the IP-range check above before being followed - a redirect is not permitted to silently escape the allowlist. The whole chain shares a single timeout_ms wall-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 to MAX_REDIRECTS + 1 times the plugin’s configured budget - the total wall-clock cost of a (possibly multi-hop) http_fetch call stays bounded to the same timeout_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 .wasm binary 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 .wasm file 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-supplied id is rejected (not ignored), because a plugin that could name the new user’s UUID could target an existing account’s id - colliding with, or attempting to overwrite/alias, a password-authenticated admin - which is exactly the arbitrary-user_id claim 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 UserCreate in 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_domains set (§6.B). The role name allowlist alone does not bound where the grant applies; without this, a plugin holding member in assign_role_allowed could grant its self-provisioned user member on an arbitrary project - including a sensitive one in another domain (e.g. the admin project). 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 the provision_user domain check.
  • Which scope type - the load-bearing control against privilege escalation: assign_role only 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 that Authorization::System requires is_system: true plus admin-level authorization, neither of which a plugin invocation ever carries. (An earlier draft of this ADR additionally proposed rejecting assign_role_allowed entries 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:admin in 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:

  1. 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).
  2. Wall-clock deadline (timeout_ms) - bounds total invocation time including any http_fetch calls the guest makes.
  3. 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 governor crate already a workspace dependency (Cargo.toml:82):
  1. 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-spoofable remote_addr AuthPluginRequest carries (§4) - never a raw X-Forwarded-For value 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 with 429 Too Many Requests before it can touch the plugin-wide budget at all, audited as a RateLimited outcome 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.
  2. Invocation rate limit (invocation_rate_limit_per_minute, §5) - a sliding-window token bucket per plugin, shared across all sources. Exceeding it rejects further authenticate calls for that plugin with 429 Too Many Requests until the window clears, audited as a RateLimited outcome.
  3. Concurrency cap (max_concurrent_invocations, §5) - a semaphore bounding how many Store instances (and therefore how many in-flight http_fetch calls) 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 claims map: 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 / SecurityContext construction only as plugin_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 future SecurityContext field that trusts a top-level claim not on the list (e.g. groups driving 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 whole Allow closed) 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’s SystemTokenShadowing write-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 for mapping mode.” A mapping-mode token has no plugin-recoverable field in its FernetToken payload, so bumping a mapping-mode plugin’s valid_since does not invalidate outstanding tokens the way it does for full_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 compromised mapping-mode plugin relies on revocation events or short token TTLs, not valid_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 calls provision_user outside their own domain? Should allowed_hosts be 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 .wasm file, 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_all disables 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 deliberately plugin_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_auth plugin resolve any user within its provision_domain_id without 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 via scim_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 internal user_id.

9. Consequences

Positive

  • Operators can add custom authentication logic without forking keystone-rs or 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_id claim, but by making find_user/provision_user incapable 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_all admin 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.
  • mapping mode (§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_auth plugins a path to pre-existing users too, for cases mapping mode 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.
  • route mode (§4) lets a client that can only ever send a fixed method name - Terraform’s application_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 existing inventory-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 .wasm file 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 accompanying CRITICAL alert. 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-rs Rust development.
  • The namespace-scoped identity model (§4) and mandatory audit wrapping (§6.E) add host-side bookkeeping (per-Store handle 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’s IdentitySource enum and MappingContext payload (§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, and mapping-mode plugins depend on an admin having separately authored Mapping Engine rules for them (§4 step 4) or they authenticate no one.
  • route mode is reachable by a strictly larger slice of traffic than full_auth/mapping plugins - every request whose identity.methods matches its inspect_methods list, 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 by inspect_methods scoping and the payload/header allowlists already used elsewhere (§4).

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 by mapping mode and identity linking (§4).
  • [openstack_keystone_core_types::mapping::resolution::IdentitySource] enum, gains the WasmPlugin variant for mapping mode.
  • [MappingContext] - mapping-mode token invalidation reuses its existing mapping_id to recover the plugin’s valid_since from the matched ruleset’s IdentitySource::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 - ScimResourceIndex and the externalId lookup index reused by the SCIM identity-linking convenience (§4).
  • [AuthenticationContext] enum.
  • [AuthenticationContext::Mapping], the variant a successful mapping-mode login produces.
  • [openstack_keystone_core::identity::backend::IdentityBackend::create_user], the target of the provision_user host 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:

  1. 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_credentials grant yields a JWT that Nova/Neutron accept directly — no long-lived application credentials embedded in cluster secrets.
  2. 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.
  3. “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.
  4. 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.
  5. Modern CLI login. The Device Authorization Grant (§7.C) gives openstack/osc CLI 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.
  6. 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

  1. Malicious or Compromised Relying Parties (RPs): An external application consuming an id_token or access_token must be structurally barred from leveraging that credential to access native OpenStack core APIs. This is achieved by enforcing strict, segregated audience (aud) targeting: an authorization_code/refresh_token grant only ever produces an OpenStack-capable access token (aud: "openstack-apis:{domain_id}", carrying openstack_context/roles) when the client explicitly requested and was granted the openstack:api scope (§4, “Scope Validation”). Every other RP - including every client that only wants “Login with OpenStack” identity display - receives a minimal OidcAccessTokenClaims (§4) whose aud is its own client_id, structurally incapable of passing downstream aud verification (§6) against any OpenStack service.
  2. 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 (S256 only), exact-match redirect allowlists, and persistent runtime state bindings.
  3. 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 AxisFormal Decision
Cluster Operational RoleAuthoritative OAuth2 Authorization Server & OpenID Connect Provider (OP).
Token ArchitectureStateless 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 TrackES256 (default) or RS256 configurable via [oauth2] signing_algorithm.
Discovery & JWKSPer-domain issuer, per ADR 0006. GET /v4/oauth2/{domain_id}/jwks + /.well-known/openid-configuration.
Scope ModelStandard 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 SynchronizationDistributed 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 AcceptanceExecuted via a lightweight, custom Python WSGI middleware injected into legacy Paste Deploy pipelines.
Machine Workspace StorageTypically 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 EngineHandled natively at the handler layer using the governor crate with pre-hash enforcement.
Audit VerificationJCS-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:

  1. Primary/Active Key: Used exclusively by the Raft leader to sign newly minted outbound JWTs.
  2. 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:

  1. Generate a fresh asymmetric keypair in memory, per the configured [oauth2] signing_algorithm.
  2. Commit it via a Raft proposal to _meta:oauth2:signing_key:<domain_id>:pending, computing its kid (§3).
  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.
  4. 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>:previous and the JWKS response.
  5. The rotation event is recorded as a CADF audit event (ADR 0023) with domain_id, the new kid, and the retiring kid.

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:

  1. Trigger: keystone-manage oauth2 rotate-signing-key --domain <domain_id> --emergency, requiring SystemAdmin and dual-control confirmation (ConfirmRotateSigningKey from 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.
  2. Immediate replacement: A fresh keypair is generated and committed via Raft, promoted directly to Primary/Active.
  3. 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 revoked and publishes a jti-based revocation list alongside JWKS at GET /v4/oauth2/{domain_id}/jwks/revocation. The list initially includes the jti of any tokens issued within the compromise window (derived from the audit log). The middleware (§6) checks this list on every token verification. Tokens without jti (notably IdTokenClaims) are unaffected — they carry no downstream authorization authority and are rejected by the middleware absent openstack_context (Finding 1.4). OpenStackAccessTokenClaims always carries jti, so they are covered. The revocation list TTL mirrors the one-max-lifetime retention window of normal rotation.
  4. Incident logging: Recorded as a distinct CADF event type (OAUTH2_EMERGENCY_KEY_ROTATION) with domain_id, revoked kid, new kid, operator identity, and the full revoked_jtis list 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)]:

  1. 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 of IdTokenClaims, OpenStackContext, and OpenStackScope at 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)].

  2. 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 per OpenStackScope variant (§4). ${roles.*} is excluded from v1 claims_template to prevent role data from leaking to id_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.

  3. 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_claims map 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 requested scope must be a subset of allowed_scopes ∩ {openid, profile, email, openstack:api}. Any requested value outside that set is rejected outright with invalid_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:api is not a display scope: it must be explicitly present in the client’s allowed_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; omitting scope entirely (below) does not imply openstack:api even if the client’s allowed_scopes includes it, since defaulting to “full access” on an omitted scope would silently hand OpenStack authority to any client that merely forgets to pass scope.
  • client_credentials: the requested scope (if present) must be a subset of allowed_scopes, same reject-outright rule. These values are opaque resource-scope strings in v1 - they carry no OIDC meaning (no id_token is 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 response scope field 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 scope param (either grant): defaults to the client’s full allowed_scopes value, per RFC 6749 §3.3 - except openstack:api on authorization_code/refresh_token, which is never implied by omission and must always be requested explicitly (see above); client_credentials is 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-facing client_id presented at /token to the resource’s admin-facing coordinate, exactly as the OIDC/K8s/SPIFFE resources resolve their own protocol identifiers.
  • IdentitySource variant: ADR 0020 §3 gains IdentitySource::OAuth2Client { provider_id: String }, matching the existing Federation { idp_id }, K8s { cluster_id }, and Spiffe { trust_domain } pattern - the anchor is the resource’s own identifying field, not a generic wrapper (ADR 0020 §13 D1/D7).
  • client_credentials grant is an ingress event. When a machine workload authenticates with client_id/client_secret, it flattens to a minimal claims map (client_id, domain_id) and runs through the exact same MappingRuleSet match → upsert pipeline as SPIFFE/K8s ingress (ADR 0020 §5, §7.2), not a separate authorization mechanism. Any scope requested on this grant is validated against allowed_scopes as 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; sub is the real user_id; the client’s own client_id appears only as aud on the id_token it consumes (azp is reserved, §4, but unused in v1 since aud is always the single requesting client).
  • Machine-identity role (client_credentials): the client is the subject. sub is the ephemeral shadow user_id derived 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_credentials and writes Authorization::Project/Domain rules 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: true or Authorization::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 plaintext client_secret in the response body (never persisted or retrievable again; only client_secret_hash is stored). provider_id must be unique within domain_id (409 on collision); client_id is 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 includes client_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. Setting pre_authorized requires SystemAdmin regardless of the Tier 2 self-service path otherwise available for this endpoint (§7.C). client_id, provider_id, and domain_id are immutable post-creation, mirroring MappingRuleSet.domain_id immutability (ADR 0020 §9.D).
  • POST /v4/oauth2/{domain_id}/clients/{provider_id}/rotate-secret - Invalidates the current client_secret_hash and 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 natural exp per 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, aud is domain-bound ("openstack-apis:{domain_id}"), and the downstream middleware (§6) validates both aud against its configured domain and iss against 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_id string 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:

  1. The incoming request passes through the global_ip_limiter using the originating client address resolved via trusted proxy CIDRs.
  2. The user lookup occurs in the identity backend to confirm actual account existence.
  3. 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_code and user_code are subject to separate per-IP and per-user_code rate limits with exponential backoff.
  • Invalid or expired device_code presented at /token triggers 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 a slow_down error response per RFC 8628 §3.5 (not a generic penalty error), signaling the client to increase its polling rate.
  • The minimum interval between polling attempts is 5 seconds.
  • A pre_authorized flag on OAuth2Client may skip the user consent step for trusted first-party devices. Creating or updating this flag requires SystemAdmin and triggers a high-severity CADF event (ADR 0023). A pre_authorized client must not include openstack:api in allowed_scopes (enforced at CRUD time), preventing silent consent bypass from granting OpenStack authorization. If openstack:api is added to a pre_authorized client, the PUT request is rejected with 422.
  • Code entropy requirements (RFC 8628 §3.5): device_code must be at least 256 bits of entropy (43+ base64url chars) to prevent brute-force at /token. user_code must be at least 8 characters using unambiguous characters [A-Z,0-9] (excluding O/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 /token polling limit in §7.C).
  • user_code uniqueness checks must use constant-time comparison to prevent timing-based enumeration attacks.
  • Failed console attempts generate device_code reuse alerts via CADF audit event (ADR 0023), correlating with /token polling 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: nosniff
  • X-XSS-Protection: 0 (disabled per OWASP modern guidance; CSP default-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:

  1. 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 to refresh_token_reuse_grace_minutes to replay it before family revocation triggers (see §11).
  2. The token engine invalidates the entire token family tree associated with that original grant immediately.
  3. The event handler bypasses the best-effort perimeter logging pool and commits a critical alert via dispatch_critical().
  4. 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) and TokenApi::encode_token currently take/return FernetToken directly. Introduce a format-neutral token payload type and a [token] provider = fernet | jws selector (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-jws crate: 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 existing KeySource abstraction in crates/key-repository so 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 public GET /v4/oauth2/{domain_id}/jwks endpoint. 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 the OAuth2Client storage 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 /token endpoint handling secret matching. Integrate the pre-hash rate-limiting checks from ADR 0022. Connect the token generation to IdentityMode::Ephemeral shadow 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 mandatory S256 PKCE 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” KeystoneNativeJwtMiddleware codebase. 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 /authorize time and baked into the JWT. If roles are removed from a user after issuance but before exp, the token carries stale roles. Unlike the Fernet path where roles are re-resolved on every new_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 with nbf enforcement. 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_id Enumeration via JWKS: GET /v4/oauth2/{domain_id}/jwks is unauthenticated by design (relying parties must fetch it without a Keystone token) and returns 404 for an unknown domain_id versus 200 for a provisioned one, letting an anonymous caller confirm whether a given domain_id exists. This is accepted, not mitigated further: every major multi-tenant OIDC provider (Auth0 tenant name, Okta org subdomain, Keycloak realm name, Google Workspace hd domain) treats the tenant identifier in the issuer/JWKS URL as public, not secret — it is embedded in every issued token’s iss claim and handed to every RP’s configuration, so it cannot be kept confidential once a single client is onboarded. Since domain_id is 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.yaml conventions, 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_id to a random value at bootstrap time.
  • Client ID Claim Enumeration: OpenStackAccessTokenClaims carries client_id, which identifies the registering OAuth2 client (OidcAccessTokenClaims, §4, does not). If an attacker obtains an OpenStackAccessTokenClaims access_token (e.g., via XSS on an RP page or browser dev tools), they can extract the internal client_id. Since the token is cryptographically signed and aud-bound, enumeration alone does not enable privilege escalation. However, combined with misconfigured endpoints that ignore aud verification, it could enable targeted client_id guessing. Mitigation: strict aud enforcement in all downstream services; v2 may move client_id to a separate internal claim not exposed to RP id_token.
  • keystone_ruleset_version Blast 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-exchange added to OAuth2Client.grant_types.
  • Request: /token accepts subject_token (an existing valid Keystone token - Fernet or native JWT - representing the trust/app-cred/EC2 grantor context), subject_token_type, and requested_token_type (urn:ietf:params:oauth:token-type:access_token).
  • Response: A new access_token with delegation_context set to whichever of DelegationContext::Trust { project_id }, AppCred { project_id }, or Ec2 { project_id } (§4) matches the grantor context of the presented subject_token (replacing the v1 default Plain), project_id populated 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 /token sub-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_token exchanges 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:

  • AppCred only, not Trust or Ec2. Only ApplicationCredential’s immutable project (ApplicationCredential.project_id) is carried on the object embedded in AuthenticationContext::ApplicationCredential once subject_token is validated through the existing TokenApi::validate_to_context pipeline - no new provider lookup needed. AuthenticationContext::Trust and AuthenticationContext::Ec2Credential are rejected outright (invalid_grant). While Trust.project_id is technically available, trust tokens have special handling characteristics (impersonation, trust-specific constraints) that make them unsuitable for token exchange. AuthenticationContext::Ec2Credential carries 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 outer Trust/ApplicationCredential context 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 OAuth2Client must hold token-exchange in its own grant_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 existing policy/oauth2/client/update.rego. No new admin-only carve-out was added specifically for this grant (unlike pre_authorized), since - unlike pre-authorization skipping user consent entirely - a Token Exchange grant only ever re-expresses a delegation the presented subject_token already proves the caller holds; it grants no new authority beyond what that token’s own chain already carries.
  • keystone_ruleset_version: set to 0 (a sentinel, not a real ruleset hash) - a token-exchange grant does not go through a MappingRuleSet at 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_event best-effort path (the same one client_credentials/authorization_code use), keyed on client_id - no new event type was needed since delegation-specific detail already lives in the returned delegation_context claim 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:

  1. Rolling upgrades – mixed Python/Rust Keystone deployments querying the same directory without behavior divergence.
  2. Configuration portability – a [ldap] config section written for Python Keystone works unmodified in keystone-rs.
  3. Fallback capability – operators can switch between driver = sql and driver = ldap at the configuration level without code changes.

Python LDAP Backend Architecture

The Python implementation spans four modules totaling ~2,823 lines:

FileLinesPurpose
__init__.py13Re-exports from core.py
core.py491Identity, UserApi, GroupApi classes
models.py75User and Group model definitions
common.py2,244BaseLdap, 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 (default False).
  • 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 member DN attributes, not a local junction table.
  • No local domain_id concept 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

MethodLDAP Operation
create_userForbidden – returns IdentityProviderError::Readonly
get_user(user_id)id_to_dn → BASE search
update_userForbidden – Readonly
delete_userForbidden – Readonly
list_users(params)Subtree paged search + hint filters
find_federated_userName-based search (maps idp_iddomain_id)
get_user_domain_idReturns default_domain_id from config

Authentication

MethodLDAP Operation
authenticate_by_passwordTwo-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:

  1. Resolve user_id or name + domain_id to the user’s LDAP DN.
  2. Attempt LDAP simple bind using the user DN and provided password.
  3. On success, fetch full user attributes using the service bind pool.
  4. Return AuthenticationResult with the mapped UserResponse.

Step 2 uses the dedicated auth_pool to prevent authentication storms from exhausting the service query pool.

Group CRUD

MethodLDAP Operation
create_groupForbidden – Readonly
get_group(group_id)id_to_dn → BASE search
list_groups(params)Subtree search, filtered by group_objectclass
delete_groupForbidden – Readonly

Membership

MethodLDAP Operation
add_user_to_groupForbidden – Readonly
remove_user_from_groupForbidden – 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_groupsForbidden – 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

MethodBehavior
update_user_passwordForbidden – Readonly. Passwords are LDAP-managed.
create_service_accountNotImplemented. LDAP has no service account concept.
get_service_accountReturns 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 ComparatorLDAP FilterExample
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.
  • enabled filter: 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:

  1. Case-insensitive attribute matching – LDAP attribute names are lowercased before matching attribute_mapping.
  2. Bytes handling – Rust LDAP library (e.g., ldap3) handles UTF-8 natively.
  3. Multi-value ID attributes – fall back to DN as identifier.
  4. Additional attributes*_additional_attribute_mapping entries are exposed as user/group extras.
  5. Private attribute filtering – matches Python’s filter_entity (removes dn, password from results).

7. Enabled Emulation

Four strategies, all configurable per the Python EnabledEmuMixIn:

StrategyConfigMechanism
Bitmask*_enabled_maskRead integer attribute, apply bit mask
Invert*_enabled_invertInvert boolean interpretation
Emulation*_enabled_emulationGroup membership in cn=enabled_us*rs,{tree_dn}
Default*_enabled_defaultFallback 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

LibraryPoolTLSPaged SearchMaturity
ldap3YesFullRFC 2696Production
slapd-rsNoNoNoNiche

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:

TableSchemaPurpose
userid, name, domain_id, enabled, extra, …Main user record
local_userid, passwordLocal user password data
nonlocal_user(domain_id, name, user_id) FKFederated user link
federated_userid, user_id, idp_id, protocol_id, unique_idIdP protocol binding
idmappingdomain_id, entity_type, local_id, public_idLocal ↔ public ID mapping

The SQL create flow works as:

  1. Insert user (main record)
  2. If local user → insert local_user (with bcrypt password hash) + password
  3. If federated user → insert federated_user (links to idp_id + unique_id)
  4. The idmapping table 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 response
  • get_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

  1. Configuration parity – An operator’s keystone.conf [ldap] section works identically across Python and Rust deployments. No conversion tooling required.

  2. Auth storm isolation – The dedicated auth pool prevents a login storm from exhausting directory query capacity and breaking control-plane operations.

  3. Zero local data mutation – The read-only contract means keystone-rs cannot corrupt the LDAP directory. All write operations return clearly named errors.

  4. AD compatibilitygroup_ad_nesting with IN_CHAIN matching provides native AD nested group support, matching Python’s behavior.

  5. Pagination safety – RFC 2696 paged search ensures large directories don’t exceed LDAP server sizelimit and return incomplete result sets.

  6. Identity separation – LDAP, federation, and SQL identity data remain in separate storage layers. No cross-backend confusion.

Negative

  1. Trait overhead – ~8 of 27 IdentityBackend methods are no-ops or return hardcoded errors. A more minimal trait (e.g., ReadonlyIdentityBackend) could reduce this but would complicate the plugin registration model.

  2. 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.

  3. No write capability – Deferred write support means operators cannot use LDAP for user or group lifecycle management. If future work enables WRITABLE mode, the risk of directory corruption must be carefully managed.

  4. New dependencyldap3 adds an external LDAP protocol dependency to the Rust crate graph.

  5. No idmapping or nonlocal_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 to driver = sql or use federation to bridge the gap.

Migration Path

The driver crate integrates via ADR-0018’s automatic discovery:

  1. Add openstack-keystone-identity-driver-ldap crate to workspace.
  2. Add as dependency of crates/keystone/Cargo.toml.
  3. build.rs discovers it automatically via the driver name filter.
  4. Set [identity] driver = "ldap" in config to activate.

Testing Strategy

  1. Config parity tests – Verify Rust LdapConfig deserializes identically to Python’s conf.ldap for a set of representative config files.
  2. Filter translation tests – Unit tests verifying hint-to-filter mapping matches Python output for all comparator types.
  3. LDAP mock server tests – Integration tests against a controllable LDAP mock (e.g., ldap-mock or slapd in Docker) validating full query paths.
  4. 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 --emergency path 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 keystone by the local spire-agent over its own workload-API socket — not a round-trip to a remote SPIRE server. A Raft network partition (which affects [distributed_storage] node_cluster_addr connectivity 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_admin in keystone.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_PEERCRED against 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.conf section, 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-bypass is 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.
  • --justification is 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)

  1. 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).
  2. 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).
  3. 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.
  4. 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_jtis for signing keys; _local:dek:cluster:revoked for 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.
  5. 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_CONFLICT condition (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:

  1. 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.
  2. 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_CONFLICT from §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.
  3. 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.
  4. 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_version and 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/revocation continues 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-bypass as 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 until confirm-rotate-signing-key succeeds. DEK’s own audit convention differs (it already audits RotateDek’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::Admin connection extension; every admin-UDS operation today is secured by the ordinary Auth extractor plus an OPA policy requiring SystemAdmin. 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-candidates against every node that may hold a candidate, then reconcile-local-emergency-key / reconcile-dek-local-emergency against the specific node holding the chosen rotation_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-dek in 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

  1. Architecture Overview
  2. Crate Layout
  3. Key Hierarchy
  4. Encryption Details
  5. Data Tiers and Read Consistency
  6. Intra-Cluster Transport (mTLS)
  7. Audit Log
  8. Quarantine and GCM Failure Handling
  9. DEK Rotation
  10. Deployment Guide
  11. Operational Runbook
  12. 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.

SubcommandDescription
initBootstrap 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-peersShow cluster peers in a table
metricsShow 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-idConfirm a pending emergency DEK rotation
backup [--cluster-addr] --outputCreate an encrypted Fjall snapshot
restore [--cluster-addr] --snapshotRestore an encrypted snapshot to the cluster

Architecture Overview

The storage engine combines three components:

LayerComponentPurpose
ConsensusopenraftLog replication, leader election, linearizability
State machine / log storefjall (LSM-tree)Durable on-disk persistence, SSD-optimized
TransportgRPC over mTLSIntra-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

  1. The API serializes the mutation to MessagePack.
  2. The Log DEK encrypts the payload (nonce: node_id_BE ++ counter_BE, AD: term_BE ++ index_BE).
  3. OpenRaft proposes the encrypted blob and replicates it over mTLS to a quorum.
  4. On apply, the state machine decrypts the log entry using the Log DEK.
  5. The current per-record version is read from Fjall (0 for new records).
  6. 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]

FieldValue
Nonce (12 bytes)[node_id_u64_BE; 8] ++ [counter_u32_BE; 4]
Associated dataterm_u64_BE ++ index_u64_BE
Tag16 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]

FieldValue
Nonce (12 bytes)HKDF-Expand(StateDek, pk ++ version_u32_BE, L=12)
Associated data[tier_u8] ++ domain_id ++ pk
Tag16 bytes
Version suffixversion_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]

FieldValue
Associated datab"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^31 rotation 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.

TierLabelRead pathExamples
0PUBLICLocal readFeature flags, role display names
1INTERNALLocal readDisplay attributes, config markers
2SENSITIVELinearizable (ReadIndex)Group memberships, session tokens, API keys
3SECRETLinearizable (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 with PERMISSION_DENIED at 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.
  • CertExpiryWatchdog checks 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
1WARN log, metric increment
2ERROR log, alert
3Drain 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_KEK and KEYSTONE_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):

VariableDescription
KEYSTONE_DEV_KEKHex-encoded 256-bit KEK. Requires --dev-mode and KEYSTONE_ALLOW_ENV_KEK=1.
KEYSTONE_ALLOW_ENV_KEKMust be set to 1 when using KEYSTONE_DEV_KEK.

Warning: KEYSTONE_DEV_KEK and KEYSTONE_ALLOW_ENV_KEK must never appear in production Dockerfiles, Kubernetes manifests, or systemd units. The CI gate tools/check_no_dev_mode.sh enforces 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:

  1. Generate a new certificate from your Intermediate CA (max 30-day validity).
  2. Deploy the new certificate and key to the node.
  3. Restart the node, or use a runtime reload mechanism if available.
  4. The CertExpiryWatchdog logs 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 the backup_dek role 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.

  1. No plaintext on disk. Every byte is AES-256-GCM encrypted before the write call returns. GCM tags are always 16 bytes.

  2. 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 Zeroizing buffers.

  3. 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.

  4. No stale reads for sensitive data. Tier 2 and Tier 3 reads always execute the ReadIndex protocol before returning data.

  5. GCM failure quarantine is durable. Quarantine state is committed via Raft and persists across node restarts.

  6. No environment-variable KEK in production. Starting with KEYSTONE_DEV_KEK requires both --dev-mode and KEYSTONE_ALLOW_ENV_KEK=1. CI rejects deployment artifacts that contain these flags.

  7. NodeId collision detection is fail-closed. A collision detected at startup or on add_learner is fatal. Inability to query membership (no quorum) is treated as a detected collision.

  8. DEK generation targets mlock’d memory. The DEK must not be generated into an unlocked buffer and subsequently copied.

  9. Per-record write rate guard. Writes beyond the version threshold (2^30 by default) are blocked with a CRITICAL log. This prevents nonce-space exhaustion for pathologically hot keys within a DEK epoch.

  10. 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.

  11. Deployment validation. tools/check_no_dev_mode.sh runs in CI and rejects production service definitions containing --dev-mode or KEYSTONE_ALLOW_ENV_KEK.

  12. Startup pre-flight. Before loading any key material, the node verifies RLIMIT_CORE == 0 and PR_SET_DUMPABLE == 0. Failures emit CRITICAL log entries and (when --dev-mode is 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 wasmtime crate 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 requires wasm feature 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:

1. Vocabulary

TermMeaning
Authentication chainThe immutable record of how the caller authenticated: AuthenticationContext (token, app-cred, trust, EC2, K8s, …) plus the delegation objects themselves. Fixed at authentication time.
ScopeThe authorization target of the current token: ScopeInfo (Project, Domain, System, TrustProject, Unscoped). Chosen per-request and re-chosen on every rescope.
Delegated authAny 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.
RescopeExchanging a token for another token with a different scope.
ReauthRe-running authentication (new token) rather than reusing/rescoping an existing one.
Scope-bind escapeA 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 ValidatedSecurityContext may 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 on Credentials, the policy cannot enforce it. Adding a delegation-sensitive policy rule almost always requires first projecting a new chain-derived field onto Credentials.
  • 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:

MethodFernet payloadSource-auth carrierToken-from-token (reauth)
Trustdedicated TrustPayloadtrust embedded in the payloadForbidden — see AuthenticationError “Token renewal (getting token from token) is prohibited.”
Application credentialdedicated ApplicationCredentialPayloadapplication_credential embedded in the payloadAllowed, but scope-locked — a token minted from an AC token cannot change scope
EC2 credentialregular 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::Ec2CredentialN/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; only ScopeInfo changes. 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 pipelineSecurityContext::validate(), expiry, trust-chain validation, role resolution — so it is the safe path when in doubt. A validated context is only obtainable via new_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 Credentials from the chain, and does the rule anchor on delegated_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 ScopeInfo variant or auth method? Updated validate_scope_boundaries(), calculate_effective_roles(), fully_resolved(), and Credentials::try_from (I5)?
  • New lookup by a client-derivable key (like sha256(access))? Does it re-assert type/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 / CVEClassInvariants
OSSA-2026-015Delegated token not bound to its delegation project on credential CRUDI1, I2, I3
OSSA-2026-005 / CVE-2026-33551Restricted app-cred escapes role restriction via minted EC2 credentialI4, I6
CVE-2019-19687List leaks objects the caller cannot readI8
CVE-2020-12691Mutation of immutable credential fieldsprovider-layer field guard; see ADR 0019

9. Known open gaps

  • App-cred access_rules unenforced at request time (§5). Feature-sized; needs request-matching middleware and likely its own ADR. Until then, treat access_rules as 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:

  1. 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?
  2. How should the project test for security gaps rather than only testing the happy path?
  3. 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 in TryFrom<&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 via new_for_scope(); Deref-only, no DerefMut, 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_context falling through to ProjectScopePayload) 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_contextProjectScopePayload 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 through new_for_scope(), with the invariant “effective roles ⊆ delegation role set” checked mechanically. test_new_for_scope_delegated_roles_never_exceed_delegation_matrix is 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 clippy wildcard_enum_match_arm allow-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 policy runs, but only in policy-container.yml, gated on paths: policy/**. A Rust-only change that alters which policy_name a handler enforces, or changes the Credentials projection, 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 --check runs in linters.yml, but formatting is not correctness.
  • There is no gate asserting every enforced policy_name has a matching .rego rule and a _test.rego, nor that every CRUD handler calls enforce(). CLAUDE.md requires “>=3 tests per CRUD handler” and the “>=1 negative policy test” convention, but nothing enforces it mechanically (a grep -rL enforce over v3 today returns only auth/token/create.rs, which is fine — but nothing keeps it that way).
  • Fail-closed on OPA error looks correct (PolicyErrorforbidden()), but there is no explicit test that an OPA outage / malformed response / timeout yields deny, not allow.

Control.

  • Gate A: run opa test policy in the main ci.yml matrix (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 a policy/**/<name>.rego rule and a sibling _test.rego exist, and (3) asserts every handler module implementing a CRUD verb contains an enforce call. 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 bare input.target.<x>.project_id instead of object.get(..., null) — the exact trap security.md I2 warns about, where an undefined argument 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 …/show logic on a delete),
  • keys the object under the wrong resource name (ADR 0002 mandates {"target": {"<resource>": obj}}; a typo makes every input.target.<resource>.… lookup undefined, and an undefined-driven Rego rule can silently allow),
  • puts the stored object in target instead of existing on 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.

LayerTested today
Rego logic in isolationopa test policy (synthetic input)
credentials (chain) projectionCredentials::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 MockPolicy and assert on the captured arguments — e.g. test_create_policy_input_omits_password (crates/keystone/src/api/v4/user/create.rs) checks existing.is_none() and that the password is absent from target. 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_api runs 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 PolicyEnforcer double that records every (policy_name, target, existing)) with a standard, uniform assertion set applied to every handler, not re-derived per test:
    1. policy_name is a member of the known policy set and resolves to an existing .rego (ties to Gate B1);
    2. target (and existing, 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 an undefined at runtime;
    3. operation/slot correctness: create/show/delete/list pass existing: None; update passes existing: Some(stored) and target: patch (never swapped);
    4. secret-free: no denylisted field (blob, password, *_secret, TOTP seed, token) appears anywhere in target/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 calls enforce() 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 only HttpPolicyEnforcer exists (OPA over HTTP/unix socket); to make this usable in handler-level and test_api tests without a hand-maintained live server, add a PolicyEnforcer implementation that evaluates the compiled bundle in-process (OPA already compiles a bundle in policy-container.yml; opa build -t wasm + an in-process wasm evaluator, or a managed opa eval subprocess, 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 … --bundleoras 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: write permission already present in the publish job) and verify the signature + digest at bundle load in Keystone. Pin by digest, not latest, 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_rules are not enforced, so operators are not misled. Optionally reject creation of an app-cred with non-empty access_rules behind 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-For from 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 against allowed_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 in Credentials, crates/core/src/policy.rs) and a reserved-key denylist.
  • Identity-binding bypass: a find_user that 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 ResolvedIdentityHandle is rejected; a claim named is_system is dropped; a route target 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 in exposed_headers (config-load rejection), tested directly.
  • Pentest: treat the plugin invocation path as an internet-facing, unauthenticated endpoint — SSRF, request smuggling into route targets, 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_uri rejected, plain PKCE rejected, missing code_verifier rejected, 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-vectorVerdictEvidence
client_credentials/authorization_code/refresh_token/token-exchange: unknown client_id vs. wrong secret vs. disabled client, by responseMitigatedtoken.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-107generate_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 oracleFixedtoken.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_uriNot 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 enumerationMitigatedauthorize.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 limitingFixeddevice.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 lookupNot vulnerableoauth2_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_ip rate 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 checks grant_types after secret verification, so it matches the uniform-response posture of the other three grant handlers.
  • Testing: negative tests asserting 429 under burst-exhaustion now exist for /device, /device/login, /device_authorization, alongside the pre-existing /authorize//token coverage.
  • 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/login across many usernames from a single IP; brute-force /device user_code guessing at volume; attempt to reach a password check via any client_id value without it being a pre-registered OAuth2Client row (expected: impossible, confirm it stays that way) — all should now hit 429 after 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 error Display impls.
  • 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).

GateWhat it doesCatchesEffort
ARun opa test policy in main ci.yml, not only on policy/** pathsV3 — Rust change that breaks policy enforcement merges green todayLow
B1Policy↔handler existence checker (every enforce(name).rego + _test.rego; every CRUD handler ⇒ enforce)V3 — missing policy, orphan policy, unenforced handlerLow
B2Handler→policy input-contract harness: a shared capturing enforcer + route-sweep asserting resource-key correctness, target/existing slotting, and secret-free input, on every handler automaticallyV3a — wrong policy_name, mis-keyed resource, target/existing swap, secret leak — none caught by opa testMed
B3Handler→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 onMed/High
CInvariant-test presence check: every delegated-auth policy carries a scope-drift negative case; every new scope/auth arm has a matrix rowV1/V2 — silent boundary regressionMed
DGenerated (auth method × scope × restricted?) matrix test through new_for_scope() with “roles ⊆ delegation” assertionV1/V2 — the I4 near-miss classMed
ERego lint for the undefined-argument footgun (object.get(…, null) required for delegated helper args)V3 — I2 trapLow
FFuzz Credentials::try_from, Fernet token decode, and (when built) the WASM host↔guest JSON boundaryV1/V7 — malformed-input logic bugsMed
GMutation testing (cargo-mutants) scoped to core/core-types auth+policy modules, to prove the negative tests actually fail on a regressionall — verifies the tests have teethMed
HSign the OPA policy bundle (cosign) + verify signature/digest at loadV4 — policy supply chainMed
IStructural “no secret in policy input / audit / error string” serialization testV9Low
JGrep-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/V2Low

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.md as 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:

  1. 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.
  2. 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_id holds for every delegated Credentials the 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 proptest to search the input space around these.
  3. 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 policy proves 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.
  4. Differential testing against Python Keystone. CI already installs pip install keystone for cross-verification — extend it to authorization-decision differentials on the delegated paths, so a divergence from the reference implementation’s allow/deny is visible.
  5. 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.
  6. 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.

  1. 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.
  2. List leakage (I8, CVE-2019-19687). For every list endpoint, confirm per-item re-check drops rows the caller cannot individually read.
  3. 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.
  4. Pre-auth DoS (V6). Resource-exhaust every unauthenticated crypto endpoint; attempt X-Forwarded-For spoofing to defeat per-IP limits.
  5. App-cred access_rules (V5). Confirm the currently-unenforced state, and re-test once middleware lands.
  6. WASM plugins (V7) and OAuth2 provider (V8) — full dedicated suites when those features ship; treat both as internet-facing pre-auth surfaces.
  7. Token lifecycle (V10). Revocation-window and version-binding probing.
  8. OAuth2 device-flow rate limiting (V8a, fixed 2026-07-16). Password-spray /device/login from a single IP across many usernames; brute-force /device user_code guessing at volume — both should now hit 429 after burst exhaustion; re-verify this holds after any future change to device.rs/device_authorization.rs.

7. Prioritized recommendation

If the project adopts nothing else from this review:

  1. 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 test and the current mocks do not.
  2. Ship access_rules enforcement or fail loud (V5) — the highest-impact known live gap; today a control operators trust is a no-op.
  3. Gate D matrix + Gate G mutation testing (V1/V2) — convert the “remembered checklist” defense of the delegation boundary into a structural one.
  4. 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/mappings API 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_provider with the subset of attributes

  • For every identity provider “oidc” and “jwt” protocol entries in the federation_protocol table 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_id via the Authorization fields. 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_id in 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.

  1. A OIDC type client should be created.
  • redirect_uris specifies 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 url http://localhost:8050/*) must be added.

  • client authorization should be enabled for the client for the better security. The client_secret is 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_id claim 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).

  1. 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.

  1. 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 = federation binds this ruleset to the identity provider.
  • user_name template interpolates the OIDC claim into the Keystone user name.
  • identity_mode: local creates/finds the user and syncs groups on every login.
  1. 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.

  1. 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
  1. 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.

  1. 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": []
      }
    ]
  }
}
  • source identifies the identity provider.
  • match defines the conditions for rule evaluation (empty array matches all).
  • identity_mode: "local" performs user CRUD and group sync on every login.
  • user_name and user_id are templates that interpolate OIDC claims.
  1. 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)

OptionDefaultPurpose
argon2_memory_kib65536Argon2id memory cost
argon2_time_cost3Argon2id iterations
argon2_parallelism4Argon2id parallelism
trusted_proxies(empty)CSV of CIDRs, e.g. 10.0.0.0/8,192.168.1.0/24
rate_limit_burst_size10Token-bucket burst, keyed on lookup_hash (or source IP for malformed tokens)
rate_limit_replenish_per_minute60Token-bucket refill rate
janitor_inactive_days90Auto-disable a key unused for this long
janitor_grace_days7Extra grace period absorbing async last_used_at write drift
janitor_tombstone_retention_days365Hard-purge revoked keys older than this

Exceeding the rate limit returns 429 Too Many Requests.

Errors

ConditionResponse
Malformed token / bad CRC32request dropped
Unknown / disabled / expired key401 (dummy Argon2id hash computed regardless, to avoid timing-based lookup enumeration)
IP outside allowed_ips401
Mapping resolves to zero authorizations401
Mapping resolves to more than one authorization401
Mapping resolves to any authorization other than a domain scope (project, system, …)401
Rate limit exceeded429
Re-enabling a revoked key via PUT409

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_credentials and 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 returned access_token carry OpenStack authorization data (openstack_context: scope + effective roles) and an aud that 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 scope entirely defaults to the client’s full allowed_scopesexcept openstack:api is 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.

  1. 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=S256
    

    PKCE (S256 only) is mandatory for public clients. Keystone serves its own login and consent pages.

  2. On success, your redirect_uri receives ?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 for refresh_token) a refresh_token.

  3. 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.

  1. Start the flow:

    POST /v4/oauth2/{domain_id}/device_authorization
    Content-Type: application/x-www-form-urlencoded
    
    client_id=<id>&scope=openid profile openstack:api
    

    Response:

    {
      "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
    }
    
  2. Show the user verification_uri_complete (or verification_uri + user_code) and have them approve it in a browser.

  3. 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 interval seconds — polling too fast returns slow_down per RFC 8628 §3.5, which means “back off further,” not a hard failure. user_code uses an unambiguous character set ([A-Z0-9] minus O/0/I/l/1) so it’s easy to type by hand.

Token types you’ll see

  • id_token — identity only, aud is your client_id. Never carries roles or OpenStack scope; only your client’s configured claims_template output is added.
  • access_token (openstack:api granted, or any client_credentials grant) — carries openstack_context (scope + effective roles at issuance time) and aud: "openstack-apis:{domain_id}". This is what OpenStack services accept.
  • access_token (openstack:api not granted) — minimal, aud is your own client_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/domains gets its signing key automatically (provisioned by Oauth2KeyHook on the domain-create event). A domain provisioned any other way — most notably the default domain, 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 (see ensure-signing-key below).
  • Tokens are stateless JWTs (id_token, access_token) signed ES256 by default (RS256 configurable). Refresh tokens are the one stateful piece — rotating, family-tracked, stored in Raft + FjallDB.
  • OAuth2Client registrations (relying parties / machine identities) are a fourth ADR 0020 provider resource, domain-owned, managed via the /v4/oauth2/{domain_id}/clients CRUD API below.

Configuration ([oauth2] in keystone.conf)

OptionDefaultPurpose
signing_algorithmES256ES256 or RS256. Governs both outbound signing and inbound verification — must match across a deployment.
signing_key_rotation_days90Automatic rotation cadence. Manual rotation via keystone-manage oauth2 rotate-signing-key is always available regardless of this value.
argon2_memory_kib65536Argon2id memory cost for confidential-client secret hashing.
argon2_time_cost3Argon2id iterations.
argon2_parallelism4Argon2id lanes.
access_token_lifetime_minutes15access_token TTL.
id_token_lifetime_minutes15id_token TTL.
authorization_code_lifetime_seconds60Single-use authorization code TTL.
refresh_token_lifetime_days30Idle lifetime of a refresh token family; reset on each rotation.
refresh_token_reuse_grace_minutes10Grace 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_minutes10Pre-authentication browser session TTL for the login/consent sequence.
device_code_lifetime_minutes10RFC 8628 device_code/user_code TTL.
device_code_poll_interval_seconds5Minimum interval between /token polls for a device_code.
token_rate_limit_burst_size10/token rate-limit burst, keyed on unverified client_id.
token_rate_limit_replenish_per_minute60/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_secret in the create/rotate-secret response body — it is never stored or returned again, only its Argon2id hash.
  • provider_id is unique within a domain; client_id is server-generated and globally unique (it’s the sole key presented at /token, before domain_id is known).
  • client_id, provider_id, domain_id are immutable after creation.
  • Setting pre_authorized: true (skips user consent for trusted first-party device-code clients) requires SystemAdmin regardless of the Tier 2 self-service path otherwise available on this endpoint, and is rejected together with openstack:api in allowed_scopes (a pre-authorized client cannot silently gain OpenStack authorization).
  • DELETE revokes the client and immediately invalidates all refresh tokens in its family tree. Outstanding bearer access/id tokens remain valid until natural exp — 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:

  1. A fresh keypair is generated and committed via Raft, promoted directly to Primary/Active — no grace-window overlap with the compromised key.
  2. 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 its jtis are published on a dedicated revocation list: GET /v4/oauth2/{domain_id}/jwks/revocation.
  3. --revoke-jti is how you seed that list: pass every jti you 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).
  4. The downstream middleware (below) checks this list on every token verification and fails closed if the endpoint is unreachable.
  5. A distinct CADF event (OAUTH2_EMERGENCY_KEY_ROTATION) is recorded with domain_id, revoked kid, new kid, operator identity, and the full revoked_jtis list.

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_idtrue 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=300 on /jwks).
    • Revocation list cache TTL: 60s.
  • aud is 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_issuers explicitly; claim presence of iss alone 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-jti is 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

SymptomLikely cause
/jwks or /.well-known/openid-configuration returns 404Domain has no signing key — run keystone-manage oauth2 ensure-signing-key --domain <id>
429 on /tokenRate limit hit — see token_rate_limit_* config
429 on /authorize, /device, /device/login, or /device_authorizationGlobal 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 blipExpected 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 errorConfirming 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. Subsequent GET/PUT/PATCH against that id from the owning realm return 404.

  • 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_days has 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:

OptionDefaultPurpose
driverraftStorage driver for realm records

[scim_resource] in keystone.conf:

OptionDefaultPurpose
driverraftStorage driver for the resource ownership index
janitor_deprovisioned_retention_days365Days 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

SymptomLikely cause
403 on every /Users//Groups requestRealm not registered for this (domain_id, provider_id), or enabled: false — check step 1
403 with a valid, enabled realmAPI 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 requestAPI-key-level auth failure — see API-Key Authentication troubleshooting
Individual operations (e.g. create) return 403/policy errorMapping ruleset doesn’t emit a role in {admin, manager, scim_provisioner} — check step 2
422 when writing the mapping ruleRole 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 createuserName/displayName/externalId already exists domain-wide, or under this realm — expected, not a bug
Newly-created resource immediately 404sDeprovisioned 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 attributeKeystone field
iddeterministic (domain_id + externalId)
externalIdrealm-scoped ownership index
userNamename
activeenabled
name.givenName / name.familyNameextension attributes
emails[primary eq true].valueextension attribute
displayNameextension attribute

Attribute Mapping (Group)

SCIM attributeKeystone field
idserver-assigned
externalIdrealm-scoped ownership index
displayNamename
membersresolved 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 attributeAllowed operators
userNameeq, ne, co, sw, pr
externalIdeq, ne, pr
ideq, pr
activeeq, pr
Group attributeAllowed operators
displayNameeq, ne, co, sw, pr
externalIdeq, ne, pr
ideq, 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:

ResourcePatchable paths
Useractive, userName, displayName, externalId, name.givenName, name.familyName
GroupdisplayName, 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"
}
ConditionHTTP statusscimType
Realm not registered / disabled403(no body)
Resource not owned by caller’s realm404(no body)
userName/displayName/externalId collision409uniqueness
Missing/wrong request schemas, cross-realm membership reference, invalid PATCH value400invalidValue
Disallowed filter attribute/operator/mixed chain/oversized400invalidFilter
Disallowed or unrecognized PATCH path400invalidPath
PATCH targeting a real but immutable path (id, meta)400mutability
Malformed JSON request body400invalidSyntax
Unsupported/missing Content-Type on a bodied request415(no body)
Unmapped HTTP method on a mapped path405(no body)
If-Match version mismatch412(no body)
/Bulk, /Me501(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 §FeatureStatusNotes
§3.1HTTP methods, Content-Type, Location headerSupportedAccepts application/json in addition to application/scim+json
§3.2Discovery (ServiceProviderConfig/Schemas/ResourceTypes)SupportedHonestly advertises unsupported features rather than over-claiming
§3.3POST (create)Supportedschemas and (User) externalId are mandatory
§3.4.2FilteringPartialRestricted attribute/operator allowlist, homogeneous and/or only, no nesting — see Filtering
§3.4.2.2Alternative search (POST .search)Not supported
§3.4.3PaginationSupportedOffset/count only, no continuation token
§3.4.4SortingNot supportedsort.supported: false in discovery
§3.4.5attributes/excludedAttributes query paramsNot supportedAlways returns the full mapped resource
§3.5.1PUT (replace)SupportedFull declarative replace, incl. Group membership resync
§3.5.2PATCH (modify)PartialScalar top-level path allowlist only — see PATCH
§3.6DELETEPartialSoft-delete/tombstone semantics, not hard delete — see DELETE semantics
§3.7Bulk operationsNot supportedExplicit 501, not a bare 404
§3.11/MeNot supportedExplicit 501 — no “current resource” concept for an API-key-authenticated client
§3.12Error response formatPartialEnvelope always present; only 6 of 10 defined scimTypes are emitted — see Errors
§3.14ETag / conditional requestsSupportedWeak ETags, atomic compare-and-swap on If-Match
§4 (core schema)User resource attributesPartialCore identity + name/email/displayName only — see Attribute Mapping
§4 (core schema)Group resource attributesPartialdisplayName + members only — see Attribute Mapping
§4 (core schema)Extension schemas (Enterprise User, etc.)Not supported
§7 (multi-tenancy)Tenant isolationSupported (different mechanism)Domain + realm scoping instead of a tenant attribute — see Ownership fencing
§8 (security)Bearer token authSupportedDomain-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-delegator role.

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 KeySource
k8s.serviceaccount.nameParsed from TokenReview username
k8s.serviceaccount.namespaceParsed from TokenReview username
k8s.audJWT 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

ActionEndpointDescription
Create clusterPOST /v4/k8s_auth/Register a new Kubernetes cluster
Manage instanceGET/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.

ActionEndpointDescription
Create rulesetPOST /v4/mappingCreate a ruleset with source: { type: "k8s", cluster_id: "..." }
List rulesetsGET /v4/mappingFilter by domain_id, source, enabled
Show rulesetGET /v4/mapping/{mapping_id}Full rule definitions
Update rulesetPUT /v4/mapping/{mapping_id}Toggle enabled, update allowed_domains, or replace rules
Delete rulesetDELETE /v4/mapping/{mapping_id}Remove ruleset
Mutate rulesPOST /v4/mapping/{mapping_id}/rules/mutateAtomic insert/update/delete of individual rules

Authentication Endpoint

ActionEndpointDescription
AuthenticatePOST /v4/k8s_auth/instances/{auth_instance_id}/authExchange K8s SA token for Keystone token

Authentication Request

{
  "jwt": "<jwt_from_k8s_service_account_token_volume>",
  "rule_name": "ci-pipeline-admin"
}
FieldTypeRequiredDescription
jwtstringyesKubernetes service account JWT token
rule_namestring | nullnoMapping-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 optional require_all_keys flag. 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 of MatchCriteria, 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 specified value. JSON primitives (numbers, booleans) are normalized to strings for comparison.
  • AnyOf: The claim value must match at least one value in the values array.
  • MatchesRegex: The claim value must match the given regex pattern. 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 to DomainResolutionMode constraints.
  • 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 (requires is_system: true). Requires system_id (typically "all") and roles.
  • Domain: Grants roles on a specific domain. Requires domain_id and roles.
  • Project: Grants roles on a specific project. Requires project_id, project_domain_id, and roles.

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 (default CreateOrGet):
    • 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 the domain_id on the ruleset. user_domain_id templates cannot reference ${claims.*} tokens.
  • ClaimsOnly: Domain is resolved exclusively from claims. At least one rule in the ruleset must use a ${claims.*} template in user_domain_id.
  • ClaimsOrMapping: Domain is resolved from user_domain_id template first, falling back to the ruleset domain_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

  1. Configure SPIFFE Trust: Ensure the Keystone service is configured to trust the SPIFFE trust domain.
  2. Create Mapping Rules: Create a mapping ruleset that targets spiffe source type with the appropriate trust_domain. The rules should match against spiffe.id or spiffe.trust_domain claims.
  3. Admin Shortcut: To bypass the mapping engine for administrative tasks, configure admin_svid in 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

ActionEndpointDescription
CreatePOST /v4/mappingCreate a new ruleset. Requires source, domain_resolution_mode, enabled, and rules.
ListGET /v4/mappingList rulesets. Filterable by domain_id, enabled, limit, and marker.
ShowGET /v4/mapping/{mapping_id}Get detailed rule definitions for a ruleset.
UpdatePUT /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.
DeleteDELETE /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 (before or after a named anchor rule).
  • Update: Replace an existing rule by its name.
  • Delete: Remove a rule by its name.

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: true are locked after creation to prevent privilege escalation via rule mutations.
  • AllOfStrict Claim Suppression Prevention: The require_all_keys flag in AllOfStrict prevents attackers from bypassing high-priority rules by suppressing specific claims in lower-trust assertions.
  • Template Safety: Templates cannot reference enclosing_domain_id as 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_salt to prevent virtual user ID collisions across clusters.
  • Stability: Changing cluster_salt after 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_salt is 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, critical
  • msg: Human-readable message
  • time: 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 directory
  • use_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 latency
  • keystone_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_failure alerts

OPA policy failures

  • Verify [api_policy] opa_base_url is 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:

  1. Unique node_id in [distributed_storage]
  2. Reachable cluster address via node_cluster_addr (https only, TLS required)
  3. Synchronized time (NTP) - consensus relies on clock accuracy
  4. 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_id explicitly, per node.
  • Up to one gossip_interval_seconds of 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 MappingRuleSet rules for final identity resolution
  • Requires MappingRuleSet rules exist under provider_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 domain
  • find_user - Look up existing provisioned users
  • assign_role - Grant roles to provisioned users (config-bounded to assign_role_allowed)

Audit logging is always enabled; it cannot be disabled.

Configuration Reference

KeyModeDefaultDescription
pathAllRequiredFilesystem path to .wasm plugin binary
sha256AllRequiredSHA-256 checksum of plugin file (verified at startup)
modeAllfull_authOperating mode: full_auth, mapping, or route
capabilitiesAllEmptyComma-separated host functions: http_fetch, provision_user, find_user, assign_role
exposed_headersAllEmptyHTTP headers plugin may access (comma-separated); hard-denied: Authorization, Cookie, X-Auth-Token, X-Subject-Token, Proxy-Authorization
allowed_hostsAllRequired if http_fetch usedHostname allowlist for http_fetch calls (comma-separated)
http_fetch_auth_headerAllOptionalHeader name to attach auth secret (e.g., Authorization)
http_fetch_auth_secret_envAllOptionalEnvironment variable containing auth secret (never enters guest memory)
http_fetch_follow_redirectsAllfalseAllow HTTP redirects (each hop re-validated against allowlist)
provision_domain_idfull_authRequired if provisioningSingle domain where plugin may create users; find_user revalidates on every call
allowed_provision_domainsfull_authAlternativeComma-separated list of domains; use if plugin must span multiple domains
assign_role_allowedfull_authRequired if assign_role usedComma-separated role names plugin may grant (e.g., member,reader)
inspect_methodsrouteRequiredComma-separated identity methods that trigger this plugin (e.g., application_credential)
route_targetsrouteRequiredComma-separated allowlist of methods this plugin may route to; admin and trust forbidden
timeout_msAll1000Wall-clock timeout for plugin invocation, including any http_fetch calls (the whole redirect chain shares this one budget, not one per hop)
fuel_limitAll10000000Instruction budget (protects against infinite loops)
memory_limit_mbAll16Linear-memory limit for plugin heap
invocation_rate_limit_per_source_per_minuteAll20Per-source-IP rate limit (sliding window)
invocation_rate_limit_per_minuteAll300Per-plugin global rate limit
max_concurrent_invocationsAll16Maximum simultaneous invocations
valid_sincefull_authNone (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 metric keystone_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’s Target.id (the plugin name)
  • 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

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/sha256 without a restart
  • Mapping rules changed (for mapping mode) - verify MappingRuleSet config
  • Identity links modified - check audit trail for admin changes

Plugin consuming too much memory/CPU

  • Increase fuel_limit or timeout_ms if legitimate
  • Check plugin code for memory leaks or inefficient algorithms
  • Reduce max_concurrent_invocations if CPU-bound
  • Review plugin logs and http_fetch external service performance

Maintenance & Upgrades

Keystone Upgrade

  1. Test in non-prod first - authentication changes are high-risk
  2. Backup fernet keys - [fernet_tokens] key_repository, [fernet_receipts] key_repository
  3. Stop all nodes - graceful shutdown prevents data loss
  4. Upgrade binary - pull new image or build from source
  5. Verify schema migrations - keystone-manage db upgrade
  6. Start nodes one at a time - wait for Raft consensus on each
  7. Monitor auth failures - transient failures are normal for 1-2 minutes

Plugin Update

When updating a plugin:

  1. Compute new SHA-256 of the updated .wasm
  2. Update config with new hash and new path if needed
  3. If this update fixes a security issue, also bump valid_since to the deployment instant. Updating sha256 alone does not invalidate outstanding tokens - version binding is a separate, explicit valid_since cutoff compared against each token’s issued_at (full_auth mode only; see the Configuration Reference table below). Forgetting this step leaves tokens minted by the previous (vulnerable) plugin version valid until they expire naturally.
  4. Restart Keystone (all nodes, one at a time)
  5. Optional: Run POST .../revoke_all if the plugin had a security fix, to also disable/unlink/revoke everything the compromised version provisioned or granted - valid_since alone 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] path directory, 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 = true temporarily to unblock (high-risk, last resort)

Security Best Practices

  1. Run Keystone as non-root - separate unprivileged user
  2. Protect fernet keys - restrictive file permissions (0600)
  3. TLS for cluster communication - inter-node replication is encrypted
  4. Audit logging enabled - retention policy required (compliance)
  5. Rate limiting tuned - prevent brute force / DDoS
  6. Policy-driven authz - OPA policies reviewed, tested, audited
  7. Plugin vetting - review capabilities and allowed_hosts before loading
  8. Secret management - http_fetch_auth_secret_env never 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 services
  • provision_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_id values
  • provision_user and find_user operate 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/Deny decisions

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:

  1. Deploy plugin with mode = mapping
  2. Create MappingRuleSet rules with provider_id = "wasm:{plugin_name}"
  3. Rules decide identity resolution (e.g., match claims to existing user by email)
  4. 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_credential that 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):

  1. Cannot touch scope (project/domain/system) - RouteResponse carries no scope field at all, only relabels which method handles the request
  2. Can only route to methods in this plugin’s route_targets allowlist - a response naming any other method is rejected as malformed, not corrected
  3. Can never target admin or trust methods, regardless of route_targets
  4. Single-shot - a request already routed once is never re-routed, by the same router or a different one
  5. 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 - raw identity.<method> block from the client’s auth request, exactly as received; deserialize it into whatever shape your plugin expects
    • headers: HashMap<String, String> - allowlisted subset of inbound HTTP headers (only names in this plugin’s exposed_headers config; 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, spoofable X-Forwarded-For value; None if no trusted address could be established
  • route: RouteRequest { methods, payloads, headers, remote_addr } - see the route mode section above; runs pre-dispatch on the client’s full identity.methods list, 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_hosts are 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_env config, 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 .wasm file
  • No redirects by default; opt in per-plugin with http_fetch_follow_redirects = true, and even then the whole redirect chain shares one timeout_ms budget, 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 in Allow.resolved_identity
  • Idempotent: same external_id on repeat calls returns a handle to the same user
  • Atomic: race-safe with concurrent requests
  • Domain-scoped: user.domain_id must be in this plugin’s provision_domain_id or allowed_provision_domains; only domain_id, name, enabled, extra are accepted - there is no id or 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):

  • role must be in this plugin’s assign_role_allowed list
  • Target project/domain must be in provision_domain_id / allowed_provision_domains
  • System scope is never allowed - there is no system variant of target at all, so a plugin has no way to even express that request
  • resolved_identity must be a handle this exact invocation’s own provision_user/find_user call 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 claims map
  • 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

  1. Keep plugins single-purpose - one auth logic, not multiple concerns
  2. Fail closed - when in doubt, deny the login, log the reason
  3. Validate external data - never trust HTTP responses or plugin input
  4. Use http_fetch carefully - SSRF protection is automatic, but credential handling must be reviewed
  5. Set reasonable timeouts - timeout_ms should be shorter than your external service’s SLA + overhead, and covers the whole invocation including any redirect chain
  6. Test failure paths - network timeouts, malformed responses, slow services
  7. Document claims - which external attributes map to which plugin claims
  8. Version plugins - git tag releases, pin SHA-256 checksums in deployments, and bump valid_since alongside sha256 when a change should invalidate outstanding tokens (full_auth mode only - see ADR §4 “Plugin Version Binding”)
  9. Audit identity changes - every host-function call and authenticate/ mapping/route outcome is CADF-audited (wasm_plugin.* events, ADR §6.E)
  10. Monitor rate limits - tune invocation_rate_limit_per_minute and max_concurrent_invocations based 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

Swagger UI

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.