OpenStack Keystone in Rust
The legacy Keystone identity service (written in Python and maintained upstream by OpenStack Foundation) has served the OpenStack ecosystem reliably for years. It handles authentication, authorization, token issuance, service catalog, project/tenant management, and federation services across thousands of deployments. However, as we embarked on adding next-generation identity features—such as native WebAuthn (“passkeys”), modern federation flows, direct OIDC support, JWT login, workload authorization, restricted tokens and service-accounts—it became clear that certain design and performance limitations of the Python codebase would hamper efficient implementation of these new features.
Consequently, we initiated a project termed “Keystone-NG”: a Rust-based component that augments rather than fully replaces the existing Keystone service. The original plan was to implement only the new feature-set in Rust and route those new API paths to the Rust component, while keeping the core Python Keystone service in place for existing users and workflows.
As development progressed, however, the breadth of new functionality (and the opportunity to revisit some of the existing limitations) led to a partial re-implementation of certain core identity flows in Rust. This allows us to benefit from Rust’s memory safety, concurrency model, performance, and modern tooling, while still preserving the upstream Keystone Python service as the canonical “master” identity service, routing only the new endpoints and capabilities through the Rust component.
In practice, this architecture means:
-
The upstream Python Keystone remains the main identity interface, preserving backward compatibility, integration with other OpenStack services, existing user workflows, catalogs, policies and plugins.
-
The Rust “Keystone-NG” component handles new functionality, specifically:
-
Native WebAuthN (passkeys) support for passwordless / phishing-resistant MFA
-
A reworked federation service, enabling modern identity brokering and advanced federation semantics OIDC (OpenID Connect) Direct in Keystone, enabling Keystone to act as an OIDC Provider or integrate with external OIDC identity providers natively JWT login flows, enabling stateless, compact tokens suitable for new micro-services, CLI, SDK, and workload-to-workload scenarios
-
Workload Authorization, designed for service-to-service authorization in cloud native contexts (not just human users)
-
Restricted Tokens and Service Accounts, which allow fine-grained, limited‐scope credentials for automation, agents, and service accounts, with explicit constraints and expiry
-
By routing only the new flows through the Rust component we preserve the stability and ecosystem compatibility of Keystone, while enabling a forward-looking identity architecture. Over time, additional identity flows may be migrated or refactored into the Rust component as needed, but our current objective is to retain the existing Keystone Python implementation as the trusted, mature baseline and incrementally build the “Keystone-NG” Rust service as the complement.
We believe this approach allows the best of both worlds: the trusted maturity of Keystone’s Python code-base, combined with the modern, high-safety, high-performance capabilities of Rust where they matter most.
Compatibility
Highest priority is to ensure that this implementation is compatible with the original python Keystone: authentication issued by Rust implementation is accepted by the Python Keystone and vice versa. At the same time it is expected, that the new implementation may implement new features not supported by the Python implementation. In this case, it is still expected that such features do not break authentication flows. It must be possible to deploy Python and Rust implementation in parallel and do request routing on the web server level.
Database
Adding new features most certainly require having database changes. It is not expected that such changes interfere with the Python implementation to ensure it is working correctly.
API
Also here it is expected that new API resources are going to be added. As above it is not expected that such changes interfere with the Python implementation to ensure it is still working correctly and existing clients will not break.
Installation
The easiest way to get started with the keystone-ng is using the container image. It is also possible to use the compiled binary. It can be either compiled locally or downloaded from the project artifacts.
Using pre-compiled binaries
As of the moment of writing there were no releases. Due to that there are no pre-compiled binaries available yet. Every release of the project would include the pre-compiled binaries for a variety of platforms.
Compiling
In order to compile the keystone-ng it is necessary to have the rust compiler
available. It may be installed from the system packages or using the rustup.rs
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
Afterwards in the root of the project source tree following command may be
executed to invoke the cargo
cargo build --release
It produces 2 binaries:
-
target/release/keystone (the api server)
-
target/release/keystone-db (the database management tool)
Currently keystone depends on the openssl (as a transitive dependency). Depending on the environment it may be a statically linked or dynamically. There are signals that that may be not necessary anymore once all dependencies transition to the use of rustls.
Using containers
It is possible to run Keystone-ng inside containers. A sample Dockerfile is
present in the project source tree to build container image with the Keystone
and the keystone-db utility. When no ready image is available it can be build
like that:
docker build . -t keystone:rust
Since keystone itself communicates with the database and OpenPolicyAgent those
must be provided separately. docker-compose.yaml demonstrates how this can be
done.
docker run -v /etc/keystone/:/etc/keystone -p 8080:8080 ghcr.io/openstack-experimental/keystone:main -v /etc/keystone/keystone.conf
Database migrations
Rust Keystone is using different ORM and implements migration that co-exist together with alembic migrations of the python Keystone. It also ONLY manages the database schema additions and does NOT include the original database schema. Therefore it is necessary to apply both migrations.
keystone-db --config /etc/keystone/keystone.conf
It is important to also understand that the DB_URL may differ between python and rust due to the optional presence of the preferred database driver in the url. keystone-ng will ignore the the driver in the application itself, but the migration may require user to manually remove it since it is being processed by the ORM itself and not by the keystone-ng code.
OpenPolicyAgent
keystone-ng relies on the OPA for policy enforcement. Default policies are provided with the project and can be passed directly to the OPA process or compiled into the bundle.
opa run -s policies
NOTE: by default OPA process listens on the localhost only what lead to
unavailability to expose it between containers. Please use -a 0.0.0.0:8181 to
start listening on all interfaces.
Parallel installation with the python Keystone
Since Keystone-NG is only an addition and is not a drop-in replacement for the python Keystone it is necessary to deploy both versions together.
With the python Keystone no changes on the deployment strategy should be necessary. Whichever WSGI method is used to run the Keystone it stays this way and continues listening on the expected port.
The rust Keystone is deployed in parallel to it (usually on the same hardware) and by default it listens on the port 8080.
Next required step is to alter the http proxy server configuration. In the devstack this is usually the Apache webserver. Some operators may run nginx or haproxy in front of the default webserver with the Keystone. Depending on the preferred functionality (i.e. whether the token validation should be performed by the python or the rust implementation) redirects can be implemented. This way it is possible to decide for every single API call individually whether it should be served by python or rust implementation.
Nginx sample configuration
server {
listen 443 ssl;
server_name devstack.v6.rocks;
...
# by default in devstack services are exposed with the url path style.
location /identity/v4 {
server http://localhost:8080;
}
proxy_pass http://<192.168.1.1>;
}
Apache sample configuration
<VirtualHost *:443>
ServerName devstack..v6.rocks
...
# Rust /v4 API
ProxyPass "/identity/v4" http://localhost:8080/v4 retry=0
# Python /v3 APIs are served by the uwsgi app
ProxyPass "/identity/v3" "unix:/var/run/uwsgi/keystone-api.socket|uwsgi://uwsgi-uds-keystone-api/v3" retry=0
# We want discovery URL to be served by Rust. The same way any /v3/ API can
# be forwarded to rust version (where supported).
ProxyPass "/identity" http://localhost:8080 retry=0
</VirtualHost>
Architecture
Keystone requires 2 additional components to run:
-
database (the same as the py-keystone uses)
-
OpenPolicyAgent, that implements API policy enforcement
architecture-beta
service db(database)[Database]
service keystone(server)[Keystone]
service opa(server)[OpenPolicyAgent]
db:L -- R:keystone
opa:L -- T:keystone
Database
Python keystone uses the sqlalchemy as ORM and the migration tool. It cannot be
used from Rust efficiently, therefore keystone-ng uses the sea-orm which
provides async support natively and also allows database type abstraction.
Current development focuses on the PostgreSQL database. The MySQL should be
supported, but is not currently tested against.
New API and resources are being added. This requires database changes. sea-orm
also comed with the migration tools. However there is a slight difference
between sqlalchemy and sea-orm. The later suggests doing database schema first.
In the next step object types are created out of the database. That means that
the database migration must be written first and cannot be automatically
generated from the code (easily, but there is a way). Current migrations do not
create database schema that is managed by the py-keystone. Therefore in order to
get a fully populated database schema it is necessary to apply
keystone-manage db_sync and keystone-db up independently.
Target of the keystone-ng is to be deployed in pair with the python keystone of “any” version. Due to that it is not possible to assume the state of the database, nor to apply any changes to the schema manaaged by the py-keystone. A federation rework assumes model change. To keep it working with the python-keystone artificial table entries may be created (in the example when a new identity provider is being created automatically sanitized entries are being added for the legacy identity provider and necessary protocols) A federation rework assumes model change. To keep it working with the python-keystone artificial table entries may be created (in the example when a new identity provider is being created automatically sanitized entries are being added for the legacy identity provider together with necessary idp protocols).
Fernet
keystone-ng uses the same mechanism for tokens to provide compatibility. The fernet-keys repository must be provided in the runtime (i.e. by mounting them as a volume into the container). There is no tooling to create or rotate keys as the py-keystone does.
Architecture Decision Records
Keystone project uses Architecture Decision Records to describe why the software is built how it is built.
1. Record architecture decisions
Date: 2025-11-03
Status
Accepted
Context
We need to record the architectural decisions made on this project.
Decision
We will use Architecture Decision Records, as described by Michael Nygard.
Consequences
See Michael Nygard’s article, linked above. For a lightweight ADR toolset, see Nat Pryce’s adr-tools.
2. Open Policy Agent
Date: 2025-11-03
Status
Accepted
Context
Use of oslo.policy is not easily possible from Rust. In addition to that during the OpenStack Summit 2025 it was shown how Open Policy Agent can be used to further improve the policy control in OpenStack. As such the Keystone implement the policy enforcement using the OPA with the following rules:
-
Listoperation MUST receive the all query parameters of the operation in the target. -
For
Showoperation the policy MUST receive the current record as the target (fetch the record and pass it into the policy engine). -
Updateoperation MUST receive current and new state of the resource (first the current resource is fetched and passed together with the new state [current, target] to the policy engine). -
Createoperation works similarly as current oslo.policy with the desired state passed to the policy engine. -
Deleteoperation MUST pass the current resource state of the resource into the policy engine.
Decision
The only policy enforcement engine supported in the Keystone is Open Policy Engine.
Consequences
-
Policy evaluation requires external service (OPA) to be running.
-
When covering existing functionality of the python Keystone policies SHOULD be converted as is and do not introduce a changed flow.
3. Sea ORM
Date: 2025-11-03
Status
Accepted
Context
Sea ORM provides a nice full async support while at the same time enables simultaneous support for multiple database backends (PostgreSQL, MySQL, …).
Decision
Sea ORM is used as the ORM library for the database communication.
Consequences
No known
4. v4 API
Date: 2025-11-03
Status
Accepted
Context
New authentication workflows cannot be covered with the existing Keystone v3 API. As such it is required to add new API methods and eventually change the existing. As such not to break compatibility and provide a relatively easy way to route the traffic allowing both project (python and rust) to co-exist a new v4 API version should be introduced.
Decision
-
All new auth methods MUST be implemented in v4.
-
Known issues with the v3 API SHOULD be addressed in the v3.
Consequences
-
New extended functionality will not be available in the v3.
-
Certain necessary changes may be ported to the v3 (including python) to implement backwards compatibility. Example: acknowledge new token payload issued with v4 with the python Keystone.
5. Passkey Auth
Date: 2025-11-03
Status
Accepted
Context
Nowadays password-less authentication becomes standard. In OpenStack it is at the moment not implemented whether on the API level (for the CLI) nor on the UI.
Webauthn is a well accepted standard for implementing password-less authentication with the help of hardware or software authenticators. Keystone should implement support for new authentication methods relying on the webauthn.
Decision
Introduce webauthn support in Keystone. This requires adding new database tables and introduction of the additional flows to allow user registering authenticators.
-
webauthn_credentialtable describes the authenticators of the user (user_id as the primary key). -
webauthn_statetable stores authentication and registration states according to the standard. -
User should be able to request the desired scope in the authentication initialization request. In this case a scoped token is returned when user has the required access.
-
To prevent attacks authentication requests for not existing users or users without registered authenticators MUST return fake (but valid) authentication state.
Consequences
New authentication method allows users to get valid token without requiring user to pass any secrets on the wire. Overall security of the system is increased.
6. Federation IDP
Date: 2025-11-03
Status
Accepted
Context
OIDC requires the server side to know the Identity provider details. Python Keystone relies on the external software to implement the OIDC flow only receiving the final data once the flow completes. Certain flows are triggered by the Service side (i.e. back-channel-logout). In addition to that relying on the external software does not allow any seld-service for the customer.
v3 currently provides limited OIDC support, but it is not possible to extend it in a backward compatible way.
As such OIDC support must be implemented natively in Keystone.
Decision
Keystone implement OIDC support natively without relying on the 3rd-party software. New APIs must provide self-service capabilities. Identity providers may be global (i.e. a social login) or dedicated (i.e. private Okta tenant).
A new set of APIs and database tables is added to Keystone for implementing new functionality. Existing DB constraints MUST not be deleted and only additive changes can be implemented to allow parallel deployment of python and rust Keystones for the smooth transition. “Virtual” database entries MUST be inserted for the old-style identity provider to guarantee the co-existence.
Global/private identity providers are implemented using the optional domain_id
attribute. When empty the identity provider is treated as global (shared) and is
correspondingly visible to every user of the cloud. Private IdPs SHOULD be only
visible to the users of the domain. Corresponding rules MUST be implemented on
the policy level to allow customization by the CSP.
The IdP specifies client_id and client_secret (when necessary). client_secret
MUST not be retrievable. It can only be set during create or update operations.
It MUST be also possible to specify JWKS urls when the identity provider does
not implement metadata discovery.
Consequences
New APIs must be implemented in the CLI.
7. Federation Mapping
Date: 2025-11-03
Status
Accepted
Context
OIDC protocol describes how the user data is being passed to the service provider. It is necessary to translate this information to the Keystone data model. In v3 “mapping” is being used to describe this translation. The data model of the v3 mapping is, however, unnecessary complex (due to historical reasons). HashiCorp vault describes “mapping“s as “roles” for such translations. Since it is not possible to use the term “role” the mapping should continue to be used instead. The model of the Vault role provides a nice and easy reference for Keystone.
Decision
“Mapping” (attribute mapping) MUST describe how the information from OIDC claims need to be translated into the Keystone data model. It MUST also describe user defined bounds to allow use restriction.
When domain_id is not being set on the IdP level it MUST be defined either on
the mapping entry, or the mapping MUST define domain_id_claim to extract the
information about domain relation of the user. domain_id MUST be immutable
property of the mapping to prevent moving it to the foreign domain.
Mapping MUST have the name attribute that is unique within the domain.
default_mapping_name property SHOULD be specified on the IdP level to provide
a default for when the user does not explicitly specify which mapping should be
used.
Consequences
- Mappings MUST be configured carefully to prevent login of users across the
domain borders.
bound_xxxshould be used extensively to guard this.
8. Workload Federation
Date: 2025-11-03
Status
Accepted
Context
It is often desired to access the OpenStack cloud from workloads (i.e. GitHub workflow, Zuul job, etc). Usually such services provide a JWT issued by the platform which the service provider can trust. This is very similar (and technically relates) to the OIDC standard.
In the JWT flow the “user” is exchanging a JWT token issued by the trusted IdP for a Keystone token. This authentication response includes a token and a service catalog to provide a known OpenStack usage scenario.
Decision
OIDC mappings MUST specify a type which is oidc or jwt to specify the flow
they define. A jwt type mapping can be only used in the JWT flow.
The new authentication API includes the IdP ID. The authentication request does
not support the Json request body and uses a generic
authorization: bearer <jwt> header and
openstack-mapping-name: <mapping_name> to request the information. Depending
on the mapping configuration the desired authorization scope is returned. The
flow does not support explicitly requesting the scope beyond what is described
by the mapping.
Consequences
-
A new API to exchange JWT token for the Keystone token is added.
-
JWT auth must provide the mapping name.
-
The mapping SHOULD point to some for of the technical user.
9. Auth token revocation
Date: 2025-11-18
Status
Accepted
Context
Issued tokens are having certain configurable validity. In cases when a user need to be disabled, the project deactivated, or simply to prevent the token use after the work has been completed it is necessary to provide the possibility to invalidate the tokens. Python Keystone provides this possibility and so it is necessary to implement it in the same way.
Since original functionality is not explicitly documented this ADR will become the base of such information.
Decision
Fernet token revocation is implemented based on the revocation_event database
table.
The table has following fields:
pub id: i32,
pub domain_id: Option<String>,
pub project_id: Option<String>,
pub user_id: Option<String>,
pub role_id: Option<String>,
pub trust_id: Option<String>,
pub consumer_id: Option<String>,
pub access_token_id: Option<String>,
pub issued_before: DateTime,
pub expires_at: Option<DateTime>,
pub revoked_at: DateTime,
pub audit_id: Option<String>,
pub audit_chain_id: Option<String>,
Token revocation
When a revocation of thecurrently valid token is being requested the record with the following information is being inserted into the database:
audit_idis populated with the first entry of the tokenaudit_idslist. When this list is empty an error is being returned.issued_beforeis set to the current time with the UTC timezone.revoked_atis set to the current time with the UTC timezone.- other fields are left empty.
Revocation check
A token validation for being revoked is performed based on the presence of the
revocation events in the revocation_event table matching the expanded token
properties. This means that before the token revocation is being checked
additional database queries for expanding the scope information including the
roles the token is granting are performed.
Following conditions are combined with the AND condition:
- First element of the token’s
audit_idsproperty is compared against the database record. When this list is empty an error is being returned. token.project_idis compared against the database record when present.token.user_idis compared against the database record when present.token.trustor_idis compared against the database recorduser_idwhen present.token.trustee_idis compared against the database recorduser_idwhen present.token.trust_idis compared against the database recordtrust_idwhen present.token.issued_atis compared against the database record withrevocation_event.issued_before >= token.issued_at.
Python version of the Keystone applies additional match verification for the selected data on the server side and not in the database query.
- When
revocation_event.domain_idis set it is compared againsttoken.domain_idandtoken.identity_domain_id. - When
revocation_event.role_idis present it is compared against every of thetoken.roles.
After the first non matching result further evaluation is being stopped. Logically there does not seem to be a reason for such handling and it looks to be an evolutionary design decision. Following checks can be added into the single database query with a different logic only comparing the corresponding fields when the column is not empty.
While following checks allow much higher details of the revocation events in the
context of the usual fernet token revocation it is only going to match on the
audit_id and issued_before.
Revocation table purge
In the python Keystone there is no automatic cleanup handling. Due to that expired records are removed during the revocation check. Records to be expired are selected using the following logic.
expire_delta = CONF.token.expiration + CONF.token.expiration_bufferoldest = utc.now() - expire_deltaDELETE from revocation_event WHERE revoked_at < oldest
When both python and rust Keystone versions are deployed in parallel and both
try to delete expired records errors can occur. However, if only rust version is
validating the tokens python version will not perform any backups. Additionally
no errors were reported yet in installations with multiple Keystone instances.
Therefore it is necessary for the rust implementation to do periodic cleanup. It
should be exexcuted with the following query filter:
revoked_at < (now - (expiration + expiration_buffer)). Such implementation
must be made optional with possibility to disable this behavior using the config
file.
Consequences
-
Database table with the revocation events must be periodically cleaned up.
-
Token validation processing time is increased with the database lookup.
-
Expired revocation records are optionally periodically cleaned by the rust implementation.
10. PCI-DSS requirement: Invalid authentication attempts are limited
Date: 2025-11-27
Status
Accepted
Context
PCI-DSS contains the following requirement to the IAM system:
Invalid authentication attempts are limited by:
- Locking out the user ID after not more than 10 attempts.
- Setting the lockout duration to a minimum of 30 minutes or until the user’s identity is confirmed.
Python Keystone implements this requirement with the help of the
conf.security_compliance.lockout_duration during the login attempt to identify
whether the user is currently temporarily disabled:
def _is_account_locked(self, user_id, user_ref):
"""Check if the user account is locked.
Checks if the user account is locked based on the number of failed
authentication attempts.
:param user_id: The user ID
:param user_ref: Reference to the user object
:returns Boolean: True if the account is locked; False otherwise
"""
ignore_option = user_ref.get_resource_option(
options.IGNORE_LOCKOUT_ATTEMPT_OPT.option_id
)
if ignore_option and ignore_option.option_value is True:
return False
attempts = user_ref.local_user.failed_auth_count or 0
max_attempts = CONF.security_compliance.lockout_failure_attempts
lockout_duration = CONF.security_compliance.lockout_duration
if max_attempts and (attempts >= max_attempts):
if not lockout_duration:
return True
else:
delta = datetime.timedelta(seconds=lockout_duration)
last_failure = user_ref.local_user.failed_auth_at
if (last_failure + delta) > timeutils.utcnow():
return True
else:
self._reset_failed_auth(user_id)
return False
Decision
For compatibility reasons rust implementation must adhere to the requirement.
During password authentication before validating the password following check must be applied part of the locked account verification:
-
When
conf.security_compliance.lockout_durationandconf.security_compliance.lockout_failure_attemptsare not set the account is NOT locked. -
When
user_options.IGNORE_LOCKOUT_ATTEMPTis set user account is NOT locked -
When
user.failed_auth_count >= conf.security_compliance.lockout_failure_attemptsthe account is locked. -
When
user.failed_auth_at + conf.security_compliance.lockout_duration > now()account is locked. When the time is< now()- reset the counters in the database. -
Otherwise the account is NOT locked.
After the authentication is success the user.failed_auth_at and
user.failed_auth_count are being reset. In the case of failed authentication
such attempt sets the mentioned properties correspondingly.
Consequences
-
Authentication with methods other than username password are not protected.
-
Reactivating the temporarily locked account can be performed by the admin or domain admin via resetting the
user.failed_auth_countattribute.
11. PCI-DSS requirement: Inactive user accounts are removed/disabled
Date: 2025-11-27
Status
Accepted
Context
PCI-DSS contains the following requirement to the IAM system:
Inactive user accounts are removed or disabled within 90 days of inactivity.
Python Keystone implements this requirement with the help of the
conf.security_compliance.disable_user_account_days_inactive during the login
attempt to identify whether the user is currently active or deactivated:
def enabled(self):
"""Return whether user is enabled or not."""
if self._enabled:
max_days = (
CONF.security_compliance.disable_user_account_days_inactive
)
inactivity_exempt = getattr(
self.get_resource_option(
iro.IGNORE_USER_INACTIVITY_OPT.option_id
),
'option_value',
False,
)
last_active = self.last_active_at
if not last_active and self.created_at:
last_active = self.created_at.date()
if max_days and last_active:
now = timeutils.utcnow().date()
days_inactive = (now - last_active).days
if days_inactive >= max_days and not inactivity_exempt:
self._enabled = False
return self._enabled
In python Keystone there is no periodic process that deactivates inactive accounts. Instead it is calculated on demand during the login process and listint/showing user details. With the new application architecture in Rust it is possible to implement background processes that disable inactive users. This allows doing less calculations during user authentication and fetching since it is possible to rely that the background process deactivates accounts when necessary.
Decision
For compatibility reasons rust implementation must adhere to the requirement.
After successful authentication when user.enabled attribute is not true the
authentication request must be rejected with http.Unauthorized.
Additional background process must be implemented to deactivate inactive
accounts. For this when
conf.security_compliance.disable_user_account_days_inactive is set a process
should loop over all user accounts. When the
user.last_active_at + disable_user_account_days_inactive < now() presence of
the user.options.IGNORE_USER_INACTIVITY_OPT should be checked. When absent the
account must be updated setting user.enabled to false.
Since it is technically possible that the background process is not running for any reason the same logic should be applied also when converting the identity backend data to the internal account representation and applied when the user data is reported by the backend as active. On the other hand having a separate background process helps updating account data in the backend and produce audit records on time without waiting for the on-demand logic to apply. It also allows disabling accounts in the remote identity backends that are connected with read/write mode (i.e. SCIM push).
After the successful authentication of the user with password or the federated
workflow the user.last_active_at should be set to the current date time.
Consequences
-
Authentication with methods other than username password are not updating the
lst_active_at. Due to that the account that used i.e. application credentials for the activation for more than X days would become disabled. This requires account to perform periodic login using the password. -
It should be considered to update application credentials workflow to update the
user.last_active_atattribute after successful authentication. -
It could happen that the periodic account deactivation process does not work for certain amount of time (i.e due to bugs in the code or the chosen frequency) allowing the user to login when it should have been disabled. This can be only prevented by applying the same logic during the conversion of the database entry to the internal
Userstructure the same way like python keystone is doing. -
Administrator account can be deactivated. Separate tooling or documentation how to unlock the account must be present.
12. PCI-DSS requirement: Inactive user accounts are removed/disabled
Date: 2025-11-27
Status
Accepted
Context
PCI-DSS contains the following requirement to the IAM system:
If passwords/passphrases are used as the only authentication factor for user access (i.e., in any single-factor authentication implementation) then either:
-
Passwords/passphrases are changed at least once every 90 days, OR
-
The security posture of accounts is dynamically analyzed, and real-time access to resources is automatically determined accordingly.
Python Keystone implements this requirement with the help of the
conf.security_compliance.password_expires_days and password.expires_at
during the login attempt to identify whether the specified used password is
expired. user.options.IGNORE_PASSWORD_EXPIRY_OPT option allows bypassing the
expiration check.
Decision
For compatibility reasons rust implementation must adhere to the requirement.
Password expiration is performed after verification that the password is valid.
-
password.expires_at_int(as epoch seconds) or thepassword.expires_at(as date time specifies the password expiration. When none is set password is considered as valid. Otherwise it is compared against the current time. -
During account password update operation when user is not having the
user.options.IGNORE_PASSWORD_EXPIRY_OPToption enabled the current date time plus theconf.security_compliance.password_expires_daystime is persisted as thepassword.expires_at_intproperty. -
Password expiration MUST NOT be enforced in the password change flow to prevent a permanent lock out.
Consequences
- Administrator account can be deactivated. Separate tooling or documentation how to unlock the account must be present.
13. OpenIDConnect federation: Expiring group membership
Date: 2025-12-09
Status
Accepted
Context
Python Keystone uses expiring group membership for the federated users
https://specs.openstack.org/openstack/keystone-specs/specs/keystone/ussuri/expiring-group-memberships.html.
Every time the user authenticates using the federated login it’s group
membership are persisted in the expiring_user_group_membership table instead
of the user_group_membership. The table has a non nullable column
last_verified which is set to the time of the last user login. The user is
considered to be included as a member of the group for the period of time
specified in the conf.federation.default_authorization_ttl. Once the
user.last_verified + ttl < current_timestamp() the user is not considered the
member of the group anymore. The intention of this mechanism is to prevent stale
group memberships granting the user privileges.
Decision
For compatibility reasons rust implementation must implement the same functionality.
Every time the user authenticates the user group memberships are persisted in
the expiring_user_group_membership table.
-
Current group membership is being read from the database (ignoring the time limitation).
-
The group membership that the user should not be having anymore are deleted.
-
For the new group memberships corresponding entries are added with the current timestamp.
-
For all other groups that the user is still member of corresponding records are updated to set
last_verifiedto the current timestamp. -
Effective role assignments of the user are taking into the consideration expiring group memberships through the
list_user_groupsrespecting the expiring membershipts (independent of theidp_id) asexpiring_user_group_membership.last_verified > current_timestamp - conf.federation.default_authorization_ttl.
Consequences
-
The user must login periodically to keep application credentials working when corresponding roles are granted through the expiring group membership.
-
With the SCIM support the expiring membership should not be necessary.
14. Application Credentials
Date: 2025-12-12
Status
Accepted
Context
Application Credentials will have the following characteristics:
-
Immutable.
-
Allow for optionally setting limits, e.g. 5 Application Credentials per User or Project, to prevent abuse of the resource.
-
Assigned the set of current roles the creating User has on the Project at creation time, or optionally a list of roles that is a subset of the creating User’s roles on the Project.
-
Secret exposed only once at creation time in the create API response.
-
Limited ability to manipulate identity objects (see Limitations Imposed)
-
Support expiration.
-
Are deleted when the associated User is deleted.
Application Credentials will be treated as credentials and not authorization tokens, as this fits within the keystone model and is consistent with others APIs providing application authentication. It also avoids the security and performance implications of creating a new token type that would potentially never expire and have custom validation.
Decision
For compatibility reasons rust implementation must implement the same functionality.
Application Credential Management
Users can create, list, and delete Application Credentials for themselves. For example, adding an Application Credential:
POST /v3/users/{user_id}/application_credentials
{
"application_credential": {
"name": "backup",
"description": "Backup job...",
"expires_at": "2017-11-06T15:32:17.000000",
"roles": [
{"name": "Member"}
]
}
}
name must be unique among a User’s application credentials, but name is only guaranteed to be unique under that User. name may be useful for Consumers who want human readable config files.
description is a long description for storing information about the purpose of the Application Credential. It is mostly useful in reports or listings of Application Credential.
expires_at is when the Application Credential expires. null means that the
Application Credential does not automatically expire. expires_at is in ISO
Date Time Format and is assumed to be in UTC if an explicit timezone offset is
not included.
roles is an optional list of role names or ids that is a subset of the roles the Creating User has on the Project to which they are scoped at creation time. Roles that the Creating User does not have on the Project are an error.
In the initial implementation, the Application Credential will assume the roles of the Creating User or the given subset and we will not implement fine-grained access controls beyond that.
Response example:
{
"application_credential": {
"id": "aa4541d9-0bc0-44f5-b02d-a9d922df7cbd",
"secret": "a49670c3c18b9e079b9cfaf51634f563dc8ae3070db2...",
"name:" "backup",
"description": "Backup job...",
"expires_at": "2017-11-06T15:32:17.000000",
"project_id": "1a6f968a-cebe-4265-9b36-f3ca2801296c",
"roles": [
{
"id": "d49d6689-b0fc-494a-abc6-e2e094131861",
"name": "Member"
}
]
}
}
The id in the response is the Application Credential identifier and would be returned in get or list API calls. An id is globally unique to the cloud.
secret is a random string and only returned via the create API call. Keystone
will only store a hash of the secret and not the secret itself, so a lost secret
is unrecoverable. Subsequent queries of an Application Credential will not
return the secret field.
roles is a list of role names and ids. It is informational and can be used by
the Consumer to verify that the Application Credential inherited the roles from
the User that the Consumer expected. This is not a policy enforcement, it is
simply for human validation.
If the Consumer prefers to generate their own secret, they can do so and provide it in the create call. Keystone will store a hash of the given secret. Keystone will return the secret once upon creation in the same way it would if it was generated, but will not store the secret itself nor return it after the initial creation.
A Consumer can list their existing Application Credentials:
GET /v3/users/{user_id}/application_credentials
{
"application_credentials": [
{
"id": "aa4541d9-0bc0-44f5-b02d-a9d922df7cbd",
"name:" "backup",
"description": "Backup job...",
"expires_at": "2017-11-06T15:32:17.000000",
"project_id": "1a6f968a-cebe-4265-9b36-f3ca2801296c",
"roles": [
{
"id": "d49d6689-b0fc-494a-abc6-e2e094131861",
"name": "Member"
}
]
}
]
}
A Consumer can get information about a specific existing Application Credential:
GET /v3/users/{user_id}/application_credentials/{application_credential_id}
{
"application_credentials": [
{
"id": "aa4541d9-0bc0-44f5-b02d-a9d922df7cbd",
"name:" "backup",
"description": "Backup job...",
"expires_at": "2017-11-06T15:32:17.000000",
"project_id": "1a6f968a-cebe-4265-9b36-f3ca2801296c",
"roles": [
{
"id": "d49d6689-b0fc-494a-abc6-e2e094131861",
"name": "Member"
}
]
}
]
}
A Consumer can delete one of their own existing Application Credential to invalidate it:
DELETE /v3/users/{user_id}/application_credentials/{application_credential_id}
Note
Application Credentials that expire will be deleted. The alternative would be to allow them to accumulate for forever in the hopes that keeping them around will make investigation as to why an Application is not working easier, but the only real benefit to this is providing a different error message. More thought and feedback on this are needed, but are not essential for the first round of work.
When the Creating User for an Application Credential is deleted, or if their roles on the Project to which the Application Credential is scoped are unassigned, that Application Credential is also deleted.
Aside from deletion, Application Credentials are immutable and may not be modified. Using an Application Credential to Obtain a Token
An Application Credential can be used for authentication to request a scoped token following Keystone’s normal authorization flow. For example:
POST /v3/auth/tokens
{
"auth": {
"identity": {
"methods": [
"application_credential"
],
"application_credential": {
"id": "aa4541d9-0bc0-44f5-b02d-a9d922df7cbd",
"secret": "a49670c3c18b9e079b9cfaf51634f563dc8ae3070db2..."
}
}
}
}
Keystone will validate the Application Credential by matching a hash of the key secret associated with the id similar to how Keystone does Password authentication currently.
If the Application Credential is referred to by name, it will be necessary to
provide either user_id or the combination of user_name and
user_domain_name so that Keystone can look up the Application Credential for
the User.
POST /v3/auth/tokens
{
"auth": {
"identity": {
"methods": [
"application_credential"
],
"application_credential": {
"name": "backup",
"user": {
"id": "1a6f968a-cebe-4265-9b36-f3ca2801296c"
},
"secret": "a49670c3c18b9e079b9cfaf51634f563dc8ae3070db2..."
}
}
}
}
As an alternative to the current use of Service Users, a Deployer could create a single Service User and an Application Credential for each service. Or even create a Nova user and then give each nova instance it’s own Application Credential. Although at this point the Application Credential does not have the ability to further limit API use, the ability to start assigning Application Credentials per-service and performing expiration and rotation may be a desirable step forward that can be further enhanced with the addition of restricting an Application Credential’s API Access.
Consequences
This would have a positive security impact:
-
Instead of having a Service User for each service, all services can use a single Service User and multiple Application Credentials. This decreases the attack vector of gaining access to privileged operations by reducing the number of accounts to attack.
-
Usernames and passwords are kept out of configuration files. While Application Credentials are still extremely sensitive, if compromised they do not allow attackers to glean service user password conventions from configuration.
-
Application Credentials will grow the ability to have limited access, so a move to them is a step towards limited access credentials.
-
Application Credentials can be gracefully rotated out of use and deleted periodically, allowing Consumers and Deployers a mechanism to prevent compromised Users without requiring swapping credentials in short amounts of time that might cause service interruption or downtime.
-
Although we had long considered allowing application credentials to live beyond the lifetime of its creating user in order to allow seamless application uptime when the user leaves the team, it unfortunately poses too high a risk for abuse. Ensuring the application credential is deleted when the user is deleted or removed from the project will prevent malicious or lazy users from giving themselves access to a project when they should no longer have it.
There is an inherent risk with adding a new credential type and changing authentication details. One such risk would be the allowing of many credentials for the same User account.
End user impact
- Consumers who have Applications that monitor or interact with OpenStack Services should be able to leverage this feature to improve the overall security and manageability of their Applications.
- Consumers can gracefully rotate Application Credentials for an Application with no downtime by creating a new Application Credential, updating config files to use the new Application Credential, and finally deleting the old Application Credential.
- Consumers who do not start using Application Credentials should experience no impact.
Deployers impact
- Deployers only need to enforce security on a single Service User instead of multiple.
- Password rotation policies for Service Users no longer require immediately redeploying service configuration files. A User password change does not affect the existing Application Credential in the various service configuration files.
- Deployers can gracefully rotate Application Credentials through a deployment with no downtime.
API policy enforcement
API policy is implemented using the
Open Policy Agent (OPA). It is a very powerful
tool and allows implementing policies much more complex than what the
oslo.policy would ever allow. The policy folder contain default policies.
They can be overloaded by the deployment.
OPA can be integrated into Keystone in 2 ways:
-
HTTP. This is a default and recommended way of integrating applications with the OPA. Usually the OPA process is started as a side car container to keep network latencies as low as possible. Policies themselves are bundled into the container which OPA process is capable of downloading and even periodically refreshing. It can be started as
opa run -s --log-level debug tools/opa-config.yaml. Alternatively the OPA process can itself run in the container in which case the configuration file should be mounted as a volume and referred from the entrypoint. -
WASM. Policies can be built into a WASM binary module. This method does not support feeding additional data and dynamic policy reload as of now. Unfortunately there is also a memory access violation error in the
wasmtimecrate happening for the big policy files. The investigation is in progress, so it is preferred not to rely on this method anyway. While running OPA as a WASM eliminates any networking communication, it heavily reduces feature set. In particular hot policy reload, decision logging, external calls done by the policies themselves are not possible by design. Using this way of policy enforcement requireswasmfeature enabled.
All the policies currently are using the same policy names and definitions as the original Keystone to keep the deviation as less as possible. For the newly added APIs this is not anymore the case.
With the Open Policy Agent it is not only possible to define a decision (allowed or forbidden), but also to produce additional information describing i.e. reason of the request refusal. This is currently being used by the policies by defining an array of “violation” objects explaining missing permissions.
Sample policy for updating the federated IDP mapping:
package identity.mapping_update
# update mapping.
default allow := false
allow if {
"admin" in input.credentials.roles
}
allow if {
own_mapping
"manager" in input.credentials.roles
}
own_mapping if {
input.target.domain_id != null
input.target.domain_id == input.credentials.domain_id
}
violation contains {"field": "domain_id", "msg": "updating mapping for other domain requires `admin` role."} if {
identity.foreign_mapping
not "admin" in input.credentials.roles
}
violation contains {"field": "role", "msg": "updating global mapping requires `admin` role."} if {
identity.global_mapping
not "admin" in input.credentials.roles
}
violation contains {"field": "role", "msg": "updating mapping requires `manager` role."} if {
identity.own_mapping
not "member" in input.credentials.roles
}
As can be guessed such policy would permit the API request when admin role is
present in the current credentials roles or the mapping in scope is owned by the
domain the user is currently scoped to with the manager role.`
List operation
All query parameters are passed into the policy engine to be provide capability
of making decision based on the parameters passed. For example an admin user may
specify domain_id parameter when the current authentication scope is not
matching the given domain_id or a user with the manager role being able to
list shared federated identity providers.
Policy is being evaluated before the real data is being fetched from the backend.
Show operation
Policy evaluation for GET operations on the resource are executed with the requested entity in the scope. This allows policy to deny the operation if the user requested resource it is should not have access to. This means that 404 error may be raised before the validation of whether the user is allowed to perform such operations.
Create operation
Resource creation operation would pass the whole object to be created in the context to the policy enforcement engine.
Update operation
For the update operation the context contain the current state of the resource and the new one. This allows defining policies preventing resource update upon certain conditions (i.e. when tag “locked” is added).
Delete operation
Resource deletion also passes the current resource state in the context to allow comprehensive logic.
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/mappings (manage the mappings tied to the identity provider)
-
/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)
DB changes
Following tables are added:
- federated_identity_provider
#![allow(unused)]
fn main() {
//! `SeaORM` Entity, @generated by sea-orm-codegen 1.1.7
#[cfg(test)]
use derive_builder::Builder;
use sea_orm::entity::prelude::*;
#[derive(Clone, Debug, PartialEq, DeriveEntityModel, Eq)]
#[sea_orm(table_name = "federated_identity_provider")]
#[cfg_attr(test, derive(Default))]
#[cfg_attr(test, derive(Builder))]
#[cfg_attr(test, builder(setter(strip_option, into)))]
pub struct Model {
#[sea_orm(primary_key, auto_increment = false)]
pub id: String,
pub name: String,
pub domain_id: Option<String>,
#[cfg_attr(test, builder(default))]
pub enabled: bool,
pub oidc_discovery_url: Option<String>,
pub oidc_client_id: Option<String>,
pub oidc_client_secret: Option<String>,
pub oidc_response_mode: Option<String>,
pub oidc_response_types: Option<String>,
pub jwks_url: Option<String>,
#[sea_orm(column_type = "Text", nullable)]
pub jwt_validation_pubkeys: Option<String>,
pub bound_issuer: Option<String>,
pub default_mapping_name: Option<String>,
pub provider_config: Option<Json>,
}
#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]
pub enum Relation {
#[sea_orm(
belongs_to = "super::project::Entity",
from = "(Column::DomainId, Column::DomainId, Column::DomainId, Column::DomainId)",
to = "(super::project::Column::Id, super::project::Column::Id, super::project::Column::Id, super::project::Column::Id)",
on_update = "NoAction",
on_delete = "Cascade"
)]
Project,
}
impl Related<super::project::Entity> for Entity {
fn to() -> RelationDef {
Relation::Project.def()
}
}
impl ActiveModelBehavior for ActiveModel {}
}
- federated_mapping
#![allow(unused)]
fn main() {
pub id: String,
pub name: String,
pub idp_id: String,
pub domain_id: Option<String>,
pub r#type: MappingType,
#[cfg_attr(test, builder(default))]
pub enabled: bool,
pub allowed_redirect_uris: Option<String>,
pub user_id_claim: String,
pub user_name_claim: String,
pub domain_id_claim: Option<String>,
pub groups_claim: Option<String>,
pub bound_audiences: Option<String>,
pub bound_subject: Option<String>,
pub bound_claims: Option<Json>,
pub oidc_scopes: Option<String>,
pub token_project_id: Option<String>,
pub token_restriction_id: Option<String>,
}
- federated_auth_state
#![allow(unused)]
fn main() {
pub idp_id: String,
pub mapping_id: String,
#[sea_orm(primary_key, auto_increment = false)]
pub state: String,
pub nonce: String,
pub redirect_uri: String,
pub pkce_verifier: String,
pub expires_at: DateTime,
pub requested_scope: Option<Json>,
}
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, the preferred attribute mapping 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.
User domain mapping
Long years of working with multiple CSPs showed that there is no single way how users are stored in external IdPs. Sometimes it is desired to have a single “realm” with all users of the cloud differentiated by certain attributes or group memberships. Or every OpenStack domain is mapped as a dedicated “realm” in which case users are more isolated from each other. Or every customer is having a physically different IdP.
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.
A Keystone attribute mapping can be dedicated for a certain domain by setting the domain-id attribute. In such case all users authenticating using such attribute mapping would be placed in the specified domain. This makes it possible for the users to obtain memberhip in other domain and should therefore be used with extra care and only when absolutely necessary.
The ultimate flexibility of having a single IdP for multiple domains is by
specifying the claim attribute that specifies domain the user should belong to.
This is implemented by using the domain-id-claim attribute of the mapping.
Authentication with the claim missing is going to be rejected.
User group membership
When a user authenticates using the OIDC the group memberships are persisted in
the database with the expiration in addition to the list of group ids being also
saved in the token. This mechanism intends preventing continuous use of the
roles granted through the group memberships (i.e. with application credentials).
conf.federation.default_authorization_ttl configuration variable defines the
expiration for such group memberships. Every time the user authenticates with
the OIDC the group memberships are renewed (their last_verified property is
reset to the login timestamp). Groups the user is not member of anymore would be
unassigned.
The major consequence of this when using the application credentials that rely on the roles assigned through the group memberships is that the user need to periodically login using the OIDC (no other authentication method is renewing the group memberships since Keystone has currently no mechanism of querying the IdP for the current groups.
This is going to be changed soon since Keystone now has the means of communication with the IdP. Additionally the SCIM support is going to be added allowing the IdP to push updates to the Keystone.
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 attribute mapping 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_NAME}"
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 attribute mapping 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 attribute mapping. Since in such case there is no real human the mapping also need to be configured slightly different.
-
It is strongly advised the attribute mapping must fill
token_user_id,token_project_id(and soontoken_role_ids). This allows strong control of which technical account (soon a concept of service accounts will be introduced in Keystone) is being used and which project such request can access. -
Attribute mapping should use
bound_audiences,bound_claims,bound_subject, etc to control the tokens issued by which workflows are allowed to access OpenStack resources.
GitHub workflow federation
In order for the GitHub workflow to be able to access OpenStack resources it is
necessary to register GitHub as a federated IdP and establish a corresponding
attribute mapping of the jwt type.
IdP:
"identity_provider": {
"name": "github",
"bound_issuer": "https://token.actions.githubusercontent.com",
"jwks_url": "https://token.actions.githubusercontent.com/.well-known/jwks"
}
Mapping:
"mapping": {
"type": "jwt",
"name": "gtema_keystone_main",
"idp_id": <IDP_ID>,
"domain_id": <DOMAIN_ID>,
"bound_audiences": ["https://github.com"],
"bound_subject": "repo:gtema/keystone:pull_request",
"bound_claims": {
"base_ref": "main"
},
"user_id_claim": "actor_id",
"user_name_claim": "actor",
"token_user_id": <UID>
}
TODO: add more claims according to docs
A way for the workflow to obtain the JWT is described here.
...
permissions:
token: write
contents: read
job:
...
- name: Get GitHub JWT token
id: get_token
run: |
TOKEN_JSON=$(curl -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" \
"$ACTIONS_ID_TOKEN_REQUEST_URL&audience=https://github.com")
TOKEN=$(echo $TOKEN_JSON | jq -r .value)
echo "token=$TOKEN" >> $GITHUB_OUTPUT
...
# TODO: build a proper command for capturing the actual token and/or write a dedicated action for that.
- name: Exchange GitHub JWT for Keystone token
run: |
KEYSTONE_TOKEN=$(curl -H "Authorization: bearer ${{ steps.get_token.outputs.token }}" -H "openstack-mapping: gtmema_keystone_main" https://keystone_url/v4/federation/identity_providers/IDP/jwt)
Federating Keystone with Keycloak
Kestone enables federating users from the Keycloak to OpenStack. This integration is considered a primary citizen and is enforced using the integration tests.
Connection methods
It is possible to user Keycloak as the shared/global Identity Provider or to bind it for the single Keystone domain to be used as the private IdP.
Using Keycloak as a global IdP
When connecting the Keycloak as the Idp that can be used by all Keystone domains
an IdP is registered without specifying the domain_id. It is important to
remember also that in this case a single Keycloak realm can be registered at a
time and Keystone is not itself supporting multirealms configuration. It is,
however, possible to register every single Keycloak realm as a technically
independent IdP, what they in reality are.
Further it is necessary to establish rules into which Keystone domains Keycloak users are going to be placed. It can be accomplished with two different ways:
- domain bound mappings.
- using the
domain_idin the OIDC claim.
When using the domain bound mapping such mapping specifies the domain_id
property and every user is explicitly selecting the desired mapping. Logically
this introduce possibility for users to easily roam between different domains
without control. They only get the permissions explicitly granted them on the
concrete scope, so they will not get elevated privileges. But users are still
going to be created in the different domains. This may be acceptable for the
private cloud use case or when anyway only a single domain is existing. It
should not, however, be used for the public cloud use case.
A far more flexible alternative is to rely on the domain_id claim populated
into the user ID token issued by the IdP. This way the IdP controls the user
domain relations and can apply whichever logic is internally desired. The only
requirement for this method is that the domain_id claim must be present in the
token. It can be achieved, for example, by creating a client scope that
re-exposes the domain_id user attribute as the token claim. On the Keycloak
side users can be structured into groups where each group stands for the
Keystone domain and the domain_id attribute is being set on the group level.
Every user automatically inherits all group attributes in Keycloak.
Keycloak as a private IdP
In a very similar way to connecting Keycloak as a shared IdP making it bound to
the concrete Keystone domain can be chosen (i.e. in a public cloud a certain
customer has already Keycloak instance on premises and is willing to use it to
consume cloud resources). The only difference to the previous scenario is that
both IdP and mapping in Keystone explicitly specify the domain_id property of
the domain they should be bound to.
Configuration
A first step to connect Keycloak as an IdP in Keystone is in the preparation of the OIDC client. Since the Kecloak volatile and changes the UI concepts quite often no screenshots are going to be present in this guide. Instead just a description is given. Functional tests in the project are performing all this steps using the API and can be used as a reference for uncertainty.
- A OIDC type client should be created.
-
redirect_urisspecifies list of clients (i.e. user cli/tui, dashboards, etc) that would require to provide a callback listener to interact with the IDP as a relying party capturing the authorization code. To allow users to use rust cli (osc) an urlhttp://localhost:8050/*) must be added. -
client authorization should be enabled for the client for the better security. The
client_secretis only going to be known by the Keystone itself and is not required to be known by the end users of the cloud. -
When using Keycloak in the shared mode it is most likely necessary to add
domain_idclaim into the token. For this a protocol mapper should be added (or the existing one extended) adding a claim into the access token, id token and userinfo token. The claim name is not relevant and is going to be used on the Keystone side. It is described in the previous chapter how the corresponding attribute can be assigned to the user (directly or through the group membership).
- Registering the IdP on Keystone.
An osc is going to be used to register the IdP.
osc identity4 federation identity-provider create --bound-issuer <KEYCLOAK_ISSUER> --oidc-client-id <CLIENT_ID> --oidc-client-secret <CLIENT_SECRET> --oidc-discovery-url <KEYCLOAK_DISCOVERY_URL> --default-mapping-name keycloak --domain-id <DOMAIN_ID> --name keycloak
The default-mapping-name parameter allows the specified mapping to be applied
automatically during the login unless user explicitly specifies the mapping.
Mapping names are unique within the identity provider they are created under.
Then mapping does not exist yet and is going to be created in the next step.
This is an optional parameter and it can be set or unset later.
- Registering the mapping.
Now it is necessary to create the attribute mapping that converts OIDC protocol
claims into the corresponding user attributes and perform additional
verification (i.e. requiring certain bound_claims to be present).
osc identity4 federation mapping create --user-id-claim sub --idp-id <IDP_ID> --user-name-claim preferred_username --name keycloak --oidc-scopes openid,profile --domain-id-claim <DOMAIN_ID_CLAIM_NAME>
-
idp-idis the identity provider is created in the previous step. -
user-id-claimrepresents the claim name which should be used for the remote idp user identifier. This is not the resultinguser_idin Keystone, but aunique_idproperty. -
user-name-claimrepresents the claim name with the user name. -
domain-id-claimis the name of the claim populated with the client mapper on the Keycloak side and represents the domain on the Keystone the user would be placed under. Note: domain must exist on Keystone. -
Many more additional attributes can be passed to further tighten the mapping process.
- 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 oktaDefault mapping name is created in the next step and is not explicitly required. It is used when no mapping was explicitly specified in the authentication request. The provider name can be also obfuscated more. The authentication depends on the identity provider ID and not the name.
-
create authentication mapping
osc identity4 federation mapping create --user-id-claim sub --idp-id <IDP_ID> --user-name-claim preferred_username --name okta --oidc-scopes openid,profile
Afterwards osc can be used by users to authenticate.
clouds.yaml
clouds:
devstack-oidc-okta:
auth_type: v4federation
auth:
auth_url: <KEYSTONE_URL>
identity_provider: <IDP_ID>
$ osc --os-cloud devstack-oidc-okta auth show
A default browser is going to be opened at `https://<CENSORED>.okta.com/oauth2/default/v1/authorize?response_type=code&client_id=<CENSORED>&state=<CENSORED>&code_challenge=<CENSORED>&code_challenge_method=S256&redirect_uri=http%3A%2F%2Flocalhost%3A8050%2Foidc%2Fcallback&scope=openid+profile+openid&nonce=<CENSORED>`. Do you want to continue? [y/n]
Using Dex as the Identity provider
Dex is an identity service that uses OpenID Connect to drive authentication for other apps. Dex acts as a portal to other identity providers through “connectors.” This lets Dex defer authentication to LDAP servers, SAML providers, or established identity providers like GitHub, Google, and Active Directory. At the same time Dex is not an Identity Provider in the classical sense since it does not itself stores user identity data. Instead it serves more like an OpenIDConnect proxy.
Since Dex is not responsible for the ideneity data it also not the right place for the advanced claims that would be necessary to address all possible scenarios of the Keystone integration. It should be considered therefore as mostly suitable for the private IdP mode only or being the only existing IdP.
Configuration
Dex is designed to be deployed in front of a real IdP (Keycloak, GitHub, Google, etc). For the sake of example a static user base and a static client is going to be used.
- Prepare the Dex configuration
issuer: http://localhost:5556/dex
storage:
type: memory
web:
http: 0.0.0.0:5556
staticClients:
- id: keystone_test
redirectURIs:
- "http://localhost:8050/oidc/callback"
name: 'Keystone'
secret: keystone_test_secret
enablePasswordDB: true
staticPasswords:
- email: "admin@example.com"
# bcrypt hash of the string "password": $(echo password | htpasswd -BinC 10 admin | cut -d: -f2)
hash: "$2a$10$2b2cU8CPhOTaGrs1HRQuAueS7JTT5ZHsHSzYiFPm1leZck7Mc8T4W"
username: "admin"
userID: "08a8684b-db88-4b73-90a9-3cd1661f5466"
With this configuration Dex server can be started
dex server
- Registering the IdP on Keystone.
An osc is going to be used to register the IdP.
osc identity4 federation identity-provider create --bound-issuer <DEX_ISSUER> --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 allows the specified mapping to be applied
automatically during the login unless user explicitly specifies the mapping.
Mapping names are unique within the identity provider they are created under.
Then mapping does not exist yet and is going to be created in the next step.
This is an optional parameter and it can be set or unset later.
- Registering the mapping.
Now it is necessary to create the attribute mapping that converts OIDC protocol
claims into the corresponding user attributes and perform additional
verification (i.e. requiring certain bound_claims to be present).
osc identity4 federation mapping create --user-id-claim sub --idp-id <IDP_ID> --user-name-claim username --name dex --oidc-scopes openid,profile --domain-id <DOMAIN_ID>
-
idp-idis the identity provider is created in the previous step. -
user-id-claimrepresents the claim name which should be used for the remote idp user identifier. This is not the resultinguser_idin Keystone, but aunique_idproperty. -
user-name-claimrepresents the claim name with the user name. -
Many more additional attributes can be passed to further tighten the mapping process.
- 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
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() {
pub id: i32,
pub user_id: String,
pub credential_id: String,
pub description: Option<String>,
pub passkey: String,
pub r#type: String,
pub aaguid: Option<String>,
pub created_at: DateTime,
pub last_used_at: Option<DateTime>,
}
- webauthn_state
#![allow(unused)]
fn main() {
pub user_id: String,
pub state: String,
pub r#type: String,
pub created_at: DateTime,
}
API
Performance comparison
TODO