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 Python Keystone identity service, maintained upstream by the OpenStack community, 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.

Executive summary: the authentication and authorization vision

Status: Draft for community discussion

Date: 2026-10-15

This page is for readers who decide rather than implement: Technical Committee members, project leads and operators. It is one page by design. The vision holds the reasoning, the roadmap holds the order of work, and IAM data plane and check API holds the design of the part that carries the most risk.

The problem

Keystone predates the identity standards the rest of the industry is built on. Its token model, API and scope-and-roles payload were designed in 2010–2012, before OAuth 2.0 (RFC 6749, October 2012), OpenID Connect (2014), JWT (RFC 7519, 2015), SPIFFE (2017) and OAuth token exchange (RFC 8693, 2020). Those standards were attached at the edges later and never adopted as the core. Federation goes through Apache modules that map external assertions onto local users and groups. OAuth 1.0a and OAuth 2.0 client credentials are extensions with a limited coverage. JWS is just another token format carrying the same payload. X-Auth-Token and /v3/auth/tokens are still OpenStack-only, so no gateway, identity provider or service mesh can validate an OpenStack token or read its roles without calling Keystone. Each problem below has to be fixed inside Keystone instead of by adopting what other platforms already use.

An OpenStack token proves who the caller is, carries the project, domain or system the caller may act on, and carries the roles held there, all frozen at issuance. Every service validates it against Keystone and applies local policy to what is inside (§2). Following consequences hurt.

1. Every request is a Keystone round trip. A Keystone outage stops the cloud the moment the validation cache expires (§2.2).

2. Scope and roles in the token cannot express what operators need. Policy asks “does this token’s scope match the resource”, not “may this principal do this to that resource”. The two diverge in cross-project operations, cloud-wide read and services acting for users, so system scope, global reader and “admin is global” never worked cleanly and Secure RBAC stalled (§2.1, §2.3, §2.4).

3. Roles are the only unit of grant. To let one workload call one operation, an operator would create a role, wire it into every service’s policy file and assign it. Not all operators do this, and this is not triviel, so the workload gets member or admin. Application credential access rules can only narrow a credential that is already as privileged as its role, never grant (§2.5).

4. Long-lived secrets are everywhere (§2.6). Each is a bearer secret whose theft looks exactly like legitimate use.

  • Every service has a service user with a password in a config file and the admin or service role.
  • Heat, Magnum and Mistral store trusts. Magnum ships a trustee password into every cluster VM. Heat ships a per-instance EC2 keypair into the metadata of every VM that runs its CloudFormation client.
  • Tenant automation runs on application credentials or EC2 keys baked into images, Kubernetes secrets and CI variables, valid for months.
  • Users authenticate with passwords in clouds.yaml.

5. Trusts, application credentials and EC2 credentials are a recurring source of vulnerabilities. They are delegation mechanisms, and delegation is where Keystone has repeatedly decided on the wrong input: the scope of the token instead of the immutable record of how the caller authenticated (security model).

  1. Keystone doesn’t scale well. A massive swift/rgw cluster requires scaling Keystone to hundreds+ of instances only to validate request signatures.
  • OSSA-2026-005 / CVE-2026-33551. A restricted application credential escaped its role restriction by minting an EC2 credential. Redeeming it at /v3/ec2tokens rebuilt the delegation chain but presented a bare project scope, so the credential inherited the owner’s full project roles.
  • OSSA-2026-015. A delegated token was not bound to its delegation project on credential create, read, update and delete, so a delegate could reach credentials outside the project it was delegated.
  • Trusts have structural weaknesses besides bugs (ADR 0036). The trustee is a user with a password that is copied to wherever the service runs. expires_at is optional, so a trust need not lapse. Any user can name any other user as trustee, and nothing caps the roles a service can receive. With impersonation downstream logs see only the trustor. One trustee holds every trust of its service, so activity cannot be attributed per trust. OS-TRUST is a home-grown protocol that no gateway, identity provider or audit tool understands, and it is expressed as token scope, which blocks scope-less tokens.
  • The pattern is the point. Fixing each advisory closes one path. The vision removes the scope-keyed decision itself, and removes the delegated secret, so the next variant has less to key on.

OpenStack is the outlier on credential, scope location and workload identity against AWS, GCP, Azure and Kubernetes (§2.7).

The proposal

Modernize keystone and separate the three things the token conflates (§1).

QuestionAnswer under the proposal
Who is callingA short-lived, scope-less JWT for the public API. SPIFFE mTLS inside the control plane.
Where may it actThe request says so, in an OpenStack-Target header or derived from the addressed resource.
What may it doThe service’s policy asks Keystone on every request, against a replica of live state, with no cross-request cache.

Four further changes follow:

  • A secretless control plane. Services authenticate by SPIFFE identity. Service users, service passwords and X-Service-Token go away (§4.2).
  • Trusts are replaced by server-side service grants and RFC 8693 on-behalf-of exchange. No secret is delegated, and every delegation is recorded (§4.5).
  • Tenant workloads get identity without credentials. A VM or cluster node attests and receives an SVID bound to its project (§4.6).
  • Roles stay, permissions are added. A rule, an operation or a set of them can be bound to any human, service or workload on any target, without inventing a role (§6).

What it costs

The costs fall on different parts of the community.

  • Operators deploy and maintain SPIRE. The secretless control plane and tenant workload identity both need a SPIRE deployment: servers, an agent on every control-plane host and compute host, a trust domain, and the plumbing that registers each service. It is new infrastructure with its own upgrades, monitoring, backup and certificate-rotation practice, and it sits under every service-to-service call. Deployment tools and devstack have to ship it, and the roadmap makes this the first milestone (§4.2, A0).
  • Operators also take on a new availability model. Authorization becomes a live check on every request, and an optional replicated data plane per failure domain keeps that from being a single point of failure. A total outage of the write path still stops all new logins, refreshes and exchanges, so the access-token lifetime becomes an availability setting (§8.5).
  • Service maintainers change their policy and their API handling. A service asks instead of reading roles out of the token, and it enforces that the requested target matches the resource’s owner before it accepts per-request targets (§5.1). Each service may do this on its own schedule, or not at all.
  • Client and SDK authors take on new key management. Public clients need sender-constrained tokens, and official OpenStack clients have no established story for holding a DPoP key yet (§10).
  • The community has to agree on shared contracts. The header grammar and the operation vocabulary only work if projects adopt the same one, and who may write to the vocabulary is a decision about who holds a privilege (§6.1).

How it is adopted

Every step is additive (§9). Python Keystone, Fernet tokens and unmodified services keep working. Every capability has a per-service off switch. A deployment may stop at role resolution indefinitely and lose nothing it had.

The roadmap orders the work by dependency, not as one line of phases, so teams can start in parallel: SPIRE and the secretless control plane, the token format, the check API, and the policy engine.

Decisions needed

Most questions belong to the community, not to this document (§10): the OpenStack-Target header grammar, where the operation registry lives and who may write to it, DPoP versus mTLS-bound tokens for public clients, and the retirement timeline for application credentials and EC2 keys.

OpenStack Authentication and Authorization: A Vision

Status: Draft for community discussion

Date: 2026-09-18

This document proposes a target model for authentication and authorization across OpenStack. It is derived from the architecture decisions already taken in the Rust Keystone implementation (the ADRs under adr/) and from the SPIRE integration plan, and it extends them into one coherent picture that other OpenStack projects can react to. It is deliberately written for a wide audience: operators, service maintainers, SDK authors, and the Technical Committee. It is not an ADR and it does not commit any project to anything; its purpose is to give the community a concrete proposal to argue about.

The model is described independently of any implementation. The Rust Keystone is the reference implementation and the place where the building blocks already exist, but nothing in the proposal requires it; Python Keystone, keystonemiddleware, oslo.policy and every service can adopt the pieces that concern them.

Each part states its own requirements and limits where it is described, and the last section lists the questions the community, not this document, has to answer.

A one-page version for decision-makers is the executive summary.

This document says what the model is and why. The order in which it is adopted is in the roadmap. The design detail of the check API and the data plane is in IAM data plane and check API.

TL;DR

The problem: scope and roles are bound into the token at issuance, so one artifact proves who the caller is, where it may act and what it may do. The proposal separates the three.

  • Tokens say who, and nothing else. Short-lived, scope-less JWTs from an OAuth2/OIDC provider. Public clients use DPoP (RFC 9449) and SVID holders use JWT-SVID client authentication (RFC 7523). Authentication is validated offline. Authorization is not: it is decided per request. During the transition scoped tokens remain available. A token still carries authentication facts such as amr, act, delegation_context and optional restrictions, and can be narrowed to the audience of one service type.
  • The request says where. The target travels per request in the OpenStack-Target header, which generalizes the tokenless-auth headers of v3 Keystone, or is derived from the addressed resource. For an operation on an existing resource the resource’s owner wins over anything the caller names. For create and list the caller names the target, and Keystone checks the caller’s authority there.
  • Keystone decides what. The service’s policy asks Keystone (/v4/authz/check) on every request, against a replica of live state, with no cross-request cache. An outage of the write path degrades the cloud rather than stopping it, to a bound the document states (§8.5).
  • The control plane authenticates with SPIFFE mTLS. Services are identified by their SVIDs, not by passwords. Service users, service passwords and X-Service-Token go away. Each service is still a principal in Keystone, mapped from its SPIFFE ID, and holds the service permission set on the system target. mTLS identifies the service, never the user, so a call made for a user still carries the user’s JWT, forwarded or exchanged. A callee can also key a rule on the calling service’s identity without reading roles.
  • Trusts are replaced. Service grants are server-side, revocable records for authority that must work with no user present. RFC 8693 on-behalf-of exchange continues a user’s operation. No secret is delegated, and the act and delegation_context claims record the chain (ADR 0036).
  • Tenant workloads get identity without credentials. A VM or cluster node attests with a Keystone-signed proof of its placement and receives an SVID bound to its project. A mapping rule then grants it roles or permissions on that project. Application credentials and EC2 keys remain for off-cloud automation, as a legacy niche.
  • Global reader and admin isolation become expressible. Because the target is named per request, a system-level grant such as reader can apply to project-owned resources without a project-less system-scoped token. Keystone resolves that projection in one place. Each service must still enforce that the requested target matches the resource’s owner, and a service adopts the model only after it makes that declaration.
  • Separate interfaces. A public API; an internal interface that accepts SPIFFE mTLS (the plaintext back-channel stays only until legacy services have moved); and a local admin interface over a Unix domain socket that carries the full API, is reachable only from the host and authenticates by SVID.
  • A secretless control plane. No Keystone credentials in the control plane and no long-lived inter-service secrets. Database and message-queue credentials are outside this proposal.

The order of adoption is in the roadmap.

1. The vision in one page

OpenStack authentication and authorization today is built around one artifact: the scoped bearer token. A token proves who the caller is and carries the project, domain or system the caller may act on and the roles the caller holds there, frozen at issuance. Every service validates that token against Keystone (or a cache of Keystone) and applies a local policy file that reads the roles and the scope out of it.

The proposal separates the three things the token conflates:

ConcernTodayProposed
Who (authn)Fernet/JWS token, password, app-cred, trustShort-lived signed JWT for the public API (OIDC/OAuth2, passkeys, device flow, workload federation, JWT-SVID for tenant workloads); SPIFFE mTLS identity for everything inside the OpenStack control plane
Where (scope)Baked into the token at issuanceStated per request (a structured OpenStack-Target header, or derived from the addressed resource), generalizing the v3 tokenless auth headers to every call. The scope types do not change: project, domain and system scope all survive, they stop being properties of the credential
What (authz)Roles frozen into the token; oslo.policy per serviceDecided per request by a policy engine that receives the authentication facts, the target scope and the operation, and asks Keystone whether the caller holds the required role or permission on that target

The central move is the third row. Today a service asks “does the role list inside this token contain admin, and does the scope inside this token match the resource?”. Under this proposal it asks Keystone “does this principal hold the role or permission this operation requires, on this target?”. The token stops being the carrier of the answer and becomes only the proof of who is asking.

Five further changes follow from this split:

  • The OpenStack control plane authenticates with mTLS and nothing else. Every OpenStack service holds a SPIFFE identity issued by SPIRE. Service-to-service calls, the Keystone back-channel, and the Nova-to-Keystone attestation path all authenticate at the TLS layer. Service users, service passwords and X-Service-Token disappear.
  • Trusts are replaced by service grants and on-behalf-of exchange (ADR 0036). The acting service is always recorded, no secret is ever delegated, and the delegation boundary is a fact of the authentication chain rather than of the token’s scope.
  • Tenant workloads get platform identity without credentials. A VM or a Kubernetes node boots, attests to SPIRE with a Keystone-signed proof of its placement, receives a project-bound SVID, and calls the OpenStack APIs with it. No password, application credential or EC2 key is ever written into a guest.
  • Humans authenticate with phishing-resistant methods. WebAuthn passkeys and OIDC federation are first-class; the design intent is for a CLI to authenticate with WebAuthn directly, with the device authorization grant covering headless machines and CI (§4.7).
  • Keystone’s read path can be replicated, and an outage of its write path degrades the cloud rather than stopping it. Authorization decisions and credential validation are served from replicas of Keystone’s state — an optional data plane an operator deploys per failure domain (§4.4, §8) — while writes and authentication stay on the IAM control plane. Existing credentials keep being validated and authorized throughout an outage of it; what stops is the issuing of new ones, which the access-token lifetime turns into a stated bound (§8.5). A deployment that can live with the availability of the control-plane nodes queries them directly and deploys nothing more; the API is the same either way.

The payoff is a model in which a policy rule, an individual operation, or a set of operations can be bound to a human, a service or a workload identity on any target without inventing a new role for it, the way AWS STS session policies or Azure role assignments work. That is what finally makes “system scope”, “global reader”, “domain manager” and “service role” expressible without every service re-implementing the semantics.

2. Where OpenStack is today and what hurts

2.1 Tokens carry scope

A Keystone token is (user, scope, roles, expiry) sealed together. Consequences:

  • A client that touches three projects holds three tokens, or re-authenticates on every switch. SDKs, Terraform providers and dashboards all carry code to manage this.
  • Every service’s policy reads project_id and roles out of the token. The policy therefore answers “does this token’s scope match the resource” rather than “may this principal do this to that resource”. The two questions diverge exactly in the cases the community has struggled with for a decade: cross-project operations, cloud-wide read, and services acting on behalf of users.
  • Scope is attacker-influenceable through rescope. Two 2026 advisories (OSSA-2026-005, OSSA-2026-015; see the security model) are instances of a decision keyed on token scope instead of on the immutable authentication chain.

2.2 Every request is a Keystone round trip

keystonemiddleware validates the token on each request, with a cache in front. Keystone is on the data path of every OpenStack API call and its availability bounds the availability of the whole cloud. Python Keystone is therefore already the single point of failure of every OpenStack deployment: every authentication and every token validation goes through it, and the validation cache in front of it is what makes a short outage survivable. The Rust implementation already addresses the validation half of this with offline JWT verification (ADR 0026 §6), but as long as roles are frozen into the token, offline validation also freezes authorization for the token lifetime.

This proposal does not remove the dependency on a running authority. It makes IAM more resistant than it is today, in three specific ways. Authentication is verified offline from a signed credential, so no round trip is needed to establish who is calling. Authorization is decided per request by a component that can be replicated independently of the nodes that accept writes — an optional data plane an operator deploys where the availability of the IAM control plane is not enough (§4.4, §8). And the cost of an outage of it is stated exactly in §8.5.

The round trip itself moves from token validation to an authorization decision, and by default no cross-request cache stands in front of it. Python Keystone’s caching layer has produced the most bugs in its design; this proposal replaces it with a replica of the grant state instead, not a smarter cache (§6.3). A caller that touches many projects therefore holds one credential and asks one question per request, batched, against something in its own failure domain.

2.3 System scope and the Secure RBAC goal

The community goal “Consistent and Secure Default RBAC” introduced three personas (reader, member, admin), later manager and service, on three scopes (project, domain, system). The design placed the scope in the token and asked every service to decide what a system-scoped token means for each of its project-owned resources. Services could not answer that uniformly: a system-scoped token has no project, so creating a server “as the system” had no owner, listing servers “as the system” needed new query semantics, and existing operator tooling broke. The goal was re-scoped; system scope is today avoided by most services and admin on a project remains the practical cloud administrator.

The root cause is not the persona model, and it is not system scope itself. It is that the scope was a property of the credential, so the credential had to be re-issued for each target, and a system-scoped credential arrived at a service with no project in it at all. This proposal keeps system scope — it is the right way to name cloud-level resources and cloud-wide authority — and removes it from the credential. An operator holding a system-level grant names a project target on the calls that need one and the system target on the calls that do not, without re-authenticating between them.

2.4 Global reader and “admin is global”

Two of the oldest open problems in OpenStack authorization are the same problem seen from both ends:

  • Global reader: an auditor, a billing collector, or a monitoring system needs read access to every project. Today that requires either admin, a role on every project, or a system-scoped token that most services cannot interpret.
  • Admin is global: admin on any project historically meant admin everywhere, because policies checked role:admin and the token scope was not compared to the resource. Fixing this per service took years and is still incomplete.

Both need the same primitive: a decision that takes the principal, the operation and the resource’s target, and a single authority that knows how a grant on the system or on a domain projects onto a project. That authority is Keystone; today it is not consulted at decision time.

2.5 Roles are the only unit of grant

The only thing Keystone can grant is a role on a target. To let one workload call one operation (rotate a secret, signal a Heat wait condition, read one metric endpoint), an operator must create a role, wire the role into every service’s policy file, and assign it. Nobody does that; instead the workload gets member or admin. Application-credential access rules were meant to address this: a rule matches (service, method, path) and narrows what one application credential may call. But narrowing is not granting — a credential created with no access rules inherits its role’s full permission set, so an access rule can only cut down a credential that is already as privileged as its role allows, never add a permission the role lacks. The rule also lives on the credential, not on a principal: it cannot be assigned to a human or a workload identity, held independently of the one credential it was created with, or inherited by anything else. It is a self-imposed filter over one credential’s own tokens, not a unit of grant.

Every hyperscaler solved this with permissions as the unit of grant and roles as named permission sets: AWS IAM actions and policies, GCP IAM permissions and roles, Azure actions and role definitions. OpenStack already has a cloud-wide operation vocabulary (the oslo.policy rule names such as os_compute_api:servers:create); it has never been usable as a grant.

2.6 Secrets everywhere

  • Every service has a service user with a password in a config file and the admin (or service) role.
  • Heat, Magnum, Mistral and others store trusts; Magnum ships a trustee password into every cluster VM, and Heat ships a per-instance EC2 keypair into the metadata of every VM that runs its CloudFormation client.
  • Tenant automation gets application credentials or EC2 keys baked into images, Kubernetes secrets and CI variables, valid for months.
  • Users authenticate with passwords in clouds.yaml.

Each of these is a long-lived bearer secret whose theft is indistinguishable from legitimate use.

2.7 How the rest of the industry does it

PlatformCredentialWhere the scope livesUnit of grantWorkload identity
AWSSigV4-signed requests with STS short-lived keysIn the request (resource ARN); evaluated per callAction on resource (IAM policy)Instance profile, IRSA, roles anywhere
GCPOAuth2 access tokenIn the request (resource name); hierarchy inheritancePermission; roles are permission setsService account attached to the workload
AzureEntra ID JWT, audience-boundIn the role assignment, not in the token; evaluated per callAction; role definitionsManaged identity
KubernetesProjected service-account token, audience-boundIn the request (namespace/resource); SubjectAccessReviewVerb on resource (RBAC rule)Service account, SPIFFE via SPIRE
OpenStackFernet token (JWT in keystone-rs, still scoped)In the tokenRole on project/domain/systemApplication credential in the guest

OpenStack is the outlier on every column. The proposal below moves each column to the industry pattern while keeping the v3 API and Python services working during the transition.

3. Principles

  1. Authentication proves identity, nothing else. A credential says who the caller is and how strongly that was established (amr). It does not say what the caller may do.
  2. Authorization is decided per request, against the target. The target is the addressed resource’s owner, or the scope the caller names for create/list operations. It is never taken from the credential.
  3. Security decisions key on the authentication chain, never on scope. This is the overarching rule of the security model (of which invariant I1, “delegation facts come from the chain”, is the delegation-specific case), generalized: delegation boundaries, restrictions and the acting party are chain facts; the target is a per-call input.
  4. Keystone is the authority for grants; services are the authority for resources. A service knows which project a server belongs to; Keystone knows whether the caller holds compute:servers:delete there, including through domain or system inheritance. Neither should have to know the other’s rules.
  5. Credential-less wherever possible. Identity is bound to the runtime (SPIFFE SVID, attested VM, passkey), not to a secret in a file.
  6. Short-lived, sender-constrained, audience-bound. Whatever bearer artifacts remain live for minutes, are bound to the presenting workload where the transport allows it, and can be narrowed to the services that may accept them.
  7. Every acting party is recorded. Delegated calls carry act; audit never loses who did what on whose behalf.
  8. Compatibility is a design input, not an afterthought. v3 tokens, Python services and existing SDKs keep working through every phase.
  9. Grants are roles and permissions; a relation store is an implementation choice. The model works on the SQL assignment driver alone. Roles, inheritance and permission sets answer every problem in §7; an OpenFGA-backed driver (ADR 0033) is valuable for deployments that already run a relation store and for resource-level grants (§6.5), and it is a per-domain backend decision (ADR 0034), never a prerequisite for adopting anything here. The choice also decides where the data plane’s replica comes from: on the SQL and Raft drivers the data plane holds its own copy; on an externally owned store it is a client of the store’s replication (§6.3, §8.1).
  10. Copies of authorization state are replicas, never caches. A replica’s freshness is maintained by the replication protocol, which observes every write, and is reported as a version with every answer. A cache’s freshness depends on the writer remembering to invalidate it, and on a lifetime when it does not. Python Keystone’s caching bugs were of the second kind, and this proposal admits only the first on the request path: a Raft learner, a database standby, or a store’s own replica. The only memo the architecture itself contains is scoped to one request and dies with it; a client-side cache is an opt-in a deployment takes on with its constraints, never part of the path specified here (§6.3).

4. Target architecture

flowchart LR
    subgraph callers["Callers"]
        human["Human<br/>passkey, OIDC, device flow"]
        ext["External automation<br/>GitHub, GitLab, Zuul, k8s operator"]
        vm["Tenant workload<br/>VM or cluster node with SVID"]
    end

    subgraph ks["IAM control plane: writes, authentication"]
        op["OAuth2 / OIDC provider<br/>issues scope-less JWT"]
        map["Mapping engine<br/>SVID / OIDC / JWT to principal"]
    end

    subgraph dt["IAM data plane (optional): replicas per failure domain"]
        authz["Authorization API /v4/authz/check<br/>grants, roles, permissions, inheritance<br/>JWKS, credential validation"]
    end

    subgraph cp["OpenStack control plane: SPIFFE mTLS"]
        svc["OpenStack service<br/>nova-api, neutron, ..."]
        pdp["Policy engine<br/>OPA, or oslo.policy bridge"]
        peer["Other service<br/>placement, glance, ..."]
    end

    spire["SPIRE"]

    human -->|"login"| op
    ext -->|"workload federation JWT"| map
    vm -->|"JWT-SVID"| map
    map --> op
    callers ==>|"Bearer JWT + OpenStack-Target"| svc
    svc -->|"authn facts, target, operation"| pdp
    pdp -->|"check(sub, op, target)<br/>per request, batched, versioned"| authz
    ks -.->|"replication: Raft learner,<br/>database standby or store replica"| authz
    svc -->|"mTLS peer identity,<br/>user JWT forwarded or exchanged"| peer
    spire -.->|"SVIDs"| cp
    spire -.->|"SVIDs"| vm

4.1 Identity tracks

CallerAuthenticationWhat the service sees
Human at a browserOIDC authorization code + PKCE at Keystone; passkey or federated IdP behind itBearer JWT, sub = user, amr = ["webauthn"] etc.
Human at a CLIDevice authorization grant (browser login with passkey/MFA on a headless machine)Same JWT
External CI / operatorWorkload federation: platform JWT exchanged through the mapping engine (ADR 0008/0020)Bearer JWT, sub = mapped principal
Tenant VM / cluster nodeSPIRE-issued project/instance SVID, presented as JWT-SVID private_key_jwtBearer JWT, sub = ephemeral principal bound to spiffe.project_id
Control-plane service acting as itselfX.509 SVID at the TLS layer of the internal interfaceNo token at all; peer identity from the TLS session, mapped to a principal
Control-plane service acting for a userThe user’s JWT forwarded, or an on-behalf-of token with act (ADR 0036 §6)User JWT plus service SVID at the transport
Deferred automation (Heat, Magnum)Service grant redeemed with the service SVID (ADR 0036 §5)JWT with sub = grantor, act = service

4.2 The OpenStack control plane: mTLS, no dedicated authentication

Audience: deployers and service maintainers. Skip if you only need the operator-level picture; the TL;DR and §1 cover it.

Every OpenStack control-plane process holds a SPIFFE identity from a local SPIRE agent (SPIRE plan Phase 1). Keystone’s internal interface, and the internal endpoints of every other service, terminate SPIFFE mTLS and identify the peer from the SVID URI. A request from spiffe://td/service/nova-api to Neutron is authenticated by the handshake; Neutron’s policy input carries the peer as service_credentials and the forwarded user context (if any) as credentials.

This removes:

  • service passwords in configuration files, and the admin role assignments services hold today;
  • X-Service-Token and service_token_roles_required, which exist only because the transport carried no service identity;
  • the plaintext back-channel from keystonemiddleware to Keystone (SPIRE plan Phase 4.1, the middleware SPIFFE mTLS transport change).

It does not remove the service as a subject of authorization. Every service still corresponds to a principal in Keystone — mapped from its SPIFFE ID through the mapping engine rather than carrying a password — and still holds a grant, namely the service permission set on the system target (§7.4). “No dedicated authentication in the control plane” means the transport authenticates the service; it does not mean the service is unauthorized, and it does not mean the service inherits the user’s authority. mTLS identifies the service, never the user it serves: a user-initiated call still carries the user’s JWT, and a policy that needs the user must read credentials, not service_credentials. Read the other way, this would reintroduce the service-user-with-admin pattern under a new name.

Per-host identities (.../nova-compute/host/{hostname}) let Keystone bind an attestation request to the compute host that actually runs the instance (ADR 0032), which a flat service identity cannot do.

flowchart LR
    subgraph host_a["Controller"]
        na["nova-api<br/>spiffe://td/service/nova-api"]
        ne["neutron-server<br/>spiffe://td/service/neutron"]
        ks["keystone internal<br/>spiffe://td/service/keystone"]
    end
    subgraph host_b["Compute host c-3"]
        nc["nova-compute<br/>spiffe://td/service/nova-compute/host/c-3"]
        ag["spire-agent"]
    end
    ss["spire-server"]
    na <-->|"mTLS: user JWT forwarded,<br/>peer = nova-api"| ne
    na <-->|"mTLS"| ks
    nc -->|"mTLS: POST /v4/vendordata"| ks
    ag -->|"node attestation"| ss
    ss -.->|"SVIDs"| na
    ss -.->|"SVIDs"| ne
    ss -.->|"SVIDs"| ks
    ss -.->|"SVIDs"| ag

4.3 Public API: a JWT that says who, and a request that says where

Keystone is an OAuth2/OIDC provider (ADR 0026). The access token it issues for OpenStack APIs carries:

  • iss, sub, aud, exp of about fifteen minutes, jti, amr. ADR 0036 §7.1 defaults aud to the control-plane-wide openstack-apis (the domain is carried by iss, not aud), narrowed to specific service types (openstack-apis:compute, …) only when the client requests it. When no audience is requested every service may accept the token; narrowing is a way for a client to restrict a token to the services it names, never a precondition for getting one. This vision keeps that rule unchanged. It means a default token is only as confined as its sender constraint (§5.4), and that the §4.8 forwarding rule confines a token only when the client narrowed it;
  • act when a service acts on behalf of the subject;
  • delegation_context when the chain is delegated (service grant, application credential, on-behalf-of);
  • restrictions when the holder asked for a narrower token than its full authority (a self-imposed ceiling, see §5.4);
  • no project, domain or system scope, and no roles.

The caller names the target on each request. The proposal generalizes the idea behind the v3 X.509 tokenless authorization headers, which already let a client say “I am this certificate, acting on this project”, into one structured header sent on every call:

GET /v2.1/servers HTTP/1.1
Authorization: Bearer <JWT>
OpenStack-Target: project="8e3f..."

Section 5 specifies the contract. Section 5.3 shows how keystonemiddleware can turn it into the X-Project-Id / X-Roles context today’s services expect, and why that compatibility mode is a per-service opt-in rather than a free ride: a client-supplied target is not a Keystone-attested one, and a service that treats it as attested is exactly the bug class §5.4 and the security model exist to prevent.

4.4 Authorization: the policy engine asks Keystone

Each service evaluates policy per request with an input of three parts:

{
  "credentials": {
    "sub": "...",
    "amr": ["webauthn"],
    "act": null,
    "delegation_context": null,
    "restrictions": null
  },
  "service_credentials": { "spiffe_id": "spiffe://td/service/nova-api" },
  "target": {
    "scope": { "project_id": "8e3f..." },
    "server": { "id": "...", "project_id": "8e3f..." }
  },
  "operation": "compute:servers:delete"
}

The policy decides using local rules (ownership, resource state, amr requirements, delegation tripwires) and one remote fact it cannot know itself: whether sub holds the operation, or a role that implies it, on target.scope. It asks Keystone:

POST /v4/authz/check
{
  "subject": { "credential": "<the JWT exactly as presented>" },
  "target": { "project_id": "8e3f..." },
  "operations": ["compute:servers:delete"]
}
{
  "results": {
    "compute:servers:delete": {
      "allowed": true,
      "via": ["role:admin@domain:inherited"]
    }
  },
  "roles": ["admin"],
  "version": { "assignment": "raft:184223", "identity": "pg:0/3A2F18C0" },
  "lag_ms": 12
}

Keystone resolves direct, group, inherited and system-level grants, intersects with the delegation boundary and with any self-imposed restriction, and returns a decision plus the classic role list for policies that still key on roles. The boundary and the restriction are derived from the credential the request carries, not from fields the calling service filled in — they are chain facts; §6.3 sets out why a data plane that takes the caller’s word for them would reproduce the §2.1 defect class.

Every answer carries the version of the state it was decided against and how far that state was behind the write path when the answer was produced. There is no lifetime in the response and no cache in front of it: an enforcement point uses the answer for the request that asked and discards it. §6.3 sets out the freshness contract — the version, the min_version a caller may demand, and the max_staleness an operator sets — and why it is the same on every driver that lets Keystone hold a replica, and different on one that does not.

There is no cross-request decision cache, and that is the architecture rather than a posture. The live call happens on every request, batched per request, against a replica in the caller’s own failure domain (or against the Keystone nodes themselves, where no data plane is deployed). What that costs is one round trip per inbound request — the round trip today’s deployments already pay to memcached for a validation-cache hit, against a component whose answer is microseconds of in-memory work — and a hard dependency on that replica set being reachable, which §8 turns into a replica-count requirement rather than a grace window.

This is the Kubernetes SubjectAccessReview pattern, and the shape oslo.policy already supports through its http check type, which gives Python services a zero-code migration path (see the roadmap).

sequenceDiagram
    participant C as Client
    participant M as keystonemiddleware
    participant S as nova-api
    participant P as Policy engine
    participant K as Keystone authz API

    C->>M: DELETE /servers/{id}<br/>Authorization: Bearer JWT<br/>OpenStack-Target: project=P
    M->>M: verify JWT offline (JWKS cache)<br/>parse OpenStack-Target
    M->>S: request + authn facts + requested scope
    S->>S: load server, owner = P'
    S->>P: input {credentials, target{scope:P, server{project:P'}}, operation}
    P->>P: requested scope must equal resource owner (P == P') else deny
    P->>K: POST /v4/authz/check (credential, P, compute:servers:delete)<br/>(batched per request, no cache)
    K-->>P: allowed via role:admin@domain:inherited, version
    P-->>S: allow
    S-->>C: 204

Where a decision is evaluated and where the grant facts come from are two independent choices, and this proposal fixes only the second. The policy engine may run as a sidecar beside each service or as an in-process oslo.policy library; a per-service OPA sidecar calling the check API over SVID mTLS is a shape an earlier proof of concept exercised (the commented http.send stanza in policy/identity/user/list.rego). What the proposal does rule out is on the other axis: replicating the grant graph itself to every evaluation point (§6.3).

The check API is served by the IAM data plane, and the data plane is optional

Audience: implementers and operators sizing availability. Skip if you only want the outage consequences; see §8.5.

/v4/authz/check carries at least one decision per API call across the whole cloud, while grant writes and token issuance carry a load orders of magnitude smaller. Those two have no reason to share a process, a release cadence or a scaling unit, and above a certain size they must not share a failure domain either. The check API is therefore served by a component in its own right: the IAM data plane, a second binary built from the same crates as keystone (keystone-dataplane in the Rust implementation), every driver of which is pointed at a replica and whose write handlers are not built in. §8 specifies it, down to the storage work it needs first (§8.3).

It is optional. The same /v4/authz/check, JWKS and validation endpoints are served by the IAM control plane itself — the Raft voters, over the primary database — so a smaller cloud, or one whose operators accept that the availability of the IAM control plane bounds the availability of authorization, points its services there and deploys nothing further. That is the dependency such a cloud already has on Python Keystone today, with offline credential validation on top. The data plane is how an operator buys authorization availability exceeding the write path’s, per failure domain, when that is worth a replica set and a standby database. Nothing in the API differs between the two; only version and lag in the response tell a caller which it is talking to.

What the data plane is not matters as much: it never authenticates. Token issuance, refresh, login, service-grant redemption, on-behalf-of exchange and attestation are all writes, or need a signing key a replica must not hold (§8.2), and they stay on the IAM control plane. The data plane decides authorization from state it already holds and validates presented JWTs against that same state; §8.5 prices the outage case.

This is the structural lesson from Zanzibar. Google did not add a check API to its identity service; it built a separately scaled system, because deciding every request is a different engineering problem from authenticating callers. The split is also the containment boundary for everything §6.3 makes driver-dependent — batch expansion, the per-item short-circuit, the version each driver reports, the limits an externally owned store imposes — so oslo.policy and the services see one uniform contract whatever the assignment driver underneath is. Without that boundary the per-driver differences leak into every enforcement point in the cloud.

One data plane does not mean one cluster. The serving layer is federated — a replica set per region or availability zone, each independently scalable and independently failable — over a single authoritative write path, so there is still exactly one place a grant is defined and revoked. Learners do not vote, so a WAN between the IAM control plane and a regional replica set never touches commit latency, and fan-out from the leader is bounded by the replica count, tens rather than thousands — which is why replicas are per failure domain and not per host. It is not uniform across drivers: a learner or a database standby follows the SQL and Raft drivers anywhere, while a region serving a domain whose assignments live in a relation store needs a replica of that store in the region too, or pays a WAN round trip on every check (§6.3).

A partition then has a stated bound: a cut-off region keeps serving decisions from local state and stops receiving revocations, and it knows by how much — past the operator’s max_staleness it refuses to allow rather than serve an answer nobody can vouch for (§6.3, §8.1).

Three properties are requirements on the data plane rather than inherited assumptions:

  • It is a second trust boundary over assignment data. The rule that security decisions key on the authentication chain and never on scope, the delegation invariants (I2/I4), and the requirement that secrets be stripped from policy input all hold inside the data plane, and are tested there with the same suites the IAM control plane runs.
  • It serves from state it holds locally for as long as the write path is unreachable, including cold-booting a replacement replica while the write path is down — the statically stable posture §8.1 states as a requirement, §8.3 lists the storage work for, and §8.6 names a test for.
  • Keystone’s own authorization stays in-process (§6.3). The data plane is how other services ask; it is not in the path of Keystone authorizing a call to itself, and it must not become a startup dependency of the IAM control plane.

4.5 Delegation without secrets

ADR 0036 replaces trusts with two mechanisms on the OAuth2 token endpoint:

  • Service grants for deferred authority (Heat stacks, Magnum clusters, Mistral workflows): a user-visible, revocable record naming a registered service client as grantee; redeemed with the service’s SVID; token sub is the grantor and act is the service.
  • On-behalf-of exchange (RFC 8693) for continuing a user-initiated operation past token expiry (Glance import, long migrations): the service exchanges the user’s token for one that carries act, bound to the root token’s jti so revoking the user’s token ends the chain.

No impersonation flag, no redelegation, no use counter, no trustee user, no password in a cluster. Both mechanisms already place every delegation fact in delegation_context, so they survive scope-less tokens unchanged (ADR 0036 §7).

A service and the guests it provisions are different identity tracks. The conductor of a Magnum cluster holds a grant here; the cluster’s nodes are §4.6 workloads with their own SVIDs. The two halves migrate independently of each other, and each has its own precondition: the trustee password can leave a guest only once the §4.6 SVID path works — the SVID is its replacement — but it does not require the grant to exist, and replacing the conductor’s trust does not require the guest to be SPIRE-attested.

sequenceDiagram
    participant U as User
    participant H as heat-api
    participant K as Keystone
    participant E as heat-engine
    participant N as nova-api

    U->>H: create stack (Bearer JWT)
    H->>K: POST /v4/service-grants (grantee = heat client, project P, roles)
    K-->>H: grant_id
    Note over E: hours later, autoscaling fires
    E->>K: POST /v4/oauth2/{d}/token grant_type=service-grant<br/>mTLS with heat-engine SVID
    K->>K: roles = grant ∩ grantor live roles on P
    K-->>E: JWT sub=user, act=heat-engine, delegation_context{grant_id, P}
    E->>N: POST /servers  Bearer JWT  OpenStack-Target: project=P
    N->>K: authz check (sub, P, compute:servers:create) with delegation bound
    K-->>N: allowed

4.6 Tenant workload identity

The SPIRE plan gives every VM a platform identity without a secret in the guest:

  1. Nova asks Keystone (over mTLS, with its per-host SVID) for a short-lived attestation JWT for (project, instance); Keystone verifies placement with Nova before signing (ADR 0032).
  2. The JWT is delivered through vendor data; the guest’s SPIRE agent presents it to the SPIRE server, which mints spiffe://td/project/{p}/instance/{i}.
  3. The workload calls Keystone’s token endpoint with the JWT-SVID (private_key_jwt) or the OpenStack APIs directly with a JWT-SVID mapped by a rule on spiffe.project_id, and receives whatever the mapping rule grants: a role, or, with §6, a specific set of operations on its own project.

The same path serves Magnum cluster nodes, Trove database guests and any tenant automation that runs on the cloud; Octavia amphorae are a candidate once their certificate bootstrap is reconsidered (out of scope in ADR 0036). Where a service provisions guests today with an impersonating trust — Magnum writes a per-cluster trustee password and trust id into every node, and the components inside redeem the trust as the cluster owner — this path changes the actor: the guest acts as itself, a project-bound workload principal, with whatever authority the mapping rule grants. The narrowing from the owner’s roles is deliberate; a component that needs more gets a wider mapping rule or permission set, never a secret in the guest. Resources the guest creates are owned by its workload principal, not by the cluster owner. Application credentials and EC2 keys remain for automation that runs outside the cloud and cannot attest, and they shrink to a legacy niche.

sequenceDiagram
    participant NC as nova-compute (host SVID)
    participant K as Keystone
    participant VM as Guest (cloud-init + spire-agent)
    participant SS as SPIRE server
    participant API as OpenStack API

    NC->>K: POST /v4/vendordata {project, instance} (mTLS)
    K->>K: verify placement with Nova, sign with attestation key
    K-->>NC: attestation JWT (5 min)
    NC->>VM: vendor_data.json
    VM->>SS: attest with JWT
    SS->>K: GET /v4/spiffe/{domain}/jwks
    SS-->>VM: SVID spiffe://td/project/P/instance/I
    VM->>API: Bearer JWT-SVID, OpenStack-Target: project=P
    API->>K: authz check (principal mapped from spiffe.project_id, P, op)

4.7 Human authentication

  • Passkeys (ADR 0005) are the default strong method. Keystone acts as the WebAuthn relying party; the OIDC provider records amr: ["webauthn"], so a policy can require phishing-resistant authentication for sensitive operations (key rotation, role assignment, grant creation).
  • External IdPs federate through the mapping engine (ADR 0020), including expiring group membership (ADR 0013) and SCIM provisioning (ADR 0024).
  • CLI login is meant to reach passkeys: a CLI authenticating with WebAuthn directly, with the device authorization grant covering headless machines and CI by delegating to a passkey-capable browser login, is the target design — no such CLI exists in this workspace yet. This is where phishing-resistant authentication would earn its keep, because OpenStack users authenticate at the CLI and in CI far more than at Horizon. With that path in place the clouds.yaml password becomes optional; today it is not yet documented as deprecated.
  • Step-up is expressed as amr requirements in policy rather than as separate credentials.

4.8 Cross-service calls on behalf of a user

When Nova calls Neutron for a user it either forwards the user’s JWT or performs an on-behalf-of exchange (§4.5). Forwarding is simpler and works with today’s code, but a forwarded token is accepted by whatever its audience allows, so a compromised service can act as the user everywhere the token is accepted. The rule is therefore:

Forward only a token whose aud includes the callee; otherwise exchange.

A token with the default audience includes every callee, so the rule confines forwarding only for clients that requested a narrower audience (§4.3) — which is what a client holding broad authority should do. For unnarrowed tokens the exchange path is the confinement: a service that does not want to hand its callee the user’s full token exchanges it instead. Both paths carry the caller’s SVID as service_credentials, which is the control-plane identity that replaces X-Service-Token.

5. Scope-less tokens and per-request target scope

Audience: service maintainers, keystonemiddleware and SDK authors. Skip if you only need the effect on callers; the TL;DR covers it, and §5.4 is the part reviewers should read.

5.1 Request contract

A request to an OpenStack service names its target in one of three ways, in this order of precedence:

  1. Resource-derived. For any operation on an existing resource (GET/PUT/PATCH/DELETE .../servers/{id}), the target is the owner of that resource as recorded by the service. This is authoritative. A client-supplied scope that disagrees with it is a policy violation and the request fails closed (404 or 403 per the service’s existing disclosure rules).

  2. Header. For operations that have no existing resource (create, list, actions on collections), the client sends a structured header (RFC 8941 dictionary syntax) naming the target:

    OpenStack-Target: project="8e3f..."
    

    Exactly one target must be named, by id or by name, as project, domain or system-scope:

    MemberValueTarget
    projectproject idthat project
    domaindomain idthat domain
    system-scopeallthe system: cloud-level resources, and cloud-wide reach over projects

    system-scope="all" is deliberately the same system scope the v3 API already has, spelled the way keystonemiddleware already exposes it (OpenStack-System-Scope: all). This proposal introduces no new scope type: a service that understands system scope today understands this header, and a service that does not is in exactly the position it is in today. What changes is only that the scope arrives per request instead of frozen into the credential, so an operator no longer re-authenticates to move between system scope and a project.

    Name-based addressing mirrors the tokenless X-Project-Name / X-Project-Domain-Name pair as dictionary members the middleware resolves to ids:

    OpenStack-Target: project-name="demo", domain-name="Default"
    

    The header may also carry the addressed resource, in the same <service-type>:<resource>:<id> form the operation vocabulary uses (§6.1):

    OpenStack-Target: project="8e3f...", resource="compute:server:550e..."
    

    For an existing resource the resource member never overrides rule 1: it lets the middleware and the policy engine pre-check and audit the request before the service loads the resource, and it is the hook for resource-level grants (§6.5). A resource member that disagrees with the URL, or a project that disagrees with the loaded resource’s owner, fails closed.

  3. Default. If neither is present, keystonemiddleware rejects the request unless the operator has enabled fallback to the subject’s default project (an existing Keystone user attribute) for the migration period. Reject is the default; fallback is a documented, per-service opt-in, and it is time-boxed by a deprecation cycle so that it expires with the migration rather than becoming a permanent mode.

Unauthenticated endpoints are outside this contract: version discovery, JWKS, .well-known documents and health checks carry neither a subject nor a target and are never subject to rule 3’s rejection. A service’s list of unauthenticated routes is the same list it maintains today.

Using a structured field rather than a family of X-* headers keeps the grammar extensible (a later region or resource-version member is additive), gives every SDK a standard parser, and avoids the X-Project-Id family that keystonemiddleware overwrites on the WSGI environment.

The resource-derived target always wins (invariant). Rule 1 is not a preference, it is the invariant that makes the whole design safe. If a service trusted OpenStack-Target for DELETE /servers/{id}, a caller with rights on project A could name A while deleting a server owned by B. Every service that participates must therefore compare the requested target to the loaded resource’s owner and fail closed on a mismatch, with the same scope-drift tripwire discipline the security model applies to delegation (I2/I3). A service that has not implemented this comparison is not ready to accept per-request targets, which is why §5.3 makes the compatibility mode an opt-in.

5.2 What keystonemiddleware does

  1. Verifies the JWT offline (signature, iss, aud, exp, nbf), or validates a legacy Fernet token through the mTLS back-channel.
  2. Parses OpenStack-Target; resolves names to ids; validates that the named project or domain exists and is enabled (a read against the same replica set the check goes to). The result is the requested target, and the middleware labels it as such: it is a client assertion, not a Keystone-attested fact.
  3. Populates the WSGI environment with the authentication facts and the requested target: X-User-Id, X-Requested-Target, X-Actor-* for act, the delegation context and the restrictions.
  4. Leaves the decision to the service’s policy, which resolves the target of §5.1 (resource owner first) and asks /v4/authz/check for the concrete operation on the resolved target. This is the model: the policy asks Keystone whether the actor holds the required role or permission on the target, instead of reading a role list out of the credential.

The middleware never fills X-Project-Id / X-Roles from the requested target on its own initiative, because a role list computed for a client-asserted scope, placed in the variable legacy policy treats as attested, is precisely the confusion of §2.1. It fills them only in the §5.3 compatibility mode.

5.3 Compatibility

  • A v3 scoped token keeps working: the middleware treats its embedded scope as the requested scope and rejects a conflicting OpenStack-Target header. A scoped token’s scope is Keystone-attested, so nothing below applies to it.

  • A client that never sends the header and holds a JWT gets the default behaviour of §5.1 item 3, which an operator can set to “subject’s default project” to keep unmodified tooling working.

  • Keystone continues to issue scoped tokens on request; audience narrowing (ADR 0036 §7.1) and the restrictions claim (§5.4) let a client voluntarily pre-bind a token where a deployment wants that.

  • Compatibility mode is a per-service opt-in. A service can ask the middleware to keep populating X-Project-Id, X-Domain-Id, X-Roles and OpenStack-System-Scope from the requested target plus a role-list lookup, so that unmodified oslo.policy rules keep evaluating. Enabling it is a declaration by that service that it enforces the §5.1 invariant: every operation on an existing resource compares the requested target to the resource’s own owner and fails closed on a mismatch. Until that declaration the middleware refuses the mode, and the service sees only the §5.2 environment.

    The declaration is the whole point of the opt-in. The environment variables are byte-identical to today’s, but their provenance is not: under a scoped token X-Project-Id was attested by Keystone, under a per-request target it is chosen by the caller. A rule that reads project_id without comparing it to the resource — or code that stamps ownership, charges quota or shortcuts on is_admin from it — becomes caller-influenceable, which is the defect class of OSSA-2026-015. Adopting per-request targets is therefore a real, reviewable change in each service, and the roadmap budgets for it.

    The mode exists so a service can accept per-request targets before moving its policy to the check API, and is time-boxed on the same footing as the §5.1 item 3 fallback: a per-service configuration flag Keystone can report as a capability, switched off service by service (roadmap, policy-engine path).

  • SDKs add one header and drop per-project re-authentication.

5.4 Threat model changes

A scope-less token represents the subject’s full authority, as an AWS access key or an Azure token does. The mitigations are the ones those platforms use, and most already exist in the ADRs. The first three are gates on shipping scope-less tokens at all, not later refinements. (Without them a scope-less bearer token is a cloud-wide master key, weaker than the scoped Fernet token it replaces.) The roadmap splits the migration at exactly this line.

  • Sender constraint (gate). JWTs issued to SVID holders are presented over mTLS and bound to the SPIFFE identity by verifying the TLS peer, not by the sub claim (which is the mapped principal, not the SPIFFE ID); JWTs issued to public clients use DPoP (ADR 0026 v1.5) so a stolen token is unusable without the client’s key. ADR 0036 rules out RFC 8705 because SPIRE rotates certificates, which is correct for a thumbprint binding — the workable substitute is to bind to the SPIFFE ID rather than to the certificate, verified at the internal interface where the SVID is the TLS peer, and that binding needs specifying before scope-less tokens ship.
  • Audience narrowing (gate). Available end to end, not the default (§4.3): a client may request a per-service-type audience (ADR 0036 §7.1), and services know their own type and accept it, so a token captured at one service cannot be replayed at another. A client that omits the audience gets a token every service accepts; holders of broad authority (CI, automation) are expected to narrow, and sender constraint is what protects the default token.
  • Self-imposed restrictions (gate). A client may request a token whose restrictions claim limits it to named targets and operations (the AWS STS session-policy shape). Restrictions only ever narrow, are chain facts, and are intersected by /v4/authz/check. Token restrictions already exist in the Rust implementation (TokenRestriction, designed into ADR 0015’s role-mapping schema for the Kubernetes authentication method, though the k8s-auth drivers don’t wire it in yet); this generalizes them. They are also the feature application-credential access rules only ever promised for one credential type (§2.5): a CI job or a script can hold a token that can do exactly one thing. Scope-less must be the default, never the only option; without restrictions the proposal trades one problem (too many tokens) for another (every token is a master key).
  • Short lifetime (fifteen minutes) with refresh-token rotation and family breach detection (ADR 0026 §9).
  • Fail-closed scope agreement. The resource-derived target always wins over the header; a mismatch is an error, never a silent widening (§5.1).
  • Audit. Every decision logs subject, act, requested scope, resolved target, operation, outcome and the state version it was decided against ( ADR 0023), which is what makes anomaly detection on a stolen token possible at all. Because no decision is answered from a cache, every one reaches the data plane (or the Keystone node serving in its place), whose decision log is therefore complete; the enforcement point records the same facts for correlation with the request it served (§6.3).

6. Authorization model

6.1 Roles stay; permissions are added

Keystone keeps roles, role assignments, implied roles and inheritance. It adds permissions: operation identifiers drawn from the cloud-wide policy vocabulary that every service already publishes through oslo.policy (os_compute_api:servers:create, get_image, create_port). A role becomes, in addition to its name, a named set of permissions; the SRBAC personas are shipped as such sets:

PersonaMeaning as a permission set
readerevery *:list, *:show, *:get operation of every service
memberreader plus lifecycle operations on tenant-owned resources
managermember plus administration of the target short of admin: project manager on a project, domain manager on a domain
admineverything on the target
servicethe operations a control-plane service needs when acting as itself

A grant is (principal, role | permission set | permission, target). Targets are system, domain:<id>, project:<id> and, later, individual resources. Inheritance is what it is today: a grant on a domain or on the system can be marked inherited and projects onto every project below it.

Which operations belong to member rather than manager in each service stays a per-project decision, and it is the same decision the SRBAC goal already requires; this proposal changes how the answer is stored, not what it is.

The operation vocabulary is a prerequisite

Per-operation grants need stable, cloud-wide operation identifiers. The oslo.policy rule names are close but inconsistent across projects (os_compute_api:servers:create versus create_port versus add_image), so the deliverable is a registry with a normalized form (<service-type>:<resource>:<verb>) and a per-project alias table. This is the largest cross-project ask in this document; authoring it is documentation and a generator rather than code.

Operating it needs more. Because /v4/authz/check expands roles and permission sets into operations at decision time (§6.3), Keystone holds a live catalog spanning every deployed service, out-of-tree ones included, that changes on every service upgrade. Four rules make that tractable:

  • The catalog is deployment state with a version. Services register their operations (or an operator imports the generated registry). A vocabulary change is a change to a decision input like any other, so a replica evaluates against a stale vocabulary for no longer than its replication lag, and the registry version is part of the version it reports (§6.3).
  • Unknown operations fail closed. A check for an operation the catalog does not know is denied, never allowed. A service that ships a new operation before the registry knows it is therefore broken loudly for that operation only, rather than quietly permissive.
  • Persona sets are expressed as patterns, not enumerations. reader is “every *:list, *:show, *:get” evaluated against the catalog, not a frozen list of operation names, so a new read operation is covered on the day it is registered. This is what keeps a service upgrade from silently demoting existing readers, and it is why the verb half of the normalized form has to be a closed set.
  • Registering an operation is a privileged, audited write. Because personas are patterns over the catalog, a registration is a grant change: an operation registered under a read verb is granted to every reader in the cloud, global readers on the system target included, without any grant being written anywhere. The closed verb set constrains the name, not the behaviour, so the registry needs what a policy rollout needs — a named owner, an audited write path, and review that the verb matches what the operation does. A service that registers a mutating or secret-disclosing operation as *:show has granted it to every auditor in the deployment. §10, question 2 asks where the registry lives; who may write to it is the other half of that question.

6.2 The authorization input contract

The community-facing deliverable is a schema, not a tool. Any policy engine (OPA is the reference; oslo.policy with an http check is the bridge) must receive:

  • credentials: the authentication facts from the JWT or SVID, exactly as projected by Credentials in the Rust implementation, without roles;
  • service_credentials: the mTLS peer, when a service is calling;
  • target.scope: the resolved target of §5.1;
  • target.<resource> and existing.<resource> per ADR 0002;
  • operation: the permission identifier;
  • context: extra resource facts the calling service attaches for a single decision — Neutron’s rbac_policy sharing rows, an image’s member list, a secret’s ACL, a network’s address-scope — passed straight through to the engine in its native shape (OPA input, OpenFGA contextual tuples plus a context object, Cedar context). This is how a service moves a complex, service-specific condition into the check call instead of keeping a local enforcement path for it. context feeds the decision only; it never feeds the delegation boundary, the restrictions filter or the role set, all of which stay bound to the authentication chain (I1/I2) and are not caller-supplied.

Rules that today read input.credentials.roles keep working in the compatibility mode of §5.3, where the middleware pre-fetches the role list for the requested target; that mode carries the §5.1 obligation with it. Rules that need finer control, and every rule in a service that has moved off the compatibility mode, call the check API for the specific operation on the resolved target.

6.3 The Keystone authorization API

Audience: core maintainers and implementers (the API contract). Skip if you only need what it solves; see §7.

POST /v4/authz/check (batchable) answers, for a subject and a target, which of the listed operations are allowed and through which grant. It applies:

  1. direct, group-derived and inherited grants on the target;
  2. system-level grants marked as applying to every project (this is how a global reader is expressed);
  3. the delegation boundary from delegation_context (I2/I4 of the security model): a delegated subject can only be allowed on its delegated project and only for the delegated roles or permissions;
  4. restrictions from the token;
  5. implied-role expansion and role-to-permission expansion.

It is served by the IAM data plane of §4.4 where one is deployed, and by the IAM control plane itself where one is not; no caller holds a cross-request copy of the answer. The OpenFGA assignment driver ( ADR 0033) and per-domain drivers (ADR 0034) mean a deployment can back this API with a Zanzibar-style relation store for tenants that already run one, with relation sync (ADR 0035) keeping group membership consistent, at the cost of a freshness guarantee the store defines rather than Keystone, which the rest of this section works through.

The design detail of this API is in IAM data plane and check API: design detail. Its conclusions are these:

  • The chain is verified, not asserted. The check request carries the subject’s credential as presented, and the data plane derives act, delegation_context and restrictions from the verified claims. A check whose chain cannot be verified is denied. Parsed fields are advisory only.
  • The API is not an oracle. A caller asks about a subject presenting to it, within a target range bounded by its own reach. Enumeration is measured.
  • It is the new hot path. It is batchable, versioned, admission-controlled per calling SVID, internal-only, and answered from a replica without a round trip to the write path.
  • Replicas, not caches (principle 10). Freshness comes from replication, reported as a version with every answer, with min_version and max_staleness as the caller’s controls. The replica comes from a Raft learner, a database standby or an external store’s own replication, per assignment driver.
  • Capacity is per caller class, and the only memo in the architecture is scoped to one request.

6.4 Binding operations to workload identities

Audience: mapping-engine implementers and operators writing rules. Skip if you do not write mapping rules; §4.6 covers the flow.

With permissions as a unit of grant, a mapping rule (ADR 0020) can bind an identity to exactly what it needs. ADR 0020’s normative rule shape is match.all_of / {type, claim, value}, an identity block (with identity_mode) and an authorizations list; the SPIRE integration plan uses the same shape. This example follows it, with two extensions the mapping engine does not have yet: a permissions authorization type for the permission-set future of §6.1, and ${claims.*} interpolation in a project authorization, which the engine copies verbatim today:

{
  "name": "cluster-autoscaler",
  "match": {
    "all_of": [
      {
        "type": "prefix",
        "claim": "spiffe.id",
        "value": "spiffe://td/project/"
      }
    ]
  },
  "identity": {
    "identity_mode": "ephemeral",
    "user_name": "${claims.spiffe.id}"
  },
  "authorizations": [
    {
      "type": "permissions",
      "project_id": "${claims.spiffe.project_id}",
      "permissions": [
        "compute:servers:create",
        "compute:servers:delete",
        "compute:servers:list"
      ]
    }
  ]
}

No role is created, no policy file is edited in Nova, and the grant is visible and revocable in Keystone. This is the AWS STS pattern, with one difference: the permission set assumed at authentication time is an upper bound on what the call may do, not the decision itself. The decision also needs the target, which this proposal binds per request (§5.1), and the grants the subject holds on that target, which stay in Keystone. There is deliberately no mint-time document that answers the question by itself — which is why the check API exists, and why attaching a small precomputed policy to the credential is not an alternative to it. Human users get the same through service grants with a role-id ceiling (ADR 0036 §4, ServiceGrant.role_ids) — extended to carry permission sets once permission sets exist — or through self-imposed restrictions.

6.5 Resource-level targets

Once services pass target.<resource> with a stable id, a grant can name a resource (server:<id>) rather than a project. This is how sharing a single image, a single network or a single secret with another principal becomes a Keystone grant instead of a per-service sharing API. It comes after permission sets and depends on services registering their resource types, but the check API shape does not change.

This is where a relation store earns its keep: resource-level grants are the shape OpenFGA (ADR 0033) models well, and a deployment that runs one can back this phase with it. Per principle 9 that stays a backend choice — the same grants are expressible on the SQL assignment driver, and no part of this proposal requires a relation store to be deployed.

Sharing rules that a service computes today from its own tables — Neutron’s rbac_policy entries being the largest example — do not have to become Keystone grants to move under the check API. A service can keep owning that data and pass it per call as context (§6.2): the engine evaluates the sharing condition, the service keeps no enforcement branch. Turning those rows into first-class resource-level grants is then an optional later step, taken if and when the service wants them managed and audited centrally.

6.6 Quota and unified limits

Quota is the one place outside policy where services read the token’s scope as an authoritative project id: it decides which project a new resource is charged to, and which project’s limit is enforced. Moving the scope into the request does not change what quota means, but it changes where the project id may come from. The rule follows §5.1: quota is charged to the resolved target, which for a create is the requested target after the service has accepted it, and never to a value read out of the credential. Two consequences deserve stating in the unified-limits work rather than being left to each service:

  • A create under system-scope="all" has no project to charge and must be rejected; a cloud administrator creating a resource for a tenant names that tenant’s project as the target (§7.1), and quota is charged there, to the tenant — which is also what makes the operation auditable.
  • A delegated call is charged to the delegated project, the same project the delegation boundary pins the decision to (I2), so a service grant cannot be used to charge one tenant’s resources to another’s quota.

7. What this solves

7.1 System scope and the SRBAC personas

The personas survive, system scope survives, and the scope moves out of the credential. An operator who holds admin on system sends OpenStack-Target: project="P" when creating a server for a tenant, and Nova asks Keystone whether that subject may compute:servers:create on P. Keystone says yes because the system grant is inherited. The created server has an owner (P), quota is charged to P (§6.6), and listing works with project="P".

Per-request targets remove the §2.3 situation — a system-scoped token arriving at a service with no project in it — so “create a server as the system” now has an owner: Nova needs no new semantics for system-scoped creates, because a create always names a project. Every service gets consistent persona semantics from one authority instead of implementing them in its own policy file.

7.2 Global reader

A reader grant on system, inherited, allows every *:list/*:show operation on every project. An auditor lists servers with OpenStack-Target: system-scope="all"; Nova checks compute:servers:list on the system target, receives allow, and runs its all-projects query — the same all-projects query it already runs for a system-scoped token today. No token per project, no admin, no new role, no new scope type (§5.1). The per-item obligations of I8 apply as in §6.3.

7.3 Admin is global

admin on project A now yields allowed: false for any target other than A, because the check is always against the resource’s own target. A cloud administrator is an explicit admin grant on system. There is no is_admin_project, no admin special-casing in policy files, and the distinction between project administrator and cloud administrator is a Keystone fact rather than a per-service convention.

7.4 Service users with admin

A control-plane service acting as itself is identified by its SVID, is mapped to a principal, and holds the service permission set on system; nothing else, and in particular no password and no admin. When it acts for a user it forwards the user’s JWT or performs an on-behalf-of exchange, and the policy sees both the user (credentials) and the service (service_credentials), so “only nova-api may call create_port with device_owner=compute:*” is a plain rule.

7.5 Trusts

Replaced as described in §4.5. The remaining trust-shaped use cases in Heat, Magnum, Mistral, Glance, Nova and Cinder each have a documented migration in ADR 0036 §10.

7.6 Cross-project operations

Live migration, image sharing, network RBAC, volume transfer and quota management all involve a caller acting across targets. With per-request targets, a single token and a single check per touched target express them without rescope. Services that today keep a service_token fallback to bypass scope can drop it.

7.7 Domain manager

manager on domain:D is a grant like any other; Keystone resolves it for OpenStack-Target: domain="D" and, when inherited, for every project in D. The domain-manager persona that has taken several cycles to plumb through Keystone policies is a permission set plus inheritance, and other services get it for free.

8. Revocation, availability and the IAM data plane

Audience: operators (outage cost, §8.5) and implementers (storage and replication, §8.1 to §8.4). Skip if you only need the adoption order; see the roadmap.

Offline JWT validation removes Keystone from the data path but freezes authorization for the token lifetime (ADR 0026 §11, SPIRE plan “Mode C”). The SPIRE plan names three keystonemiddleware postures: Mode A legacy (plaintext back-channel), Mode B SPIFFE mTLS with a per-request back-channel call (keeps instant revocation), and Mode C JWT offline validation (no back-channel, trades instant revocation for zero round trips). The proposal changes the trade-off:

  • Authentication is validated offline: signature and expiry.
  • Authorization is checked on every request against a replica of Keystone’s state. Disabling a user, removing a role, revoking a grant or narrowing a delegation takes effect when the replica applies the write, which is replication lag — milliseconds inside a failure domain, seconds across a WAN — and never a lifetime an operator configured. The replica reports the lag, and past max_staleness it refuses rather than allows (§6.3).
  • An optional signed revocation feed (/v4/oauth2/{domain}/revocations, SPIRE plan “Mode C”) bounds credential revocation for offline validators without a per-request round trip. Where credential validation is served from the data plane, the revocation events are part of the replicated state and need no separate feed.

Keystone therefore remains a runtime dependency, as §2.2 states: this proposal makes IAM more resistant in a stated way rather than pretending to remove the dependency. Which component the request path depends on is the operator’s choice, between the IAM control plane serving everything (§4.4) and a data plane per failure domain.

In either case a check that cannot reach any serving replica fails closed: denied, with no grace window, because there is no cached allow to replay. Availability is bought with replica count and a client that tries them all, never with a stale window — a simpler posture than keystonemiddleware’s token cache and a stricter one than the Kubernetes webhook authorizer’s, and what makes the audit record complete (§6.3). A deployment wanting stale-if-error for read-only integrations enables the client-side cache opt-in of §6.3 for those services and accepts its constraints; it is not the default.

That posture is an operational requirement, so it belongs in deployment guides: fail-closed behaviour stated explicitly; at least three replicas per failure domain on separate hosts, each holding its own copy of the state; a replica set per region, because a cross-region round trip on every check puts a WAN in the middle of every API call; and capacity stated in decisions per second and per-caller-class terms (§6.3), with lag and refusal rate as alerting signals rather than dashboards.

8.1 Splitting the IAM control plane from the data plane

The split of §4.4 — an IAM control plane that mutates state and authenticates, replicas that evaluate — is the industry’s answer to this section’s problem. On its own it buys nothing on availability — one replica is the same single point of failure with a different process name — but it buys independent scaling, an independent release cadence and a containment boundary. Availability comes from three further properties, each of which has to be a requirement rather than a deployment habit.

Static stability. Every replica holds a durable local copy of the inputs it serves and keeps serving from that copy while the write path is unreachable — for the length of the outage, bounded only by max_staleness. The corollary is the one implementations lose: no startup dependency on the control plane. A replacement replica must cold-boot and begin serving with the write path down, because a replica that can only warm its state by calling the component it exists to survive is coupled to it, whatever the deployment diagram says.

The requirement binds whoever owns the inputs, which is not always the replica. Where Keystone owns them — the SQL and Raft drivers — the local copy is the replica’s own, and static stability is Keystone’s to hold and to test. Where a domain’s assignments live in an externally owned store (ADR 0033), the replica is a client rather than a holder: it has no copy to cold-boot from, and static stability becomes a property of that store’s own replication (an OpenFGA instance per failure domain over a standby of its database gives the same posture). An operator choosing that driver is choosing this alongside the freshness ceiling of principle 9, and a data plane serving both kinds of domain is statically stable for part of its traffic and not for the rest — a deployment-guide fact, not a footnote.

One failure domain per replica set. The federated serving layer of §4.4 is the definition of a supported high-availability topology, not one option among several: a replica set per region, no cross-domain call on the read path, and a partitioned region degrades alone, to the bound it declared.

One lag, operator-visible. With no lifetime at the enforcement point, the only staleness term is replication lag, and every replica measures and exports it. What an operator can promise is that number, and max_staleness is what they promise it will never exceed while an allow is served.

8.2 to 8.4 What the data plane is made of

The data plane is a second binary built from the same crates, keystone-dataplane. It is read-only by construction, serves the check API, JWKS and credential validation, and never authenticates. It needs five things from the storage and SQL layers before it can serve (local reads on learners with a version, lag metrics, seeding without the leader, a learner SPIFFE role, and a version on the SQL path). Secrets are kept away from replicas by tier-keyed encryption and, for replicas outside the control-plane trust zone, a second Raft group. The detail is in IAM data plane and check API: design detail.

8.5 What an outage of the IAM control plane costs

This is the actual answer to “Keystone must not be a single point of failure”. With a data plane deployed, a total outage of the IAM control plane costs exactly this:

  • no new or changed grants, role definitions or implied-role edits;
  • no operation-vocabulary changes (§6.1);
  • no authentication of any kind: no login, no token issuance, no refresh, no JWT-SVID exchange, no service-grant redemption, no on-behalf-of exchange (§4.5, ADR 0036), no attestation for new instances (§4.6);
  • no project, domain or user lifecycle operations.

Existing credentials keep validating and keep being authorized throughout, from the replicas, until they expire — and that last clause is the cost the token lifetime sets. With fifteen-minute access tokens (§4.3), a client whose token expires during the outage cannot obtain another until the IAM control plane returns, and a workload whose refresh rotation fails is in the same position. The data plane does not change this, by design: authentication includes writes, and a replica that issued tokens would be a signing oracle in every failure domain. What an operator can tune is the access-token and refresh-token lifetimes, which are availability parameters as well — a longer access-token lifetime buys tolerance of a longer outage at the price §5.4 states. The list above belongs in the deployment guide beside the replica-count numbers: it is what an operator is buying. Read against §2.2, where a Keystone outage stops every request the moment the validation cache expires, it is still the largest availability change in this proposal.

One further lever is already in the repository and unremarked: per-domain drivers (ADR 0034) make the write path shardable by domain, so a cloud-wide control-plane failure domain is a deployment default rather than something this architecture requires.

8.6 How other platforms answer the same problem

Every large platform that decides authorization centrally has converged on the same two answers:

PlatformWhere evaluation happensGrant propagationFreshness mechanism
AWS IAMin-region, per-service data plane over asynchronously replicated policyeventually consistent, documented as suchreplication only; statically stable by design
GCP (Zanzibar)replicated ACL servers per cluster over Spannersnapshot-consistent per requestconsistency tokens, plus a materialized closure index fed from a changelog
Azure (Entra + ARM)ARM’s authorization provider, regionally, over replicated role assignmentsrole-assignment changes propagate in minutes, documented as suchreplication plus a short assignment cache; directory- and app-role claims in the token are a separate, narrower path
Kubernetesin-process RBAC over a watch-fed cache, or a webhook authorizerwatch-fed: seconds; webhook: the cache lifetimethe watch, or authorizer cache lifetimes

Nobody keeps grant propagation synchronous, and everybody buys availability by replicating the evaluator rather than the enforcement point. None answers the availability problem by handing the enforcement point the grants: where entitlements do ride in a token, as Entra’s directory- and app-role claims do, they cover a narrow, slow-moving class of authority and carry the frozen-scope problem §2.1 exists to remove; nothing on the resource path uses that shape. Kubernetes is the one place the evaluator and the enforcement point coincide, and its in-process RBAC is therefore the grant-graph replication §6.3 refuses; the reasons it works there do not transfer, because the graph is small, a cluster is a scope boundary, and there is one enforcement point rather than one per service.

Four shapes could reduce the coupling further — two taken up, two refused:

  • Replica sets per failure domain, statically stable, with lag published. The three requirements above: no new protocol, a hardening of §4.4 plus the storage work of §8.3.
  • A materialized closure index inside the replica. Group and hierarchy expansion precomputed and incrementally maintained from the replication stream (§6.3). A scaling lever rather than an availability one, invisible to services, available where Keystone owns the inputs and unnecessary on a store that maintains its own. Worth reaching for when fan-out shows up in a profile.
  • A node-local decision agent. Refused: a per-host daemon holding decisions is a cache in the sense of §6.3, and a replica per host puts the whole grant graph on every compute node and makes the leader replicate to thousands of learners. Replicas are per failure domain, not per host (§10, question 11).
  • Entitlements carried in the token. Refused, though it has the best availability properties on this list — no runtime dependency on anything — for the reason §2.1 gives: this proposal knowingly trades the strongest availability story available for per-request authorization.

Local evaluation in every enforcement point is not on that list: §6.3 refuses it, and on an externally owned store it is unavailable at any price (§10, question 8).

Static stability decays silently when it is not exercised, so it ships with a test rather than a paragraph. The deployment guide names three game-day exercises: stop the IAM control plane and verify that the cloud still validates and still authorizes existing credentials from the replicas, and that lag climbs on every dashboard; partition one region’s replica set and verify that only that region degrades, and that it refuses at max_staleness; and cold-boot a replacement replica with the IAM control plane still down.

8.7 What a central authority costs

None of the above removes the trade this architecture makes. A single authority for mutually independent trust domains means its outage is everyone’s outage at once, and a bad grant write or policy rollout has cloud-wide and cross-tenant reach. The mechanisms here shrink the blast radius (per-class capacity, refusal past max_staleness, secrets kept away from replicas) and lengthen how long an outage is absorbed before it is felt (region-local replicas, static stability, bulkheaded pools). They do not decouple anything.

This is the bet AWS made with IAM, and it is defensible. IAM’s mitigation set is the one above, regional isolation included, plus accepting and documenting eventual consistency in grant propagation. The industry answer converges on replication lag as a normal, documented property rather than a defect. For OpenStack that is an expectation change as much as an architectural one: revocation today is nominally immediate, and this proposal trades nominal immediacy for a lag an operator can measure, read and bound.

Two consequences follow for how the work is delivered. The data plane and the policy bundle both need staged rollout and canarying, because correctness blast radius is now as cloud-wide as availability blast radius — and the policy bundle is already a named trust boundary with a half-closed supply-chain gap (doc/src/contributor/security-review.md §V4: the publish side signs and verifies its own signature in CI, while the consuming side still pulls a mutable :latest tag with no verification wired into the load path). And the bet belongs in front of operators at adoption time.

9. Adoption and coexistence

How this is adopted, in which order, and what each project has to change is in the roadmap. This section states only what must hold however the order is chosen.

Every step is additive. Python Keystone, Fernet tokens and unmodified services keep working throughout, and four rules are what “additive” means:

  • v3 scoped tokens and OpenStack-Target coexist. A scoped token’s own scope is the requested scope, and a conflicting header is rejected (§5.3).
  • Python Keystone keeps validating the tokens Rust Keystone issues (ADR 0026) until it is retired.
  • No Python-owned table changes; new state lives in Raft-backed drivers.
  • Every capability has an off switch that restores the previous behaviour for one service without redeploying Keystone.

Two properties are not negotiable, because without them the proposal is weaker than the status quo. Scope-less tokens do not ship without restrictions, audience narrowing and sender constraint (§5.4). Per-request targets are not accepted by a service until it enforces the resource-owner-wins invariant (§5.1). Everything else may be adopted per service, on the service’s own schedule, or not at all. A deployment may stop at role resolution (the roadmap’s Mode 1) indefinitely: it loses resource-level decisions and central policy change, and keeps everything it enforced before.

Held to those rules, every step delivers a system strictly stronger than the status quo. It removes long-lived secrets from the control plane and from guests, makes every delegation visible and revocable, gives every service the same persona semantics from one authority, and makes the scope and role decisions behind the last decade of authorization advisories impossible to key on the wrong input.

10. What the community still decides

  1. Header grammar details. OpenStack-Target as an RFC 8941 dictionary is proposed; open are the exact member names, whether resource is mandatory for single-resource operations once services support it, and how multi-target operations (server migration between projects, image sharing) name a second target.
  2. Where does the operation registry live, and who may write to it? In oslo.policy as generated documentation, in Keystone as a resource services register at startup, or in a separate governance-owned document? The second half is the sharper one: a registration is a grant change (§6.1), so an open self-registration endpoint lets any deployed service widen what every reader may do. Whether the write is operator-curated, governance-reviewed, or service-supplied with an audit trail is a decision about who holds a privilege, not where a file lives.
  3. Cross-project listing for non-admins. system-scope="all" reuses the existing system scope and therefore keys on a system-level grant, exactly as today. It leaves untouched the case of a user with roles on many projects who wants one listing across them; resolving that to “the set of projects the subject can read, with the service filtering” is friendlier to those users and considerably more expensive. Is it worth a separate target, or is it a client-side concern?
  4. DPoP versus mTLS-bound tokens for public clients. DPoP is the OAuth standard; OpenStack clients have no established key-management story for it yet.
  5. Resource-level grants. Whether and when to let Keystone hold grants on individual resources, replacing per-service sharing APIs.
  6. Retirement timeline for application credentials and EC2 keys once off-cloud automation can use workload federation and on-cloud automation has SVIDs.
  7. Should driver-dependent freshness be an operator-facing capability matrix? Whether each assignment driver declares what it reports as version, whether it honours min_version, and what revocation latency it can be held to — a contract operators can hold the project to — or whether that stays an implementation note in ADR 0034, is a governance question as much as a technical one. Operators pick drivers on it, and an LDAP-backed or externally stored input is the row that reports no version at all.
  8. Is anything owed to deployments that want local evaluation? This proposal leaves a live check against a replica as the only shape, rejecting both pushed entitlement snapshots and grant-graph replication (§6.3) — and on a domain backed by an external store, no other shape is available in any case. For a SQL-only deployment willing to accept that every enforcement point holds every tenant’s grants, local evaluation is technically reachable, so the question is whether that is documented as a supported option or named and refused. This proposal names and refuses it; a single-tenant deployment may reasonably disagree, and should say so here rather than build it quietly. What is not open is whether the coupling exists: §8.7 states it as an accepted cost.
  9. Should the client-side cache opt-in exist at all, and who administers it? §6.3 admits it for deployments that accept a stale window and §8 keeps it out of the architecture. Whether it ships in oslo.policy, whether an operator sets it per service or a domain administrator may set it for integrations in their own domain, and what maximum lifetime a cloud can impose over that choice are governance questions with an API surface, and they should be answered before anything ships that can serve a stale allow.
  10. Does the check API receive the credential, or a signed chain assertion? §6.3 requires the data plane to verify the chain facts itself and recommends the direct route, forwarding the credential as presented: simpler, strictly stronger, and disclosing nothing the callee does not already hold. It also means every enforcement point hands the user’s credential to a second component, which some deployments will read as widening its reach even though the data plane is internal-only. A Keystone-minted chain assertion avoids that and gives up liveness. Which is the default, and whether the trusted-asserter posture is offered at all, should be settled before the check API request schema freezes.
  11. Is the node-local decision agent part of the target architecture? §8.6 answers no; the question is recorded so a deployment measuring a case for one argues here.
  12. When does a domain move its assignments to the Raft driver? §6.3 compares the two Keystone-owned drivers. Whether a greenfield deployment starts on Raft, whether a domain migrates at Python Keystone’s retirement or earlier under ADR 0034, and whether group membership and hierarchy are mirrored into the driver or joined across providers, are decisions for that driver’s ADR.
  13. Which mechanism keeps secrets away from replicas, and which secrets? §8 names tier-keyed encryption and a second Raft group and orders them. The tier of each keyspace — which of sessions, API keys, signing keys and credentials a learner may hold as ciphertext it cannot open, and which it may not hold at all — is the storage ADR’s to decide before the first replica runs outside the control-plane trust zone.

11. References

Most ADRs below are Accepted and describe the system as it stands. ADRs 0024, 0030, 0031, 0032, 0034, 0035 and 0036 are still Proposed; this vision describes them in the same present tense as the rest for readability. ADR 0024’s SCIM endpoints and ADR 0032’s vendordata endpoint already ship; the rest are not implemented yet. ADR 0036 carries the most weight here — §4.5 and §7.5 build directly on it — so its proposed status is the one to track.

Roadmap: adopting the authentication and authorization vision

Status: Draft for community discussion

Date: 2026-10-14

The vision says what the target model is and why. This document says how OpenStack gets there and in which order. It is a plan to argue about, not a commitment by any project. Section numbers written as §N refer to the vision and link to it, unless the link stays within this document. ADRs are referenced by number.

The order is driven by dependencies, not by a single line of phases. Some work is a hard prerequisite of other work, and a lot of it is independent. The graph in §2 shows which is which, so that teams can start in parallel and so that a slow item does not hold back work that never needed it.

Decisions that gate milestones. Three decisions sit outside the work itself, and a milestone that depends on one cannot start until it is made:

  • Amend ADR 0016-v2 §3 so learners may serve local reads, before E1 (detail).
  • Settle whether the check API receives the credential or a signed chain assertion, before the request schema freezes in C1 (open question 10).
  • Decide who owns the Go SPIRE node attestor, before A2.

1. Rules that hold throughout

Every step below must respect the additive rules of the vision (§9), and this plan’s rule that each release cycle carries one large, user-visible change (the token-format change, stream B, is the largest and gets a cycle of its own).

  • Python Keystone, Fernet tokens and unmodified services keep working.
  • v3 scoped tokens and OpenStack-Target coexist. A conflicting header is rejected.
  • No Python-owned table changes. New state lives in Raft-backed drivers.
  • Every capability has an off switch that restores the previous behaviour for one service without redeploying Keystone.

2. Workstreams and dependencies

Five workstreams. Each milestone has an identifier used in the graph and in the tables that follow.

StreamConcernMilestones
AIdentity foundation (SPIFFE, workloads)A0, A1, A2
BToken format (Fernet to JWT)B1, B2, B3
CAuthorization (scope, check API, policy)C1, C2, C3, C4
DDelegation (trusts replaced)D1, D2, D3
EIAM data plane (optional availability)E1, E2, E3
flowchart TB
    subgraph A["A: identity foundation"]
        a0["A0 SPIRE for the control plane"]
        a1["A1 secretless control plane"]
        a2["A2 tenant workload identity"]
        a0 --> a1
        a0 --> a2
    end
    subgraph B["B: token format"]
        b1["B1 JWT validation in the shared libraries"]
        b2["B2 JWT issued by default,<br/>Fernet still accepted"]
        b3["B3 Fernet validate-only, then off"]
        b1 --> b2 --> b3
    end
    subgraph C["C: authorization"]
        c1["C1 per-request target<br/>and check API"]
        c2["C2 scope-less JWT"]
        c3["C3 operation vocabulary<br/>and permissions"]
        c4["C4 policy-engine path"]
        c1 --> c2 --> c3 --> c4
    end
    subgraph D["D: delegation"]
        d1["D1 service grants<br/>and on-behalf-of"]
        d2["D2 service migrations<br/>(multi-cycle)"]
        d3["D3 trusts off,<br/>then removed"]
        d1 --> d2 --> d3
    end
    subgraph E["E: data plane (optional)"]
        e1["E1 real learners"]
        e2["E2 keystone-dataplane"]
        e3["E3 grant index<br/>and secrets isolation"]
        e1 --> e2 --> e3
    end
    a1 --> d1
    b1 --> d1
    b1 --> c2
    a1 -->|"sender constraint"| c2
    c1 -.->|"delegation-aware resolver"| d1
    a2 --> d3
    c1 --> e2
    c3 -.-> e3

Solid arrows are hard dependencies. Dotted arrows are requirements one side places on the other without blocking its start.

2.1 What the arrows mean

DependencyWhy
A0 to A1A service can drop its password only once it has a SPIFFE identity to authenticate with.
A0 to A2Tenant workload identity uses the same SPIRE deployment. It does not need A1.
B1 to D1Grants and exchange mint JWTs. Services must be able to validate them first. The flip of the default issuance (B2) is not required.
A1 to D1Redeeming a grant needs an SVID-authenticated service client (ADR 0036 §3, §5).
B1 to C2Scope-less tokens are JWTs.
A1 to C2The sender constraint for control-plane callers binds a token to a SPIFFE ID, verified where the SVID is the TLS peer (§5.4).
C1 to D1Not a start dependency, because ADR 0036 keeps a transitional project and role projection in the token. When C1 lands, its resolver must be delegation-aware (see below).
A2 to D3Trusts cannot be turned off while Magnum still writes a trustee password into guests. The guest side needs workload identity (ADR 0036 §10.3). Heat’s in-guest EC2 keypairs do not gate D3 — in current configurations they do not depend on the trust — and retire on this same A2 track.
C1 to E2The data plane serves the check API.

Two corrections to the intuitive ordering are worth stating.

  • Delegation does not wait for scope-less tokens. D1 ships against scoped tokens. The reverse constraint is the one that matters: when the check API resolves roles at request time, it must compute live roles of the subject on the target, intersected with the grant’s role ids, and require the target to equal the delegated project (ADR 0036 §7). A resolver that ignores the delegation would let the target a delegated caller names widen the delegation.
  • Workload identity does not wait for scope-less tokens. The SPIRE plan maps an attested VM to a project-bound principal through the mapping engine, which needs no change to token scope.

2.2 Global view by release cycle

The same milestones laid over the OpenStack six-month cycle. Cycles are named relative to the first planned one: x.1 is the first development cycle (released in April), x.2 the one after it (October), then x+1.1, x+1.2 and so on. x-1.2 is already released. The dates are indicative: they show order and size, not a commitment by any project. A stream can slip without moving the others, except along the arrows in §2. In the chart, a bar that ends at an April tick finishes in a .1 release and one that ends at an October tick in a .2 release.

gantt
    title Adoption by release cycle
    dateFormat YYYY-MM
    axisFormat %b
    tickInterval 6month
    section A identity
    A0 SPIRE in devstack and deployment tools :a0, 2026-10, 2027-04
    A1 middleware SPIFFE transport          :a1a, 2026-10, 2027-04
    A1 deployment tooling adoption          :a1b, 2027-04, 2027-10
    A2 pilot in devstack                    :a2a, 2027-04, 2028-04
    A2 general availability                 :a2b, 2028-04, 2028-10
    A2 Magnum guests credential-less        :a2c, 2028-10, 2029-04
    A2 Heat CFN guests credential-less      :a2d, 2028-10, 2029-04
    Retire app creds to off-cloud only      :aret, 2029-04, 2029-10
    section B token format
    B1 JWT validation in libraries          :b1, 2026-10, 2027-04
    B1 adopted by services                  :b1b, 2027-04, 2027-10
    B2 JWT issued by default                :crit, b2, 2027-10, 2028-04
    B3 Fernet validate-only                 :b3a, 2028-10, 2029-04
    B3 Fernet off                           :b3b, 2029-04, 2029-10
    section C authorization
    C1 Keystone side and libraries          :c1a, 2026-10, 2027-04
    C1 service adoption (Mode 1)            :c1b, 2027-04, 2028-10
    C2 gate work                            :c2g, 2027-04, 2028-04
    C2 scope-less JWT                       :crit, c2, 2028-04, 2028-10
    C3 permissions and vocabulary           :c3, 2028-04, 2029-04
    C4 policy-engine path                   :c4, 2029-04, 2029-10
    section D delegation
    D1 Keystone side and keystoneauth       :d1a, 2026-10, 2027-04
    D1 pilots (Heat, Glance)                :d1b, 2027-04, 2027-10
    D2 service migrations                   :d2, 2027-10, 2029-04
    D3 trusts off by default                :d3a, 2029-04, 2029-10
    D3 trusts removed                       :d3b, 2029-10, 2030-04
    section E data plane
    E1 real learners                        :e1, 2026-10, 2027-10
    E2 keystone-dataplane                   :e2, 2027-10, 2028-04
    E3 grant index and secrets isolation    :e3, 2028-04, 2029-04
Cycle (release month)Stream AStream BStream CStream DStream E
x-1.2 (Oct)ADR 0036 accepted; A0 designDesign onlyDesign only; settle check API request schemaDesign onlyADR 0016-v2 amendment
x.1 (Apr)A0 SPIRE delivered; A1 middleware transportB1 library support, opt-inC1 check API, header, oslo.policy check typeD1 grants and exchange in Keystone, keystoneauthE1 begins
x.2 (Oct)A1 tooling adoption; A2 devstack pilotB1 adopted by servicesC1 first services on Mode 1; C2 gate work beginsD1 pilots: Heat grants, Glance exchangeE1 done
x+1.1 (Apr)A2 pilot continuesB2 JWT by default (the cycle’s one user-visible change)C1 adoption continues; C2 gate work doneD2 migrations beginE2 keystone-dataplane
x+1.2 (Oct)A2 general availabilityB3 startsC2 scope-less JWT; C3 beginsD2 continuesE3 begins
x+2.1 (Apr)Magnum and Heat CFN guests credential-lessB3 Fernet validate-onlyC3 permissions; Mode 2 for early adoptersD2 completeE3 done
x+2.2 (Oct)Application credentials limited off-cloudB3 Fernet offC4 policy-engine pathD3 trusts off by default
x+3.1 (Apr)D3 trusts removed

What each release delivers

Audience: operators and release managers. Skip if you only need the order of work; the dependency graph covers it.

  • x-1.2 (Oct). No change for operators or users; the design is agreed.
  • x.1 (Apr). Operators can grant a service standing, bounded rights to act for a user, as a trust replacement for new setups. Libraries can validate JWT tokens and ask Keystone whether a request is allowed. SPIRE is delivered in devstack and the deployment tools (A0), and control-plane calls can use SPIFFE instead of passwords (A1 transport).
  • x.2 (Oct). Heat and Glance run long operations without trusts. Services start enforcing authorization through the check API. Services stop holding Keystone passwords for token validation. VM workloads get credentials in a devstack pilot.
  • x+1.1 (Apr). Users get JWT tokens by default. Services validate them locally, with less load on Keystone. Service migrations away from trusts proceed.
  • x+1.2 (Oct). Scope-less tokens: one token works across projects and is narrowed per request. Tenant VM workloads get credentials without stored secrets (general availability). Fernet is deprecated.
  • x+2.1 (Apr). All services have left trusts. Roles can be expressed as permissions on operations. Magnum and Heat CFN guests need no stored credentials (the per-VM EC2 keypairs leave the guest metadata). Fernet tokens validate only.
  • x+2.2 (Oct). Trusts are off by default. Fernet is off. Authorization can run in the policy engine. Application credentials are meant for off-cloud clients only.
  • x+3.1 (Apr). Trusts are removed from Keystone: no API, no table, no code path.

What shapes this schedule

  • Libraries lead services by one cycle. keystonemiddleware, keystoneauth1 and oslo.policy are cycle-released deliverables, and a service can only consume what a released library offers. Every service-side adoption therefore sits one cycle after the library change it needs: B1 in x.1 and services in x.2, and C1 in x.1 with first services in x.2.
  • Stream B gets a cycle of its own. B2 is the biggest visible change for end users. x+1.1 carries no other user-visible change. Service-side work that users do not see (D2 and C1 adoption) continues in the same cycle.
  • Breaking defaults land on skip-level boundaries. Releases that allow skip-level upgrades (x.1, x+1.1, x+2.1) are where operators jump. The default flips (B2, the Fernet steps, trusts off) are placed so that each falls in a cycle with at least one full release of warning before it. The exact deprecation periods have to be checked against the current TC policy before this plan is adopted.
  • D2 is the long pole. It touches Heat, Mistral, Senlin, Aodh, Sahara, Trove, Magnum and Glance, each on its own team’s schedule. Three cycles is a guess, not an estimate, and D3 cannot start before it ends. Trusts stay available throughout, so a service that is late blocks only D3, not the services that finished.
  • C2 waits for its gate, not for the calendar. Restrictions, audience narrowing and sender constraint are all built in the two cycles before it. If any of them slips, C2 slips with it, and nothing else in the plan has to.
  • A2 depends on a decision, not just work. The SPIRE node attestor is a Go plugin that needs a home in the community. If that is not settled by x.2, A2 and with it D3 move.
  • Python Keystone. Dates assume Rust Keystone as the reference implementation. A deployment that stays on Python Keystone can adopt the library and service changes but cannot redeem grants or serve the check API until Python Keystone gains them or is retired (ADR 0036).

3. Stream A: identity foundation

A0. SPIRE for the control plane

SPIRE is the cornerstone. It is deployed for the OpenStack control plane only, not for end users, and is configured to issue an identity to every control-plane process and every compute host (SPIRE plan Phases 1 and 2).

Status: delivered in x.1, in devstack and in the deployment tools together. This is the earliest an operator can run it.

A1. A control plane without Keystone credentials

  • keystonemiddleware gets a SPIFFE mTLS transport for validation (SPIRE plan Phase 4.1).
  • Service-to-service calls authenticate at the TLS layer. Service users, service passwords and X-Service-Token stop being needed (§4.2).
  • Legacy services that still ask Keystone for a token keep working.
  • Secrets that services hardcode into provisioned VMs, such as Magnum’s and Heat’s, stay until A2 (and D3 for the trust-based ones).

The goal is “no Keystone credentials in the control plane”. Database and message queue credentials are out of scope.

A decision to make here: service-to-service calls can forward the user’s token or exchange it (D1). A1 does not need to choose, because forwarding works with mTLS alone.

A2. Tenant workload identity

Depends on A0. It does not depend on scope-less tokens.

  1. Nova asks Keystone for vendor metadata (ADR 0032). The metadata contains a short-lived attestation token that Nova injects into the VM.
  2. The SPIRE agent in the VM presents it to the SPIRE server, which issues a project-bound SVID. This happens on new VMs only.
  3. The workload presents the JWT-SVID to Keystone, which maps it to a principal bound to spiffe.project_id (ADR 0020).

Open items:

  • How the mapping gets its authority. Authority must come from what Nova attests on the server side. Metadata that the guest or user supplies must never decide roles. Narrow authority by default (the compute-vm role of the SPIRE plan) and wider authority by explicit mapping rules or permission sets (§6.4).
  • The SPIRE node attestor is a Go plugin, and it lives outside OpenStack governance and release practice. Mirroring the repository solves hosting, not review, CI or releases. Ownership has to be decided before this milestone.
  • Magnum is a separate migration with its own plan and the Magnum team ( ADR 0036 §10.3).
  • Heat’s in-guest CFN credentials — a per-server EC2 keypair delivered in the server’s metadata, plus the stack-domain user’s password for the heat and zaqar transports — retire on this track too, once the in-guest CFN client can present the JWT-SVID (ADR 0036 §10.2). They do not gate D3.

4. Stream B: token format

This is the longest stream and the most visible to end users. It runs in a release cycle of its own.

  • B1. JWT validation in the shared libraries. keystonemiddleware and keystoneauth1 can validate Keystone JWTs: signature against the JWKS, the audience, the issuer allowlist. This is the only part of the stream that other work depends on.
  • B2. JWT issued by default. Keystone becomes a full OAuth2 provider. Fernet and JWT are accepted side by side, and at some point the default issuance flips to JWT.
  • B3. Fernet validate-only, then off. Last, after the deployments that need Fernet have moved.

Offline validation (A1 and B1 together) accepts that revocation takes effect at token expiry unless the validator checks live (§8). The 15-minute access token is the stated bound.

5. Stream C: authorization

C1. Per-request target and the check API

Five deliverables. None changes a credential, and tokens stay scoped.

  • The OpenStack-Target header, and middleware that exposes the requested target as a client assertion (§5.2).
  • /v4/authz/check, served by the IAM control plane from the first release, with version on every response (§6.3).
  • The oslo.policy keystone check type: request-scoped memo, replica list, fail-closed breaker, no cross-request cache.
  • Per-service adoption of the resource-owner-wins invariant (§5.1), with opt-in to the compatibility mode (§5.3) and SDK header support.
  • A delegation-aware resolver (see §2.1).

The operation vocabulary arrives with C3, and unknown operations fail closed from the start (§6.1). What the check API answers in C1 is role resolution on a target, plus any operations a deployment has registered.

The request schema of the check API freezes here, so the open question on whether the API receives the credential or a signed chain assertion (§10, question 10) has to be settled first.

C2. Scope-less JWT (gated)

C2 may not start until all of these are shipped:

  • the restrictions claim (gate);
  • service-type audience narrowing available end to end: clients can request it (ADR 0036 §7.1) and services accept their own type (§4.3; gate). It is not the default, and an unnarrowed token stays valid at every service;
  • sender-constrained presentation: DPoP for public clients, SPIFFE-ID binding for SVID holders (§5.4; gate).

Then: scope-less access tokens, the JWT offline filter, and scoped tokens demoted to a compatibility option. The gate is the load-bearing part. Scope-less tokens without it are a cloud-wide master key, weaker than the token they replace.

C3. Operation vocabulary and permissions

The operation registry, with versioning and fail-closed unknown operations (§6.1). Roles become permission sets. Mapping bindings carry permissions. The batch check API serves collection reads (§6.3).

This is a separate program from C1 and C2, and the one with the most open governance questions: where the registry lives, who may write to it, and whether services push their policies or a central plane distributes them (§10, question 2). It should not hold C1 and C2 back.

C4. Policy-engine path

Services that want central authorization move from oslo.policy http checks against /v4/authz/check to a full policy-engine input. The compatibility mode of §5.3 is switched off for those services one at a time. Service passwords and service admin grants are removed where no longer used. X-Service-Token is retired once nothing depends on it.

5.1 Two ways to consume the check API

How much of the decision a service hands over is a spectrum. The first mode is a valid place to stop.

  • Mode 1, role resolution. The actor and the OpenStack-Target go to Keystone on every request, which returns the effective roles and permissions on that scope. The service evaluates its existing oslo.policy rules locally, as it does today with a scoped token. The swap happens inside keystonemiddleware and oslo.policy, not in the service. The service makes a one-time, reviewable compatibility-mode declaration that its policy checks compare the requested target to each resource’s own owner (§5.3). A service whose policy.yaml tests only scope-level facts typically needs no code change to satisfy it. This is the drop-in state when tokens go scope-less in C2.
  • Mode 2, full delegation. The service sends the actor, the operation and the resource and receives allow or deny. This is the end state of C4.

A deployment may stay on Mode 1 indefinitely. It gives up resource-level decisions (§6.5), permission-set personas beyond what local rules encode, and central policy change without redeploying a service. Everything each service enforced before keeps working.

6. Stream D: delegation

Design: ADR 0036.

  • D1. Service grants and on-behalf-of exchange. The registry of delegatable services, the grant resource and API, the redemption grant, the exchange, lifecycle hooks and audit events. Pilots: Heat for grants, Glance for exchange. JWT-SVID client authentication on the public interface comes with its SPIRE preconditions (ADR 0036 §11, Phase 3).
  • D2. Service migrations. Every service that uses trusts swaps one creation call and one authentication plugin: Heat, Mistral, Senlin, Aodh, Sahara, Trove, Magnum’s conductor, Glance. This is not a large change per service. It is a multi-cycle effort across all of them, and it is the long pole of this stream.
  • D3. Trusts off, then removed. A configuration switch turns trusts off by default, with EC2 credentials under a grant handled first. Removal follows once every deployment that shares a database with Python Keystone has moved trust creation off it.

Trusts can be turned off only when D2 is complete for every service in use and A2 has removed the trustee password from Magnum guests (the dependency in §2.1). Neither C2 nor C3 is required.

7. Stream E: the IAM data plane (optional)

Design: IAM data plane and check API and the control plane and data plane split proposal. A small cloud that tolerates the availability of the Keystone nodes deploys none of this, and the API is the same either way.

  • E1. Real learners. Local reads with a version, lag metrics, a learner SPIFFE role, and restore-based seeding.
  • E2. keystone-dataplane. The read-only binary over a database standby and a Raft learner, serving the check API from C1 and credential validation.
  • E3. Grant index and secrets isolation. The in-memory grant index, batch checks at lookup cost, and tier-keyed encryption or a second Raft group so that replicas outside the control-plane trust zone become supportable.

The amendment to ADR 0016-v2 §3 for learner reads must be made before E1.

8. Changes per project

Audience: maintainers of individual OpenStack projects. Skip if your project is not listed; the workstreams cover the rest.

ProjectChange
python-keystonemiddlewareSPIFFE transport (A1); JWT offline filter (B1); OpenStack-Target parsing; /v4/authz/check client; X-Requested-Target and X-Actor-* environment; per-service gate for the §5.3 compatibility mode (C1)
keystoneauth1Device-flow, v4servicegrant and v4onbehalfof plugins (D1); per-request scope instead of per-session scope (C1)
oslo.policyA failure-aware keystone check type wrapping /v4/authz/check (C1): no cross-request decision cache by default, a request-scoped memo keyed on (subject, chain, target, operation), batch checks for the per-item re-check, a list of replicas tried in order with a fail-closed breaker per replica, and version logged with every decision. The existing http check is a stopgap that provides none of these. Operation registry export (C3)
openstacksdk, CLI, TerraformSend OpenStack-Target; stop re-authenticating per project (C1)
Nova, Neutron, Cinder, GlanceEnforce resource-owner-wins (§5.1) on every resource operation before accepting per-request targets; pass the resource owner as target.scope; charge quota to the resolved target (§6.6); drop is_admin shortcuts and service-token fallbacks (C1)
Heat, Mistral, Senlin, Aodh, Sahara, Trove, Magnum, GlanceService grants and on-behalf-of exchange instead of trusts (D2, ADR 0036 §10)
Nova (compute)vendor_data_url to Keystone; per-host SVID (A2, SPIRE plan Phases 1 and 2)
Deployment toolingSPIRE server and agents (A0); OPA or the oslo.policy bridge; internal endpoints on SPIFFE mTLS (A1); optionally the data plane per failure domain with its database standby and learner state (E)

9. Retirement

These happen last, and each is independent of the others.

  • Fernet validate-only, then off (B3).
  • /v3/OS-TRUST removed (D3).
  • Application credentials limited to off-cloud automation, once workload federation covers off-cloud automation and SVIDs cover on-cloud automation (§10, question 6).

10. Where the earlier phase numbers went

Earlier drafts of the vision used one linear list of phases. Documents written against it use these numbers.

Earlier phaseNow
Phase 0, shipped foundationShipped. Not a milestone
Phase 1, control-plane identityA0 and A1
Phase 2a, per-request targetC1, plus E2 where the data plane is deployed
Phase 2b, scope-less JWTC2
Phase 3, permissionsC3
Phase 4, delegationD1, D2, D3 (no longer behind C2 and C3)
Phase 5, tenant workload identityA2 (no longer behind C2)
Phase 6, policy-engine pathC4
Phase 7, sunsetRetirement (§9)

11. Open ordering questions

  • Who owns the Go SPIRE node attestor, and where is it released from (A2)?
  • Does a service forward the user’s token or exchange it for service-to-service calls (A1, D1)? Both work, and the answer can differ per service.
  • Is the operation registry operator-curated, governance-reviewed or service-supplied (C3)? It decides who holds the privilege to widen what every reader may do.
  • Which release cycle carries stream B, given that it should carry nothing else?

IAM data plane and check API: design detail

Status: Draft for community discussion

This document holds the design detail that the authentication and authorization vision summarizes in §6.3 and §8.2 to §8.4: how the check API stays honest about the caller’s chain, how it is protected, how replicas stay fresh, and what the data plane is built from. It was moved out of the vision so that the vision can stay a statement of what the proposal is and why. Section numbers written as §N refer to the vision, and link to it. The order in which this is built is in the roadmap. The review that led to the data plane split is in doc/plans/iam-control-data-plane-split.md.

Part 1. The check API (vision §6.3)

Audience: check-API implementers and security reviewers. Skip if you only need the effect on callers; see vision §4.4. Operators sizing a deployment can go straight to the freshness contract and capacity.

The chain facts are not the caller’s to assert

Items 3 and 4 are chain facts in the sense of I1: properties of how the caller authenticated, never of what the caller says. The request shape of §4.4 does not preserve that on its own. An enforcement point parses act, delegation_context and restrictions out of the presented credential, so a data plane that applies items 3 and 4 to the fields it was handed is applying them to data an enforcement point wrote, and a request with delegation_context omitted asks to be decided as though the chain were not delegated. That is the defect class behind OSSA-2026-005 and OSSA-2026-015, relocated from token scope into the check request, where it is harder to see because the field names are the right ones. SVID authentication does not close it: §4.2 is explicit that mTLS identifies the service and never the user it serves, and a compromised service that may assert its own account of a user’s chain can widen every delegation passing through it (§4.8).

So the chain must reach the data plane in a form the data plane verifies for itself, and the proposal takes the direct route: the check request carries the subject’s credential as presented, and the data plane verifies it offline exactly as the enforcement point did, deriving sub, act, delegation_context and restrictions from the verified claims. Parsed fields may still be sent for logging and a cheap pre-flight, but they are advisory — where they disagree with the credential the credential wins, and the disagreement is an audit event. Forwarding discloses nothing new: the callee already holds the credential, and the data plane is internal-only.

Two consequences follow.

  • A check whose chain cannot be verified cannot be decided safely. The data plane cannot differentiate an undelegated chain from a delegated one whose context was dropped, and the safe reading of that ambiguity denies everything. A deployment that cannot forward credentials — a legacy Fernet path, or a service in the middle that will not pass one on — therefore runs a declared posture on the same footing as §5.3’s compatibility mode: set per caller, reported as a capability, and understood as a statement that those enforcement points are trusted to assert chain facts. It must not be the default, and a deployment carrying delegated chains should refuse it outright.
  • A signed chain assertion is the fallback where forwarding is unacceptable. Keystone can mint at issuance an authenticated, opaque blob binding (sub, act, delegation_context, restrictions, jti) that an enforcement point relays without being able to alter it. It costs a claim and a key, and it says nothing about whether the credential is still live — so it is a fallback, not a preference (§10, question 10).

The request-scoped memo below is keyed by the same argument. Keyed on (subject, target) alone, an answer produced for a subject’s full authority could serve a delegated or restricted call from the same subject on the same target within one request — the escalation above, arriving one lookup later. The key is (subject, chain, target), where the chain component digests exactly the facts items 3 and 4 consume; an undelegated, unrestricted chain digests to a constant, so the ordinary case still shares entries. The same key is mandatory for the opt-in client-side cache described later.

One input deliberately stays outside the evaluation. The data plane knows which service is calling, and uses that for traffic classes and admission control (below), but it does not fold it into the grant decision. A rule such as “only nova-api may call create_port with device_owner=compute:*” (§7.4) is local policy at the callee, where service_credentials is already in the input (§6.2). Making the acting service a dimension of a grant multiplies the grant space by the service catalog, and belongs with resource-level targets (§6.5) if it is taken up at all. The data plane answers “may this subject do this here”, never “may this service ask”.

Answering enough questions discloses the graph

A caller that may ask about any subject, any target and any operation can reconstruct the grant graph a batch at a time. Refusing to ship the graph in bulk while serving an unmetered oracle over the same facts would be a distinction without a difference, so three limits come with the API:

  • A caller asks about a subject presenting to it. The forwarding rule above makes this enforceable rather than advisory: the credential is the caller’s proof that the subject is in fact presenting. Asking about a subject whose credential the caller does not hold is a separate, separately granted capability.
  • A caller’s target range is bounded where its own reach is. A core service serving arbitrary projects needs no bound; a registered third-party integration is registered against the domains or projects it serves, and a check outside that set is refused rather than answered — and refused, unlike denied, declines to say whether the grant exists.
  • Enumeration is measured. Distinct-subject rate and denial rate per caller class belong in the metrics the data plane ships (ADR 0031): a caller walking the graph looks like nothing else on the data plane, many distinct subjects at a high deny rate.

That separate capability is the oracle in administrative clothing, and operators need it: “which of these operations may this user perform on this project” is what an administrator asks while debugging a permission. Answering it is a privileged read of the grant graph, audited as one and granted to tooling rather than to anything on the data path.

This is the new hot path; design it like one

/v4/authz/check replaces token validation on the data path, so its non-functional requirements are not negotiable: batchable, answered from state local to the replica without a per-request round trip to the write path, versioned on terms the response states explicitly, admission-controlled per calling SVID, accounted in decisions rather than requests, and exposed on the internal interface only. Its latency budget is today’s keystonemiddleware cache hit budget, not the miss budget, because it is called on every request (§4.4). The existing assignment-layer caches stay as they are and are not building blocks: ADR 0034 §8 caches the domain-to-driver binding rather than resolved assignments, and ADR 0033 §9 declines to cache OpenFGA results at all. The new work is the in-memory grant index below, which is a replica, not a cache.

Two shapes are explicitly not the answer. A pushed “entitlement snapshot” per subject would recreate the §2.1 frozen-roles problem. Replicating the grant graph to every evaluation point — a bundle each service’s policy engine pulls and evaluates locally — fails for two independent reasons: there is no partition boundary to bound a bundle to, because Nova, Neutron, Cinder and Glance each serve arbitrary projects in arbitrary domains from any node; and an OpenStack grant graph spans mutually distrusting tenants, so “every compute node holds every tenant’s grants” is a disclosure decision, not a caching decision. Both constrain where grant facts come from and say nothing about where policy is evaluated — a per-service OPA sidecar asking this API live is unaffected (§4.4).

Freshness: replicas, not caches

A copy of authorization state has to answer one question: how does it know when it is wrong? There are exactly two answers.

A cache is wrong until someone tells it. Its freshness depends on every write path remembering which entries it affects, and on a lifetime for the cases nobody remembered. Python Keystone’s caching went wrong in precisely this way, repeatedly: a role list, a project’s enabled flag, a token’s validity or a catalog cached under whatever key the reader used; a new write path forgetting the key; region-wide flushes added when targeted invalidation proved incomplete; process-local caches diverging between workers; the lifetime becoming the real safety net; and operators disabling caching to make bugs go away. The refinements do not escape it. A per-subject invalidation counter still has to compute which subjects a write affects — one user added to a large group bumps every member’s counter, a domain-level inherited grant bumps every subject beneath it — which is the same fan-out relocated. A cloud-wide generation number avoids the computation by flushing everything on every write, at the price of a cache that never warms under churn.

A replica is never wrong about what it knows; it can only be behind. The replication protocol maintains its freshness and by construction observes every write, and the replica reports how far behind it is as a number: the Raft applied index against the leader’s commit index, a standby’s replay position against the primary’s, a relation store’s changelog position. No writer has to remember anything. A new grant and a revoked grant propagate at the same speed, which the cache schemes could never deliver for propagation. And a caller that has just written can ask to be answered from a replica that has seen its write, which no cache can offer.

PropertyCache (an oslo.cache entry per decision)Replica (Raft learner, database standby)
How it becomes freshWriter invalidates, or a lifetime expiresReplication applies every write
Who must rememberEvery write path, foreverNobody; the log is the invalidation
Staleness boundAssumed, from configurationMeasured, against the write path
New grant visibleAfter the lifetimeAfter the replication lag
Read-your-writesImpossiblemin_version: wait, forward, or refuse
Write path downServes until the lifetime, then nothingServes the last applied state, up to max_staleness
Cold startEmpty; a re-check herdFull state from a snapshot or base backup
Complexity lives inEvery enforcement point and its libraryOne component Keystone owns and tests

This proposal admits only replicas on the request path. The enforcement point holds no cross-request copy of a decision; the data plane holds a replica of the state and, where useful, an in-memory index derived from that replica and invalidated by the same replication stream. A client-side cache remains possible as an explicit opt-in for deployments that accept a stale window, and it is described last, with the constraints it carries.

Where the replica comes from, per driver

The data plane binary is driver-agnostic in the sense that the same code path handles every store below without a per-driver fork — not in the sense that a keyspace’s driver can differ between the two planes.

StoreReplicaVersion reportedCold boot with the write path downDriver change
Raft keyspaces: mapping, OAuth2 clients and public keys, permissions, operation registry, service grants, and assignments on the Raft driverPermanent Raft learner: added with add_learner, never promotedApplied log index, measured against the leader’s commit indexSnapshot from a peer replica, or keystone-manage storage restore then catch-upStorage crate: local reads on learners with version reporting (§8.3)
SQL: assignments, roles, hierarchy, identity, catalog, revocation eventsPostgreSQL hot standby per replica or per failure domain, read-only connectionReplay LSN, measured against the primarypg_basebackup from another standby (cascading replication)No new component — database.connection points at the standby — but the driver reports the replay position and its lag, and the write path returns a commit LSN (§8.3)
OpenFGAAn OpenFGA instance per failure domain over a standby of its databaseThe store’s changelog position, where exposedThe store’s own base backupPass the consistency preference through; disable the store’s own query cache
LDAP identityThe directory’s own replicaNone — see the freshness contractThe directory’sNone

Two consequences of that table matter more than its rows.

  • The SQL path needs no new component. A database standby is a replica in the sense above, it is operationally familiar, it keeps serving with the primary down, and no driver has to know it is reading one to answer a check. That is what makes the first data-plane deliverable small — but not free: reporting version and honouring min_version is driver work on the SQL path as much as on the Raft one, and §8.3 lists it.
  • New authorization state arrives in Raft. §9’s rule that no Python-owned table changes and new state lives in Raft-backed drivers means permissions, permission sets, the operation registry and service grants are in the replicated log from the start, and a learner holds them for free. Assignments themselves are a per-domain choice, and the next subsection compares the two Keystone-owned options.

The replica also removes the objection that sinks every elaborate freshness scheme. No single driver owns all five inputs of a decision (grants, group membership, hierarchy, implied roles, the operation registry), so no composite freshness token over them could be minted consistently, and every write path in four crates would have to remember to bump a counter. A replica mints nothing: within one Raft log every input the log holds is totally ordered, so a decision at applied index N is snapshot-consistent across all of them, and within one database the replay position gives the same guarantee. Only where inputs straddle two stores — SQL assignments beside Raft permissions, an LDAP group beside either — is a decision a join across two versions, and the response says so by reporting both.

A Raft assignment driver, compared with SQL

The existing assignment drivers are SQL (assignment-driver-sql, over the Python-compatible tables) and OpenFGA. A third, Raft-backed driver is the natural companion of the data plane, and worth designing on paper here because its shape decides what a learner can do with it.

It stores a grant as a relation tuple, the way a Zanzibar store does, indexed both ways in the replicated key-value store:

assignment:by_target:{target}:{role}:{actor}  -> { inherited, created_at }
assignment:by_actor:{actor}:{target}:{role}   -> (same value)

where target is system, domain:<id> or project:<id> and actor is user:<id> or group:<id>. Listing is a prefix scan on the matching index, pagination is by key, and a direct check is a point read. Effective resolution walks the same graph the SQL driver walks today — the actor’s groups, the target’s ancestors, then implied roles — and the two inputs that walk needs from other providers, group membership and the project hierarchy, are the crux of the design:

  • Mirrored tuples. The driver keeps group:member and project:parent tuples in its own keyspace, written by hooks on the identity and resource providers. Where identity and resource are themselves Raft-backed, the mirror is part of the same log and the graph is consistent at every index — the Zanzibar shape, with userset rewrites expressed as the walk. Where they are SQL, the mirror is a second write and needs the outbox and reconciliation discipline ADR 0035 already specifies for OpenFGA; the driver is then only as fresh as that sync, and the version reports it as a second component.
  • No mirror. The driver stores only grants, and the walk asks the identity and resource providers at decision time, as the SQL driver does. On the data plane those providers read their own replicas (a standby for SQL identity), so nothing is lost on freshness; what is lost is single-log snapshot consistency across the join.

Against the SQL driver, benefits and costs are both concrete:

PropertySQL assignment driverRaft assignment driver
Replica for the data planeA PostgreSQL standby per replica or per failure domainThe learner the data plane already is; no second component
Version and read-your-writesReplay LSN, per databaseApplied index, in total order with every other Raft-held input
Invalidation of an in-memory grant indexLogical decoding of the standby’s stream, or periodic reconcileThe apply hook: every committed entry is observed in process
Encryption at restThe database’sKeystone’s own (ADR 0016-v2), keyed by Keystone
Python Keystone coexistenceFull: the tables are the sameNone: Python Keystone cannot see these grants, so only for domains served by Rust Keystone alone (ADR 0034)
Group membership and hierarchyJoined at query time from the same databaseMirrored tuples, or a cross-provider walk (above)
Listing and paginationSQL cursorsPrefix scans on two indexes; pagination by key
Storage volumeWhatever the database holdsSmall: one tuple per grant; millions fit an embedded LSM tree comfortably
Operational surfaceA database and its replicationThe Raft cluster the deployment already runs for other state

The recommendation follows from the coexistence row. While Python Keystone is still in the picture, assignments stay on the SQL driver and the data plane reads a standby. A domain that only Rust Keystone serves — a greenfield deployment, or one moved under ADR 0034 — can adopt the Raft driver and gains a learner-only data plane and total-order versioning. The two coexist per domain, which is what ADR 0034 was built for, and the data plane serves both from one process.

The freshness contract

The contract is deliberately small, and the same wherever Keystone holds a replica:

  • Every response carries version. For a Raft-backed domain the applied log index; for a SQL-backed domain the replay position; for a relation store its changelog position. A decision whose inputs straddle stores reports each component. Enforcement points log it with the decision, which gives the audit trail “allowed against grant state N” (§5.4).
  • An input whose store reports no position is named, not hidden. An LDAP directory’s replica is the standing example: it contributes no version component, max_staleness cannot be enforced over it, and the response therefore marks the decision as resting on an input with an unbounded staleness term. An operator who needs a bound cannot get one from that driver, which is half of the capability question §10 asks (question 7).
  • A request may carry min_version. A replica behind it waits, up to a bounded time, for its state to reach it; then forwards to a replica that has; then refuses — not denies. The IAM control plane returns the commit version of every grant write, so a client that has just created a grant can ask for a decision that sees it. This is Zanzibar’s consistency token, and Raft provides it natively.
  • The operator sets max_staleness. A replica whose measured lag exceeds it stops answering with allows: it refuses, and the caller’s breaker moves to the next replica. This is the freshness dial. Unlike a lifetime it is measured against the write path rather than assumed, and unlike a lifetime it bounds the partition case too.
  • Refused and denied are different answers. A refusal says the replica cannot vouch for an answer; a denial says the grant does not exist. Clients treat the first as retryable elsewhere and the second as final, and dashboards count them apart.
  • There is no lifetime, no per-result TTL, no mint stamp and no generation number. The response is a fact about state version N. The caller uses it for the request that asked and discards it.
  • The data plane’s decision log is complete (§5.4). Every decision reaches a replica, so what the cloud decided is what the replicas logged, with the version each decision was made against.

What the assignment driver still changes is where the replica comes from and whether a version exists at all:

PropertyKeystone holds a replica (SQL, Raft)Externally owned store (OpenFGA, LDAP)
Freshness boundMeasured replication lag, bounded by max_stalenessThe store’s own replication; measurable only where the store exposes a position
Read-your-writesmin_versionOnly where the store offers a consistency token to pass through
Static stabilityThe replica’s own, tested by Keystone (§8.1)The store’s, established there
Revocation latencyThe lagThe lag, plus relation-sync lag for memberships Keystone syncs (ADR 0035)
Operator’s dialmax_stalenessThe same where a position is available; otherwise none

The columns converge wherever the store can report a position, and that is the payoff of not building cache invalidation: one operator-visible freshness model across every driver, instead of a SQL feature beside an external-store limitation.

The in-memory grant index inside the replica

The first data-plane deliverable answers a check by running the same queries the IAM control plane runs, against the local replica, with the request-scoped memo below removing repeats within one batch: a few milliseconds per distinct (subject, target), and enough to ship.

The scaling step is to materialize the grant graph in memory inside each replica — group membership, the hierarchy closure, implied roles, the role-to-permission map — so that a check is a lookup. This is the Zanzibar closure index. What makes it a replica and not a cache is where its invalidation comes from: the Raft apply hook, or logical decoding of the standby’s stream for SQL-backed domains, both of which observe every write. The index carries the version it was built to, the memo is still the only memo, and a replica can disable the index and fall back to queries at any time — so the index is an optimization over the replica, never a second source of truth.

A client-side cache is an opt-in, and it carries its constraints

A deployment may still decide a stale window at the enforcement point is worth a round trip saved — a Python service at the far end of a WAN, or an integration that would rather act on a stale-but-correct decision than fail. That is a client-library option (§9), off by default, and not part of the architecture. Where it is enabled, everything a cache needs applies and none of it is optional: the key includes the chain digest (above); allows are held for a short lifetime and denies may be held longer, because a stale deny is an inconvenience and a stale allow is a security failure; a decision whose chain carries act, delegation_context or restrictions is never served stale; the cached version is logged with the decision, so an auditor can tell “allowed on current data” from “allowed on data N seconds old”; and the lifetime is the bound, since no early-drop signal exists for a cache the write path cannot see. A caller that could ask to be served stale could extend its own revocation window on demand, so the opt-in is a property of the service’s deployment configuration, never of a request.

Two shapes stay refused whatever the freshness mechanism. The pushed “entitlement snapshot” named above stays refused here too, for the same frozen-roles reason. So do source-versioned freshness tokens — sealing the counters of every source a decision consulted into an opaque token, so revalidation is a comparison rather than a traversal: no single driver owns all the inputs, the counter bump becomes a correctness invariant in four write paths with nothing in the type system to enforce it, it accelerates revocation but not propagation, and the counter table becomes the hottest row set in the deployment. A replica gives what that scheme wanted, a version and read-your-writes, without minting anything.

Request-scoped deduplication is the only memo

One inbound API call can evaluate policy many times — the collection-level decision plus one per item, per the re-check requirement below — and those evaluations frequently ask the same (subject, chain, target, operation) question. An in-memory memo scoped to the request and discarded with it removes the repeats, and has no freshness problem to solve because it lives and dies with a single request. It also gives a request one consistent view: a request asking the same question twice should not get two different answers because an assignment changed in between, which is why the data plane answers a batch against one version too.

This is the caller-side counterpart of ADR 0030, which solves the same problem inside Keystone. Nothing in oslo.policy provides it today, so it is part of the check-type work in §9.

Capacity is isolated per caller class, not merely rate-limited

Rate limiting caps what one caller can do to itself. It does nothing about the problem this API creates at scale: an unrelated integration’s traffic degrading queueing and latency for the core services sharing the same data plane. Nova’s checks are not optional — one that does not complete is a user request that fails — while a third-party reporting integration’s checks are sheddable, and the two must not compete for the same queue on equal terms.

So the data plane declares traffic classes with per-class concurrency limits and admission control, shedding the lower class first, with separate deployments as an option on top rather than as the mechanism. The isolation key costs nothing: the data plane already authenticates every caller by SVID on the internal interface, so the SPIFFE trust domain or path prefix is available without inventing an identity concept for it.

Two things about the cost model:

  • Accounting is in decisions, not requests. The API is deliberately batchable, so one request can carry a thousand (target, operations) pairs, and a per-request limit is bypassed by batching. ADR 0022’s rate limiting is request-count limiting keyed on IP or username with no notion of per-request cost and no concurrency control, so this is new work rather than an existing mechanism to configure.
  • The number to size is decisions per second, not replica count. The per-item re-check below amplifies one user action into a page’s worth of decisions, so a deployment guide that documents only replica placement (§8) has documented the smaller half of the capacity question.

Both depend on being able to see the traffic, which makes per-trust-domain attribution a prerequisite and not a later nicety: decisions per second, replica lag, refusal rate, denial rate and shed count broken down by caller class belong in the metrics (ADR 0031) that ship with the data plane. Quotas that cannot be measured cannot be set.

Collection reads and the per-item re-check

The security model requires list endpoints to re-enforce the per-item read policy against each record’s own identifiers (I8), and forbids replacing that with a permissive collection filter (I8a). A cross-project list under system-scope="all" therefore cannot be one decision: it is a collection-level decision plus one per-item decision per record. Three things keep that from being a per-record round trip:

  • Batch checks. The API takes a list of (target, operations) pairs in one call, so a page of results is one request, not one per row.
  • Grant-shaped short-circuit. When the collection-level decision was allowed through a system or domain grant that covers every project in the page, the per-item check is answered from that same grant rather than re-derived. This is a short-circuit in the evaluation, not a relaxation of I8: each item is still decided against its own owner, and an item owned outside the grant’s reach still has to be decided on its own and dropped if denied.
  • The request-scoped memo. Decisions within the batch are memoized per (subject, chain, target), so a page whose rows share few owners costs few distinct decisions, and the whole batch is answered against one version.

The consequence is that cross-project listing is more expensive under this model than a system-scoped token that services interpreted themselves, and deployments must size the check API for it.

Bootstrapping and Keystone’s own authorization

Keystone is itself a service with authorized APIs. It evaluates its own policies against its own assignment data in-process — it does not call /v4/authz/check over the network to authorize a call to itself — and /v4/authz/check is itself authorized by the caller’s SVID at the internal interface, not by a recursive check. The first grant in a new deployment comes from the existing bootstrap path (keystone-manage, operating on the store directly), exactly as today. In addition, Keystone exposes a local admin interface over a Unix domain socket: it carries the full API, is reachable only from the host, and authenticates the caller by SVID, so a high-security perimeter can drive administrative calls without a bearer token and with the same policy checks the network API applies.

Part 2. The data plane (vision §8.2 to §8.4)

Audience: storage and Raft maintainers, and implementers of keystone-dataplane. Skip if you only need the outage cost; see vision §8.5.

Vision §8.2: What the data plane is, concretely

In the Rust implementation the data plane is a second binary target in the keystone crate, keystone-dataplane, built from the same library. The two binaries are built in separate invocations, and the data-plane build never names the write-side router module, so the absence of a write path is a property a reviewer checks in the build rather than a flag they trust at runtime:

  • Routes. /v4/authz/check; JWKS, .well-known and the revocation feed; JWT validation against that JWKS; the catalog reads services and SDKs need on the request path. No token issuance, no login, no refresh, no attestation, no grant or lifecycle writes, no configuration, no SCIM. Legacy Fernet validation (GET /v3/auth/tokens, Mode B) stays on the IAM control plane deliberately: Fernet keys are a symmetric repository, so a replica able to validate a Fernet token is a replica able to mint one, and putting that repository in every failure domain would undo §8.4 before it starts. The legacy path therefore keeps the legacy availability, which is one more reason for a deployment adopting the data plane to finish the move to JWT.
  • Drivers. The same drivers as keystone, each pointed at a replica (§6.3 table). The SQL connection is to a standby, which the database makes read-only, and the database user has SELECT only, so a misconfiguration pointing at the primary still cannot write. Mutating provider methods are not compiled in; where a shared path could reach one — a hook, an audit side effect — the execution context carries a read-only marker that makes the call fail loudly.
  • Storage role. The replica joins the Raft cluster as a permanent learner under a distinct SPIFFE role (spiffe://<td>/keystone/storage/learner). The leader’s gRPC interceptor, which already parses the role segment and gates operator RPCs on it, rejects client_write, change_membership and every operator RPC from that role. A learner never promotes itself and cannot be promoted by mistake: the role decides, not the membership.
  • Configuration. A [dataplane] section with max_staleness, the bounded wait for min_version, and the peer replicas to seed from; the [distributed_storage] section gains role = "learner".
flowchart TB
    subgraph cp["IAM control plane: writes and authentication"]
        k1["keystone (voter)"]
        k2["keystone (voter)"]
        k3["keystone (voter)"]
        pg["PostgreSQL primary"]
        k1 --- pg
        k2 --- pg
        k3 --- pg
    end
    subgraph r1["Region A data plane (optional)"]
        d1["keystone-dataplane<br/>learner + PG standby"]
        d2["keystone-dataplane<br/>learner + PG standby"]
        d3["keystone-dataplane<br/>learner + PG standby"]
        s1["nova-api, neutron, ...<br/>oslo.policy: memo only, no cache"]
        s1 -->|"check, batched, version"| d1
        s1 -->|"failover"| d2
        s1 -->|"failover"| d3
    end
    subgraph r0["Small cloud"]
        s0["services"]
    end
    s0 -->|"check, directly"| cp
    cp -.->|"Raft log to learners,<br/>WAL streaming to standbys"| r1

Vision §8.3: What the storage and SQL layers have to gain first

The Raft storage exists, and the data plane leans on it, but five things are missing between “a learner” and “a replica that serves”, and they are the concrete content of the static-stability requirement:

  1. Local reads on learners, with the version. Today every read on a non-leader node forwards to the leader after a ReadIndex round trip, and reads locally only as a fallback when the leader is unreachable. A node in the learner role serves reads from its own state machine without ReadIndex and returns the applied index with every result. This needs an amendment to ADR 0016-v2 §3, and this document does not make one. That section puts group membership in Tier 2 precisely because a stale membership read can allow a member who was just removed, and the argument offered here — that a learner is a replica by definition, so reporting its lag and refusing past max_staleness is more honest than hiding the staleness behind a forward to the leader — is an argument for amending it, not a substitute for doing so. The tier rule stands as written for voters, where a read is a live administrative fact. local_reads_mode, which the storage documentation already describes, is where the learner behaviour would live.
  2. Lag as a first-class metric. Leader commit index, learner applied index and their difference, exported by both sides (ADR 0031), with the max_staleness refusal counted separately from denies.
  3. Seeding without the leader. Snapshot install is leader-driven today, so a replacement replica cannot cold-boot during an outage of the write path. Either the snapshot transfer is generalized to any peer that holds a newer snapshot, over SVID mTLS, or the documented procedure is keystone-manage storage restore from the latest backup followed by catch-up. The second exists and is enough to start; the first is what makes a replacement replica routine rather than a runbook.
  4. A learner SPIFFE role, as above: a small addition to the existing role parsing and interceptor.
  5. Version and lag on the SQL path. the SQL driver still has to report the standby’s replay position as version, export its lag against the primary as a metric, and the write path still has to return the commit LSN of a grant write so that min_version means anything on a SQL-backed domain. Small work, but the freshness contract is not met without it.

Vision §8.4: Keeping secrets away from replicas

A learner applies every log entry, and applying an encrypted entry needs the data encryption key, so a learner today decrypts everything the log holds: OAuth2 private signing keys, refresh-token families, API-key hashes and whatever else a Raft-backed driver stores. More replicas in more places means more copies of that material — the one place where the data plane widens the attack surface rather than narrowing it. Two designs close it, adoptable in order.

  • Tier-keyed encryption. ADR 0016-v2 already binds a sensitivity tier into every record’s authenticated data and derives purpose-specific sub-keys from the master key. The extension is a second key hierarchy for Tier 2 and Tier 3 payloads: the writer encrypts the payload with a tier key before proposing it, under the existing log and state encryption, and only voters hold that tier key. A learner applies the entry, stores the inner ciphertext, and answers a read of such a key with “not readable in this role”. The change is confined to the storage crate’s encrypt and decrypt paths and to KEK provisioning; learners still carry the ciphertext volume, and a tier-key rotation becomes a voter-side re-encryption sweep.
  • A second Raft group. Secret material — sessions and refresh families, API-key hashes, credential plaintexts, private signing keys — moves to a group the data plane is not a member of, while authorization state stays in the group it follows. The storage crate is already generic over keyspaces, so two storage instances with separate membership and paths are configuration plus a keyspace-to-group routing table. The cost is two clusters to operate and the rule that no write may span groups. It is the cleaner boundary, and the one that stops learners carrying secret ciphertext at all.

Tier-keyed encryption is enough for replicas inside the control-plane trust zone; a replica in a remote region, or on a host shared with other services, is the case for the second group. Which secrets belong in which tier is the storage ADR’s decision, not this document’s (§10, question 13).

A Public Key Management Service for OpenStack: Architecture

Status: Draft for community discussion

Date: 2026-09-13

This document specifies the architecture of a new project: a public Key Management Service (KMS) for OpenStack-based clouds. It is a companion to the authentication and authorization vision and assumes that vision’s building blocks — SPIFFE/SPIRE workload identity, scope-less tokens with a per-request target, a Keystone-backed authorization decision, OPA policy, CADF audit, handler-level rate limiting — are available, because the KMS is built on keystone-rs primitives and reuses its crates wherever the shape fits.

The service is deliberately not a rewrite of Barbican. Barbican is a secret store with a plugin backend; what operators of a public cloud need is a managed key management service with the operational profile of AWS KMS or GCP Cloud KMS: a hard multi-tenant blast-radius story, an envelope-encryption data path that survives the loss of any single backend, standard interfaces (KMIP, the OpenBao/Vault API) so that existing customer software works unmodified, and a clear separation between what a tenant may reach from the internet and what only the cloud’s own services may reach.

Under the hood the service does not implement its own cryptographic storage engine. It operates a fleet of OpenBao clusters, shards OpenStack tenants across them for failure isolation and scalability, and puts a Rust control and data plane in front of that fleet. The fleet is an implementation detail of the service, never a contract with the tenant — not even on the surface that speaks the OpenBao API, which the KMS implements itself rather than proxying (§5.6).

Every section states its own requirements and limits. Where more than one design is defensible the options are presented with a recommendation, because this is a document to argue about, not a finished decision record. Section 19 gives the phased plan; the first useful deliverable is narrow on purpose.


1. The vision in one page

A public cloud cannot ask its customers to trust a key management service that is a thin wrapper over a single database. It has to answer four questions concretely:

  1. Where does my key material live, and who can reach it? Answer: inside a region, inside one OpenBao cluster shard dedicated to a small set of tenants, behind a barrier key that is itself sealed by a central seal authority or an HSM. No operator path reads plaintext key material; no tenant network path reaches the storage layer at all.
  2. What happens when something breaks? Answer: the blast radius of a shard failure is the tenants on that shard, not the region. Envelope-encryption operations — the hot path, and the one that users notice — are served by a stateless crypto tier that keeps working for the lifetime of its cached wrapped keys even when the shard behind it is unavailable.
  3. How do I integrate what I already run? Answer: three interfaces. A native REST API, KMIP for storage arrays, databases and backup software, and an OpenBao/Vault-compatible surface (transit, kv v2, and the minimum of sys) so that software written against Vault works without change — most immediately, so that a customer’s own OpenBao can auto-unseal against the cloud’s KMS.
  4. How do my workloads authenticate? Answer: with SPIFFE. Customer workloads are assumed to have SPIRE support — either the cloud’s own SPIRE (VMs attested per ADR 0032, cluster nodes via the same path) or a customer-run SPIRE federated with it. Long-lived secrets in guest images are a legacy path, not the design centre.

The shape that follows from those answers:

LayerWhat it isReachable from
Edgekms-api: axum HTTP, native v1 REST, Vault-compatible surface, KMIP front endTenant networks, internet
Crypto tierkms-crypto: stateless envelope encryption, KEK cache, optional PKCS#11Edge and internal services only
Control planekms-control: key metadata, placement, lifecycle, rotation, fleet operationsInternal control network only
Backend fleetSharded OpenBao clusters (transit, kv), one barrier per shardCrypto tier and control plane only
Root of trustCentral seal OpenBao (“seal cluster”) and/or HSM partitionsFleet unseal path only

and one rule that runs through the whole design:

The tenant-facing plane never talks to the storage plane, and the storage plane never terminates a tenant connection. Every crossing is mediated by a service that holds a SPIFFE identity, is authorized per request, and is audited.


2. Scope, non-goals, and the relationship to what exists

2.1 In scope

  • Symmetric key management: create, rotate, enable/disable, schedule deletion, encrypt/decrypt of small payloads, envelope-encryption data-key generation.
  • Asymmetric key management: sign/verify, wrap/unwrap, public-key retrieval.
  • Secret storage (opaque blobs) for the Barbican and kv use cases.
  • Customer-managed keys (CMK) for OpenStack services: Cinder volume encryption, Glance image encryption, Swift/S3 object encryption, Manila, database and backup encryption.
  • Bring-your-own-key import and, where policy allows, export under wrap.
  • KMIP server for third-party clients (storage arrays, Oracle/MSSQL TDE, backup software, vSphere).
  • OpenBao/Vault API compatibility for transit and kv v2, sufficient for a customer OpenBao to auto-unseal and for Vault-aware software to work.
  • Regional HA, multi-region key policy, and disaster recovery.

2.2 Out of scope (at least initially)

  • Being a general-purpose secret manager with dynamic secrets engines (databases, PKI, cloud credentials). Those are what a customer’s own OpenBao is for; the cloud KMS exists partly to make running one safe.
  • Certificate authority services. A CA is a distinct product with distinct compliance; the KMS may hold the CA’s private key, and that is the boundary.
  • Replacing SPIRE’s own CA or Keystone’s signing keys. Those have their own bootstrap chain and adopting the KMS for them is a later, separate decision (§9.6).
  • Confidential computing attestation-bound release of keys. It is an authorization condition on top of this architecture rather than a change to it, and §2.6 states what the service would and would not own if it were added.

2.3 Relationship to Barbican

Barbican defines the OpenStack contract that Cinder, Glance, Nova, Octavia and Manila already speak (castellan). The KMS therefore ships a Barbican v1 compatibility surface (§5.8) so existing services keep working from day one, and offers the native API for everything Barbican cannot express (grants, envelope operations, KMIP, key policies, multi-region behaviour). Barbican’s own plugin model is not reused: a plugin inside Barbican’s process cannot give the blast-radius or network-isolation properties of §1.

2.4 Relationship to keystone-rs

The KMS is a separate project with its own repository, release cadence and database. It depends on Keystone for identity and authorization decisions, and it borrows Keystone’s engineering: axum handlers, SeaORM persistence, the OPA integration (ADR 0002), the security context (ADR 0017), handler-level rate limiting (ADR 0022), CADF audit (ADR 0023), Prometheus metrics (ADR 0031), pagination (ADR 0029) and the per-request cache (ADR 0030). Shared code is extracted into crates published from keystone-rs rather than vendored, so that a fix to the audit pipeline or the rate limiter benefits both. Keystone’s PKCS#11/TPM KEK work (doc/plans/0016-v2-pkcs11-tpm-kek.md) is the direct ancestor of the HSM integration in §10.

The dependency on Keystone is a runtime dependency with the same posture as the one in vision §8: authentication is validated offline, authorization is a cached live decision, and the KMS fails closed when no cached decision applies. Because a KMS outage is a data-availability outage for everything encrypted with it, §11.4 imposes tighter requirements on that cache than a compute API would need.

2.5 Relationship to OpenBao

OpenBao is a premise of this design, and a premise deserves an argument. Three answers to “under whose barrier does key material live” were considered:

OptionWhat it givesWhat it costs
A fleet of OpenBao clusters (recommended)Barrier, integrated Raft replication, namespaces, backup and restore, and a seal protocol with an operational history behind itOperating N Raft clusters per region is the single largest line item in the service’s cost, development and operational alike
HSM partitions as the custody backendThe strongest custody story there isFails on arithmetic: a partition holds thousands of key objects, not the 10⁵–10⁶ that a four-digit tenant count implies, at a throughput §10.2 already calls “much lower than software crypto”
No barrier product at all: wrapped blobs in the control-plane database, wrapping keys held through PKCS#11, all cryptography in kms-cryptoOne fewer distributed system; sharding becomes a database concern the keystone-rs patterns already handle; restore is an operation the operators already knowThe project owns the barrier — wrapping format, rekey, crypto-shredding, the compliance narrative — and, decisively, the HSM stops being optional

The decisive argument is that third option’s failure, and it is not the one that first suggests itself. Writing a barrier is expensive, but the sharper reason is this: §10 promises that an HSM “is never required for the service to function”, and §9’s seal cluster — itself an OpenBao with a fleet-unseal transit mount — is the software root of trust that makes the promise keepable. Remove OpenBao and a deployment must either make an HSM mandatory, pricing out every operator who does not already have one, or grow its own software seal, which is rebuilding the least forgiving part of OpenBao with none of its review history. OpenBao is what lets the HSM stay optional, and that is a better justification than not wanting to write a barrier.

The secondary argument is the compliance narrative of §20. “The key material is inside an OpenBao barrier” is a sentence an auditor already accepts; “the key material is inside our barrier” is one the project must prove from first principles, every time, for every scheme.

What OpenBao is not asked to be is the region’s cryptographic engine. §7 makes mode B the default, under which the hot path reaches a shard roughly once per key per cache lifetime, and §6.3 is explicit that transit’s non-exportability is precisely why the offload cannot be built on transit alone. The dependency is therefore deliberately narrow, and §8.6 writes down its exact surface so that it stays narrow.

2.6 Relationship to confidential computing

Two different things travel under the name “key broker”, and separating them is most of the analysis: one is functionality the service would sell, the other is a way the service could run.

What the end user wants. A tenant running a confidential VM or a confidential container wants a key released only to a workload whose measurement it recognises. The shape the ecosystem has settled on is two components: a key broker that runs an attestation handshake with the workload and hands back a resource, and an attestation service that appraises the evidence against reference values and hardware endorsements. The KMS is the right holder of the release decision — it already holds the key, it already evaluates a per-object policy in OPA (§13.3), and a broker able to release keys the KMS custodies without the KMS deciding would split exactly the authority §13 exists to consolidate. The KMS is the wrong owner of the appraisal: maintaining reference values, vendor endorsement chains and TCB levels across TEE generations is a distinct product with a distinct compliance lifecycle, which is the same reason §2.2 excludes certificate authority services.

The split that follows:

ConcernWhere it belongs
Holding the key and deciding whether to release itThe KMS, as a condition on a grant (§13.4)
Appraising TEE evidence and issuing a resultA verifier the operator runs, or SPIRE at SVID issuance
Reference values and appraisal policy for a tenant’s imageThe tenant, as a resource the KMS stores and passes to policy
Speaking the attestation handshake to a workloadA thin edge surface on its own hostname (§5.3), for clients that need one

What the service itself needs: nothing. Attestation is also a candidate root of trust for the KMS’s own processes — a seal cluster that releases a shard’s unseal key only to a node that has proved its measurement (§9.3), or a kms-crypto running inside a TEE so that the plaintext KEK cache of §7.3 sits in memory the host cannot read. Both are attractive and neither may become required: principle 6 makes the HSM optional, and a service that could not start without attested hardware would be that promise broken in the other direction. On the service’s own path, attestation is therefore an additional Tier-0 option ranked alongside TPM and HSM sealing, never a prerequisite. The distinction also decides when each may be considered: a tenant-facing broker is a feature that can wait for demand, whereas anything the service needs in order to start has to be settled before Phase 1.

Adding the broker is cheaper than it first looks, because each piece is already present in some other guise:

  • The release primitive exists. Fetching a brokered resource is a read of an opaque secret (§5.4); releasing a key is unwrap or data-key.
  • Wrapping the released material to a public key the caller has proved it holds is the BYOK import-token machinery of §5.4 run backwards, with the ephemeral public key taken from the evidence instead of minted by the KMS.
  • The AAD of §6.4 already carries caller_scope_digest; a digest of the appraised claims goes there with no format change. That buys a property worth having on its own — a DEK minted for an attested workload cannot be replayed into an unattested context, even by the same project.
  • Appraisal policy and KMS policy are both Rego, evaluated by the engine the project already runs.

What is not cheap is precisely what the recommendation above declines to own. Appraisal needs endorsement material fetched from a hardware vendor outside the region, which principle 7 does not forbid but does not welcome. A firmware update that moves a measurement is a fleet-wide availability event with a cause the KMS cannot see coming. And reference values describe the tenant’s image, so the tenant owns them — a tenant who gets them wrong loses access to its own data with nothing broken to repair, which is a support burden of a kind the rest of this design works hard to avoid.

Three consequences are carried into the rest of the document: an identity track that is not really a new one (§4.4), a condition on grants (§13.4), and a freshness rule the caches must not quietly undo (§7.3). None of them is a Phase 1–5 concern; §21, question 12 says what would make it one.


3. Principles

  1. Blast radius is a design parameter, not an outcome. Every tenant is assigned to a shard; the shard is the unit of failure, upgrade, capacity and incident scope. A shard is sized so that its loss is an incident, not a catastrophe.
  2. Plaintext key material exists in exactly two places: inside an OpenBao barrier (or HSM), and transiently in the memory of a crypto-tier process that is currently performing an operation. It is never written to the KMS’s own database, never logged, never returned by an API except where the tenant explicitly asked for a data key or an authorized export.
  3. The hot path must not be the fragile path. Envelope operations are orders of magnitude more frequent than key-management operations and are served by a tier that can be scaled and degraded independently of the backend fleet.
  4. Internal and external are different services that happen to share code. They bind different addresses, on different networks, with different authentication, different policy, different rate limits and different audit streams. A handler is reachable from exactly one of them unless a deliberate decision says otherwise.
  5. Standards where customers already stand. KMIP and the Vault API are not nice-to-haves; they are the reason an enterprise can adopt the service without rewriting software.
  6. The HSM is an option, never a prerequisite. A deployment without an HSM must be fully functional and honestly documented as to what it does and does not guarantee. A deployment with an HSM must not have to re-architect.
  7. No dependency on any external public KMS. The service is the root of trust for the cloud it serves. Everything it needs to bootstrap must be derivable from operator-held material, a local HSM, or its own seal cluster.
  8. Region is a hard boundary by default. Key material does not leave its region unless the key was explicitly created as a multi-region key and the tenant asked for it.
  9. Crypto-agility from the first line. Algorithms, key sizes and wrapping formats are versioned in the wire format and in storage, so that post-quantum wrapping (ML-KEM) and signing (ML-DSA) are additions, not migrations.
  10. Everything is recorded. Every administrative action and every fleet operation emits its own CADF event. Every key use is recorded too, but at data-plane volumes the unit of record is allowed to be an aggregate (§14): what may never be lost is the identity, the key version and the outcome of any failed, denied, key-material or management operation. A KMS that cannot prove what it did is not usable for compliance; a KMS that emits one event per object write is not affordable, and the design says which is which rather than pretending the tension does not exist.

4. Target architecture

4.1 Component overview

flowchart TB
    subgraph ext["External zone (tenant / internet)"]
        app["Tenant application<br/>SPIFFE SVID"]
        bao["Tenant OpenBao<br/>auto-unseal client"]
        kmipc["KMIP client<br/>storage array, TDE, backup"]
        cli["Human / CLI / Horizon"]
    end

    subgraph edge["Edge tier (kms-api)"]
        rest["v1 REST API"]
        vault["Vault-compatible surface<br/>transit, kv v2, sys subset"]
        kmip["KMIP front end<br/>TTLV over TLS"]
    end

    subgraph crypto["Crypto tier (kms-crypto)"]
        env["Envelope engine<br/>KEK cache"]
        pkcs["PKCS#11 client<br/>(optional)"]
    end

    subgraph ctl["Control plane (kms-control)"]
        meta["Key metadata, grants,<br/>lifecycle, rotation"]
        place["Placement and<br/>fleet orchestration"]
    end

    subgraph fleet["Backend fleet (per region)"]
        s1["Shard A<br/>OpenBao cluster"]
        s2["Shard B<br/>OpenBao cluster"]
        s3["Shard C<br/>OpenBao cluster"]
    end

    subgraph rot["Root of trust"]
        seal["Seal cluster<br/>central OpenBao"]
        hsm["HSM partitions<br/>(optional)"]
    end

    ks["Keystone<br/>authn / authz"]
    spire["SPIRE"]
    opa["OPA"]

    app --> rest
    bao --> vault
    kmipc --> kmip
    cli --> rest
    edge --> crypto
    edge --> ctl
    crypto --> fleet
    crypto -.-> pkcs
    pkcs -.-> hsm
    ctl --> fleet
    ctl --> place
    fleet -->|"unseal"| seal
    fleet -.->|"unseal (alt)"| hsm
    seal -.->|"unseal"| hsm
    edge -->|"check(sub, op, target)"| ks
    edge --> opa
    spire -.->|"SVIDs"| edge
    spire -.->|"SVIDs"| crypto
    spire -.->|"SVIDs"| ctl
    spire -.->|"SVIDs"| app

4.2 The five processes

ProcessRoleNetworkScaling
kms-apiTerminates tenant TLS, authn/authz, rate limits, translates every protocol into one internal call setEdge + internalStateless, horizontal, per-region
kms-cryptoPerforms envelope and transit operations, holds the KEK cache, drives PKCS#11Internal onlyStateless, horizontal, cache-warm affinity
kms-controlOwns key metadata, lifecycle state machine, rotation schedules, grants, placement decisions, custody metering snapshots (§15.2)Internal onlyLeader-elected for schedulers, HA for reads
kms-fleetOperates the OpenBao clusters: provisioning, unseal, upgrade, rekey, health, migrationManagement onlySingleton per region with standby
kms-manageOperator CLI: bootstrap, shard operations, DR drills, key recovery ceremoniesManagement onlyInvoked, not served

One piece of shared infrastructure is load-bearing enough to name here rather than leave implied: the invalidation bus, the path by which kms-control tells every kms-crypto replica that a key has been disabled, a grant revoked or a deletion scheduled (§7.3). It is what makes those operations take effect in seconds rather than at the end of a cache TTL, so it is on the critical path for a security property, not for throughput. It is regional, it carries no key material (only key identifiers and a monotonic epoch), and its failure mode is explicit: a kms-crypto replica that loses the bus for longer than one TTL stops admitting new cache entries and drains, rather than serving from a cache it can no longer trust. Implementation options — a message broker, a Raft watch on the control plane, or direct fan-out over the internal mTLS mesh with kms-control holding the replica list — trade operational surface against delivery guarantees; direct fan-out is the recommendation, because the replica count is small, the fencing decision has to be synchronous anyway, and it adds no new system to the trust boundary.

kms-api and kms-crypto are separate processes even in a small deployment. That split is what makes the network isolation of §12 expressible, and it is what lets the crypto tier be given a different hardening profile (no inbound tenant traffic, memory locking, core dumps disabled, optional HSM session ownership).

4.3 Deployment topology

Per region:

  • ≥3 kms-api replicas behind the public and internal load balancers, in distinct availability zones.
  • ≥3 kms-crypto replicas, AZ-distributed, no tenant-facing address.
  • ≥2 kms-control replicas; schedulers use a lease.
  • N shards; each shard is an OpenBao cluster of 3 or 5 nodes spread across AZs, with integrated (Raft) storage.
  • One seal cluster: a small, dedicated OpenBao cluster (3 or 5 nodes) used only as a transit auto-unseal authority for the shards (§9).
  • Optional HSM partitions, at least two, in different failure domains.

Per cloud (across regions): the control plane’s metadata is regional. There is no global write path. Cross-region concerns — multi-region keys, global key identifiers, replication policy — are handled by explicit, auditable replication, never by a globally shared database (§11).

4.4 Identity tracks

CallerAuthenticates withSeen by the KMS as
Tenant workload on the cloudX.509 SVID (mTLS) or JWT-SVID from cloud SPIREPrincipal mapped from spiffe.project_id, per ADR 0008/0020
Tenant workload off the cloudJWT-SVID from a federated SPIRE trust domainPrincipal mapped from the federated trust domain’s rules
Tenant OpenBao (auto-unseal)SVID (preferred) or a scoped, rotatable app credentialPrincipal bound to one transit key
Human / CLI / HorizonKeystone-issued JWT (passkey or OIDC behind it)User principal, amr available to policy
Confidential-computing workloadSVID whose issuance appraised TEE evidence, or an attestation result token (§2.6)Principal as above, plus appraised claims in policy input
KMIP clientClient certificate: SVID, or a KMS-registered tenant CAPrincipal from SPIFFE ID or certificate mapping
OpenStack service (Cinder, Glance…)X.509 SVID at the internal interface, plus the user’s JWT or an on-behalf-of exchangeService principal and the user it acts for
KMS’s own tiersX.509 SVIDPeer identity; no bearer token anywhere internally

The last two rows are the important ones. When Cinder attaches an encrypted volume it is acting for a user; per ADR 0036 the delegation is a fact of the authentication chain, and the KMS policy keys on input.credentials.delegated_project_id, never on a scope the caller supplied. A service that has an SVID but no delegation may not unwrap a tenant’s key; this is precisely the invariant the security model already states, applied to the one service where violating it is catastrophic. The confidential-computing row is deliberately not a new mechanism. The appraisal happens before the KMS sees the caller — at SVID issuance, or in a verifier whose signed result the caller presents — so what reaches policy is the principal the KMS already understands, with claims attached (§2.6).


5. API surfaces and the external/internal split

5.1 Zones

Four zones, with distinct listeners, certificates, policy bundles and audit streams. “Zone” here is an architectural term; §12 maps it onto networks.

ZoneListenerPeer authenticationTypical caller
Publickms-api :443Bearer JWT, or mTLS with a tenant SVIDTenant apps, tenant OpenBao, Horizon
Tenant-KMIPkms-api :5696mTLS only (client certificate)Storage arrays, TDE, backup software
Internalkms-api :8443SPIFFE mTLS, plus delegated user identityCinder, Glance, Nova, Swift, Octavia
Managementkms-control :9443SPIFFE mTLS, operator role requiredkms-manage, fleet automation, CI

The same handler is never mounted in more than one zone by accident: the router is built per zone from an explicit list, and a test asserts that the public router does not contain any handler tagged internal or management. That test is the enforcement mechanism; a code review is not.

5.2 What is public and what is not

CapabilityPublicTenant-KMIPInternalManagement
Create / list / describe keys✓✓✓✓
Encrypt / decrypt / sign / verify✓✓✓–
Generate data key (envelope)✓✓✓–
Rotate, enable/disable, schedule deletion✓–✓✓
Import key material (BYOK)✓––✓
Export key material under wrap✓¹✓¹–✓
Grants on a key✓––✓
Vault transit compatibility✓–✓–
Vault kv v2 compatibility✓–––
Barbican v1 compatibility✓–✓–
Auto-unseal for a tenant’s OpenBao✓–––
Auto-unseal for a fleet shard––––²
Shard placement, migration, rekey, seal rotation–––✓
Cross-tenant listing, fleet health, key inventory–––✓
Direct OpenBao access of any kind–––✓³

¹ Only for keys created with exportable = true, only under an approved wrapping key, and only when policy allows; the flag is immutable after creation and is recorded in every audit event for that key.

² The fleet unseal path is not an API the KMS serves in any zone — it is a shard node speaking to the seal cluster over the seal network (§9), and the row exists to say so. What the management zone offers is its configuration (which shard has which unseal key, and rotating them), which is the “seal rotation” row below.

³ Break-glass only, through kms-manage, with quorum approval and an audit record, along the lines of ADR 0028.

5.3 Three surfaces, three base paths

The native API, the Vault-compatible surface and the Barbican-compatible surface each want to own /v1/, and two of them want /v1/secret(s). That collision has to be resolved in the routing layer before any of the three is written, and the resolution is visible to clients, so it belongs here rather than in an implementation note.

OptionNativeVault-compatibleBarbican-compatibleCost
Distinct hostnames (recommended)kms.<region>…/v1/vault.kms.<region>…/v1/barbican.<region>…/v1/Three certificates and three service-catalog entries; every client works unmodified
Distinct base paths on one host/v1//vault/v1//barbican/v1/Free, but relies on each client preserving a base path in its configured address
One namespace with disambiguating prefixes/v1/keys/v1/transit—Cheapest, and unworkable: Barbican’s /v1/secrets and the native /v1/secrets cannot both exist

Distinct hostnames are recommended. They keep every compatibility surface byte-identical to the API it imitates, they let the KMIP and Vault surfaces be withdrawn from a deployment without touching the native one, and they give the network layer (§12) a natural place to apply different exposure per surface. Distinct base paths are a workable fallback where certificate management is the binding constraint — the Vault client preserves the path component of its configured address, so address = "https://kms.<region>.<cloud>/vault" reaches /vault/v1/transit/… — but it has to be verified per client rather than assumed, and KMIP has no equivalent (it is a separate port regardless).

The catalog entries are distinct service types (key-manager for the Barbican surface, so that castellan finds it unchanged), which also means an operator can decline to register a surface they do not want to offer.

5.4 Native v1 REST API

Resource shapes, with the OpenStack conventions of the v4 Keystone API (ADR 0004) — cursor pagination (ADR 0029), problem+json errors, OpenStack-Target for scope where the target is not implied by the addressed resource.

POST   /v1/keys                       create a key
GET    /v1/keys                       list keys in the target project
GET    /v1/keys/{key_id}              describe (metadata only, never material)
PATCH  /v1/keys/{key_id}              enable, disable, rename, retag
DELETE /v1/keys/{key_id}              schedule deletion (waiting period)
POST   /v1/keys/{key_id}/cancel-deletion
POST   /v1/keys/{key_id}/rotate       new version, old versions kept
GET    /v1/keys/{key_id}/versions
POST   /v1/keys/{key_id}/import       BYOK, material wrapped to an import token
GET    /v1/keys/{key_id}/import-token get wrapping key + nonce for BYOK
GET    /v1/keys/{key_id}/public       public key (asymmetric only)

POST   /v1/keys/{key_id}/encrypt      small payload, <= 8 KiB
POST   /v1/keys/{key_id}/decrypt
POST   /v1/keys/{key_id}/rewrap       re-encrypt ciphertext to latest version
POST   /v1/keys/{key_id}/sign
POST   /v1/keys/{key_id}/verify
POST   /v1/keys/{key_id}/data-key     envelope: plaintext + wrapped DEK
POST   /v1/keys/{key_id}/data-key/wrapped-only
POST   /v1/keys/{key_id}/unwrap       wrapped DEK -> plaintext DEK
POST   /v1/keys/{key_id}/hmac
POST   /v1/random                     randomness from the region's CSPRNG

POST   /v1/keys/{key_id}/grants       delegate specific operations
GET    /v1/keys/{key_id}/grants
DELETE /v1/keys/{key_id}/grants/{grant_id}

POST   /v1/secrets                    opaque secret store (Barbican-shaped)
GET    /v1/secrets/{secret_id}

GET    /v1/quotas                     per-project limits and usage
GET    /v1/usage                      current billing period, per key and class
GET    /v1/regions                    regions this deployment can replicate to
GET    /v1/keys/{key_id}/regions      where this key exists today

Batch variants of encrypt, decrypt and data-key exist from the start. A storage service encrypting a thousand objects should make one request, and the per-item results carry per-item errors. Batching is the single most effective lever on backend load (§7).

5.5 KMIP

KMIP is how the enterprise storage world talks to a KMS, and shipping it is a large part of why this service exists rather than a Barbican plugin.

  • Protocol versions: KMIP 1.4 and 2.1, TTLV encoding over TLS. JSON and XML encodings are deferred; almost nothing in the field uses them.
  • Profiles: target the KMIP 2.1 Baseline Server profile plus Symmetric Key Lifecycle, Basic Cryptographic and Opaque Managed Object server conformance, and their 1.4-era equivalents where the profile names differ. Between them these cover the common clients (VMware, NetApp, Dell, Oracle TDE, Veeam, Commvault) — but “covers” is a claim to be proven by the interoperability matrix of Phase 5, not by reading the specification.
  • Operations: Create, CreateKeyPair, Register, Get, GetAttributes, GetAttributeList, AddAttribute, ModifyAttribute, DeleteAttribute, Activate, Revoke, Destroy, Locate, Check, Query, DiscoverVersions, Encrypt, Decrypt, Sign, SignatureVerify, MAC, RNG Retrieve. Get with a KeyWrappingSpecification is how KMIP clients do envelope encryption, and the KMS maps it onto the same data-key path as §5.4.
  • Identity: mTLS client certificate only. Two mappings are supported: a SPIFFE ID in the SAN (preferred; the client is a workload with an SVID), or a certificate issued by a tenant-registered CA with a subject pattern the tenant mapped to a principal. The second exists because a storage array cannot run a SPIRE agent. Certificate-to-principal mapping reuses the mapping engine (ADR 0020) — the same rule language that maps an OIDC assertion maps a certificate’s subject.
  • Tenancy: a KMIP connection is bound to exactly one project, derived from the client identity at handshake time. Locate never crosses that boundary. There is no KMIP operation that can name another tenant’s object, and object identifiers are opaque per-tenant handles, not global key IDs.

Implementation options.

OptionProsConsVerdict
A. Own TTLV codec crate (kmip-ttlv) + server in kms-apiNo unmaintained dependency; full control over conformance and fuzzingReal work: TTLV, the attribute model, profile conformance testingRecommended
B. Wrap an existing Rust KMIP crateFaster startEcosystem is thin and the crates are partial; conformance is on us anywayEvaluate, fall back to A
C. Front a third-party KMIP server (e.g. PyKMIP) as a sidecarFastest to a demoAnother runtime, another auth model, no path to the internal call setPrototype only

Option A with the codec in a separate crate, no_std-friendly and fuzzed (the repository already runs cargo-fuzz), is the recommendation. TTLV parsing is a hostile-input surface reached over the network and deserves that treatment.

5.6 The OpenBao/Vault-compatible surface

A deliberate, bounded subset — presented as an API the KMS implements, not as a transparent pass-through to a backend cluster. Requests are parsed, authorized, rate-limited, audited, and then executed against the shard. The tenant never holds an OpenBao token for a fleet cluster.

Vault pathSupportedNotes
POST /v1/transit/encrypt/{name}✓Maps to key encrypt; batch input supported
POST /v1/transit/decrypt/{name}✓The auto-unseal path
POST /v1/transit/rewrap/{name}✓
POST /v1/transit/datakey/{type}/{name}✓Maps to §5.4 data-key
GET /v1/transit/keys/{name}✓Metadata only
POST /v1/transit/keys/{name}/rotate✓
POST /v1/transit/sign|verify|hmac✓
GET|POST|DELETE /v1/secret/data/{p}✓kv v2, versioned
GET /v1/sys/health, /sys/seal-status✓Synthesized; describes the KMS, never a backend cluster
POST /v1/auth/cert/login, /auth/jwt/login✓Issues a KMS-scoped token that looks like a Vault token
sys/mounts, sys/policy, dynamic engines–Not a Vault; use the native API

The auto-unseal use case needs only four of these (transit/encrypt, transit/decrypt, auth/*/login, sys/health), which is why it is Phase 1 (§19). A customer configures their own OpenBao with:

seal "transit" {
  address         = "https://kms.<region>.<cloud>/"
  mount_path      = "transit/"
  key_name        = "unseal-prod"
  tls_ca_cert     = "/etc/ssl/cloud-ca.pem"
  tls_client_cert = "/run/spire/certs/svid.pem"
  tls_client_key  = "/run/spire/certs/svid.key"
}

and never handles an unseal key again. Because customer workloads are assumed to have SPIRE support, the client certificate is an SVID and the credential is rotated by the SPIRE agent; no secret is written into the customer’s config management.

One detail decides whether that last sentence is true. The upstream transit seal stanza carries a token field and treats it as required, because it was written against a Vault that authenticates with tokens rather than with client certificates. The KMS is not a Vault: it authenticates the mTLS peer, and the token is advisory. Three ways to close the gap, in preference order:

  1. Accept the SVID and ignore the token. The seal stanza carries a placeholder; authorization comes entirely from the client certificate. This is the recommended path and is why tls_client_cert is the load-bearing line in the configuration above.
  2. Issue a token to a certificate. A short-lived, single-key token from auth/cert/login, renewed by a sidecar that writes it where the seal stanza reads it. Needed if the client refuses to start without a syntactically valid token.
  3. A Keystone application credential scoped to exactly one transit key, for a customer whose OpenBao predates their SPIRE rollout.

The KMS records which of the three was used on every unseal, and an operator can require (1) per project so that a fallback cannot be silently adopted.

Design decision — synthesize, do not proxy. A transparent reverse proxy to a shard would be less code and is tempting. It is rejected: it leaks backend identity and version, makes the tenant’s blast radius the cluster’s blast radius, defeats per-request authorization against Keystone, and makes shard migration a customer-visible event. The compatibility surface is an implementation of the Vault wire contract, and that contract is version-pinned and covered by contract tests against a real OpenBao client.

5.7 Internal service API

The internal zone carries what OpenStack services need and tenants must not reach:

  • Envelope operations at scale, batched, with service-level rate limits and a distinct quota pool, so that a Cinder mass-attach cannot starve tenant API traffic.
  • The delegated-unwrap path: “unwrap this DEK for volume V of project P, on behalf of user U”, authorized against the user’s grant on the key, with the acting service recorded (act).
  • Key-usage attestation: a service can ask “is key K usable right now for project P”, which is what a scheduler needs before it places a workload that will require the key.
  • Bulk re-wrap notification: when a tenant rotates a CMK, services that hold ciphertext under the old version subscribe to an event stream and re-wrap lazily.

5.8 Barbican compatibility

A Barbican-shaped surface (secrets, containers, orders, ACLs, consumers) on its own hostname or base path per §5.3, sufficient for castellan and the existing OpenStack integrations. It is a translation layer over the native model: a Barbican “secret” is a KMS secret or a key, a Barbican ACL becomes a KMS grant, an “order” for a symmetric key becomes a key creation. Not everything maps — Barbican’s certificate orders do not, and return 501. Compatibility is scoped by an explicit conformance test suite run against castellan’s own tests, not by best effort.


6. Key hierarchy and cryptographic model

6.1 Hierarchy

flowchart TD
    hsm["HSM root key<br/>(optional, non-exportable)"]
    sealkey["Seal-cluster barrier key"]
    sealtransit["Per-shard unseal key<br/>(transit key in seal cluster)"]
    barrier["Shard barrier key<br/>(OpenBao master key)"]
    cmk["Customer master key (CMK)<br/>versioned, per project"]
    dek["Data encryption key (DEK)<br/>ephemeral, per object/volume"]
    data["Tenant data"]

    hsm -->|"seals"| sealkey
    sealkey --> sealtransit
    sealtransit -->|"wraps"| barrier
    barrier -->|"protects storage of"| cmk
    cmk -->|"wraps"| dek
    dek -->|"encrypts"| data

Without an HSM the top edge changes and nothing below it does: the seal cluster is sealed by operator-held Shamir shares, or by a TPM-backed key on each seal node (the mechanism Keystone’s PKCS#11/TPM KEK plan already describes). This is the concrete meaning of “the HSM is optional”.

6.2 Key states

A key moves through a state machine modelled on NIST SP 800-57 and on KMIP’s object lifecycle. The names below are the KMS’s own; the KMIP surface maps them onto the states its clients expect (enabled to Active, disabled and pending_deletion to Deactivated, deleted to Destroyed, compromised to Compromised), and that mapping is part of the KMIP conformance suite rather than an assumption:

StateEncryptDecryptSignVerifyNotes
pending_import––––Created, awaiting BYOK material
enabled✓✓✓✓Normal
disabled––––Tenant-initiated pause; metadata intact
pending_deletion––––Waiting period, 7–30 days, tenant-chosen
deleted––––Material destroyed; metadata tombstone kept
unavailable––––Shard down, HSM partition unreachable, or a representation missing (§6.3)
compromised–✓¹–✓¹Operator/tenant declared; decrypt for recovery only

¹ Only under an explicit recovery grant, and every use is a high-severity audit event.

Key versions are independent of key state: rotation adds a version, the previous versions stay decrypt-capable until the tenant explicitly retires them, and min_decryption_version (the same concept OpenBao’s transit engine uses) lets a tenant close off old ciphertext deliberately.

The waiting period on deletion is non-negotiable and non-shortenable by any API. Immediate destruction is a kms-manage operation with quorum approval, because “delete the key” is the one KMS operation that destroys customer data irreversibly and is therefore the prime target of a compromised tenant account.

6.3 How a CMK is stored, and how it is used

OpenBao’s transit engine deliberately does not let a key leave the barrier — that is its whole value, and it is also why the offload of §7 cannot be built on transit alone. The design therefore stores a CMK twice over, and the choice of which representation is authoritative is per key:

RepresentationWhereUsed for
A transit key in the tenant’s mountShard, inside the barrier, never exportableOffload mode A: every operation executes in OpenBao
A wrapped CMK blob in the tenant’s kvShard, inside the barrier; the blob is encrypted under a per-shard transit wrapping keyOffload modes B and C: kms-crypto fetches the blob and calls transit/decrypt once per cache fill, then serves DEKs locally

A key created in mode A has only the first representation and can never be promoted to mode B without a rotation, which is what makes “this key never leaves the barrier” a statement the KMS can keep. A key in mode B has both: the transit key remains the authority for rotation and for the audit trail, and the wrapped blob is the artifact the crypto tier consumes. Mode D replaces both with an HSM object handle (§10).

Which of the two is authoritative has to be answered explicitly, because both obvious answers are wrong. The transit key is not the authority: it cannot produce the blob, since transit does not export, which is the premise this whole subsection rests on. The blob is not the authority either: it knows nothing of versions the tenant has retired. The authority is the key’s metadata row in the control plane (§16). The row records which versions exist and which representations each version is supposed to have, and a version does not become enabled until the row says every representation it needs has landed. Creating or rotating a mode-B key is therefore one operation staged through that row, not two independent writes to the shard.

Divergence is then repaired toward the row, and in one direction only:

  • A representation the row does not know about is the debris of a failed rotation, and is destroyed.
  • A representation the row expects but cannot find puts the version in unavailable (§6.2). That is an incident, not a self-healing condition: a missing blob cannot be regenerated from transit, so the only repair is a restore from the shard’s backup.

The custody snapshot of §15.2 already walks this inventory every hour, which makes it the right place to detect a discrepancy — rather than discovering it on the next cache fill, in front of a tenant.

This is the mechanism the rest of the document assumes; it is spelled out here because “cache the KEK” is otherwise an instruction that cannot be carried out against transit.

6.4 Envelope encryption

The contract, identical in the native API, the Vault surface, and KMIP:

sequenceDiagram
    participant C as Client
    participant A as kms-api
    participant X as kms-crypto
    participant S as Shard (OpenBao)

    C->>A: POST /v1/keys/K/data-key {spec: AES_256, aad}
    A->>A: authn (SVID/JWT), authz (Keystone check, cached), rate limit
    A->>X: generate_data_key(key=K, ctx)
    alt KEK cached and warm
        X->>X: derive DEK from the cached KEK
    else cold
        X->>S: kv read of the wrapped CMK blob for K@v
        S-->>X: wrapped CMK
        X->>S: transit/decrypt with the shard wrapping key
        S-->>X: plaintext KEK
        X->>X: admit to cache (TTL, use count), derive DEK
    end
    X-->>A: {plaintext_dek, wrapped_dek, key_version}
    A-->>C: 200 {plaintext, ciphertext_blob}
    Note over C: Client encrypts data with plaintext DEK,<br/>discards it, stores ciphertext_blob alongside data

Three properties that matter:

  • The AAD is mandatory and structured. Every wrap binds (project_id, key_id, key_version, purpose, caller_scope_digest) as additional authenticated data. A wrapped DEK for project A’s volume cannot be unwrapped in project B’s context even if the blob leaks, and a DEK minted for volume encryption cannot be replayed into an object-storage context.
  • The ciphertext blob is self-describing and versioned: a header with format version, algorithm, key ID, key version, region, and the wrapping mode. Crypto-agility lives here.
  • Nothing about the shard appears in the blob. Shard identity is metadata the control plane owns, so that migrating a tenant between shards does not invalidate a single stored ciphertext.

6.5 Algorithms

PurposeDefaultAlso offered
Symmetric AEADAES-256-GCMChaCha20-Poly1305, AES-256-GCM-SIV
Key wrappingAES-256-GCM (KWP for KMIP/HSM interop)RSA-OAEP-SHA256, ECDH-ES+A256KW
SigningECDSA P-256 / P-384Ed25519, RSA-PSS 2048/3072/4096
MACHMAC-SHA-256HMAC-SHA-384/512, KMAC
KDFHKDF-SHA-256—
Post-quantum wrap— (reserved)ML-KEM-768 hybrid, when the fleet supports it
Post-quantum sign— (reserved)ML-DSA-65

The KMS never invents cryptography. Symmetric and AEAD primitives come from vetted crates (aws-lc-rs or ring, with a FIPS-validated module where the deployment needs it); asymmetric operations are either delegated to OpenBao’s transit engine or to the HSM. Which of those three performs a given operation is a deployment decision recorded per key, not a code path the tenant can select.


7. Offloading envelope encryption from the OpenBao fleet

This is the part of the design that decides whether the service scales.

7.1 The problem

A naive implementation forwards every encrypt/decrypt/data-key call to the tenant’s shard. Then:

  • Every object write in an encrypted Swift container, every Cinder attach, every TDE page-key fetch, every customer application call is an OpenBao request. OpenBao’s transit engine is fast, but it is also the thing that must stay up for key management, and it is backed by Raft — read-heavy load and leader elections do not mix well under stress.
  • The shard becomes both the availability bottleneck and the latency floor for the entire region’s data path.
  • Scaling means more shards, which is the expensive axis.

7.2 The options

OptionBackend loadSecurity postureComplexity
A. Pass-through. Every operation hits the shard.1 backend call per operationBest: key material never leaves the barrierLowest
B. KEK cache. kms-crypto fetches the wrapped CMK blob (§6.3), unwraps it with one transit/decrypt, and keeps the plaintext KEK in memory for a bounded TTL; DEK derivation happens in kms-crypto.~1 backend call per KEK per TTLPlaintext KEK exists in kms-crypto memory for the TTLMedium
C. Derived-DEK mode. The shard issues a per-(project, purpose, epoch) derivation key; kms-crypto derives DEKs with HKDF and never needs the CMK.~1 backend call per epochCompromise of kms-crypto exposes one purpose/epoch, not the CMKMedium-high
D. HSM-side offload. DEK generation happens in an HSM partition via PKCS#11.0 backend calls on the hot pathStrongest; bounded by HSM throughput and costHigh

Recommendation: B as the default, C for high-volume service integrations, D where an HSM is present and the tenant pays for it, A always available as a per-key opt-out. The choice is a property of the key (offload_mode), visible in the key’s metadata, chosen at creation, and changeable only through a rotation. A tenant with a compliance requirement that key material never leave the barrier selects A and accepts the latency.

7.3 How the cache is made safe

The cache holds plaintext key-encryption keys — calling it anything softer would be dishonest, and it is the single most security-sensitive component in the design. Its rules are therefore explicit:

  1. Bounded lifetime. A cache entry has a hard TTL (default 300 s) and a hard use count. Both are per-key configurable downward, never upward.
  2. Memory hygiene. Plaintext key material lives in a zeroizing, mlock-ed allocation. The process disables core dumps and ptrace attachment, and runs with swap disabled or encrypted.
  3. No persistence. The cache is never written to disk, never included in a heap profile, and does not survive a restart. A kms-crypto restart is a cache flush and a self-healing action.
  4. Revocation is push, not poll. Disabling a key, revoking a grant, scheduling deletion, or a tenant’s “panic” call publishes an invalidation to every kms-crypto replica over the internal event bus, and the operation does not return success until every live replica has acknowledged or been fenced. This is what makes “disable my key” mean something within seconds rather than within a TTL.
  5. Per-key opt-out is honoured end to end. A key in mode A is never admitted to the cache; the admission check happens in one function, and that function is unit-tested against every mode.
  6. Isolation by shard. Cache partitions are keyed by shard, so that a compromised or misbehaving shard’s entries can be evicted wholesale, and so that cache capacity planning follows the shard model.
  7. An attestation is never cached by proxy. The cache holds key material, keyed by key and shard, and never an authorization decision. That separation is what lets attestation-bound release (§2.6) cost nothing on the hot path, and it is also what forbids the shortcut: a cached authorization decision may not outlive the attestation result it was based on, and an attestation-bound operation is re-decided when that result expires, whatever the KEK entry’s TTL says.

The degraded-mode consequence is deliberate and is the answer to §1’s second question: if a shard becomes unavailable, kms-crypto keeps serving envelope operations for keys whose entries are warm, for the remainder of their TTL, while key management fails. A storage system stays readable through a short backend incident. This is a considered trade — a longer TTL buys availability and costs revocation latency — and it is an operator-tunable number with a documented meaning, not a hidden default.

7.4 Reducing load further

  • Batch everything. One request, N operations, one authorization decision, one audit event with N sub-records.
  • Per-request cache (ADR 0030) for authorization decisions and key metadata within a batch.
  • Cache ciphertext-to-version mapping so that rewrap storms after a rotation are spread over time rather than stampeding.
  • Admission control before the backend, not after. Rate limiting (ADR 0022) is evaluated in kms-api handlers; a rejected request never reaches the crypto tier, let alone the shard.

8. The OpenBao fleet: multitenancy, sharding, placement

8.1 Why a fleet

One cluster per region is simpler and unacceptable: it makes every tenant’s availability depend on every other tenant’s load, makes upgrades a region-wide risk, makes a data-corruption incident unbounded, and caps scalability at one Raft group’s write throughput.

8.2 The shard model

A shard is one OpenBao cluster (3 or 5 nodes, integrated Raft storage, one barrier key, one seal configuration) plus its metadata in the control plane. A shard hosts M tenants, where M is an operational parameter (initially in the low hundreds) chosen so that:

  • the shard’s Raft state stays comfortably within memory and snapshot budgets;
  • restoring the shard from backup is an operation measured in minutes;
  • the customer-visible blast radius of losing it is acceptable to the cloud’s own SLA and communication plan.

Each tenant gets its own OpenBao namespace (or, where namespaces are not used, a strictly separated mount and policy set) inside its shard. Within the namespace: one transit mount for keys, one kv v2 mount for secrets. Policy inside the shard is generated by the control plane; no human writes it.

8.3 Placement

OptionDescriptionTrade-off
Hash placementshard = H(project_id) mod NTrivial, but rebalancing on N change moves everyone
Consistent hashing with virtual nodesRendezvous or ring hashingRebalances a fraction; still ignores tenant weight
Directory placement (recommended)The control plane stores an explicit project -> shard mapping and chooses at first useAny policy: weight, tier, locality, isolation requests; migration is a row change plus data movement

Directory placement is recommended because the interesting requirements are not expressible as a hash: “this tenant is regulated and gets a dedicated shard”, “this tenant is large and must not share with another large one”, “this tenant is in the free tier and goes on the dense shards”, “drain shard C for decommissioning”. The directory is small, changes rarely, and is cached aggressively — but it is on the path of every key operation that misses the crypto tier’s cache, alongside the key’s metadata and the authorization decision, so it is among the first things to be made highly available and the first to be made cheap to read.

Placement classes, as an initial vocabulary: standard (dense, shared), dedicated (one tenant per shard), regulated (dedicated + HSM-backed root + restricted operator access), internal (the cloud’s own services, never shared with tenants).

Placement class also decides the shard type, and that is what makes the custody backend pluggable without inventing a second mechanism. standard, dedicated and internal place a tenant on an OpenBao shard; regulated may place one on a shard whose custody is an HSM partition plus its control-plane metadata (§10.1). The directory already resolves project -> shard, so routing to a different kind of shard is a property of the row rather than a new abstraction, and migration (§8.4) is already the procedure for moving between shards. Shard type constrains what may be created on it — an HSM shard holds key counts in the thousands and supports only hsm-resident keys in modes A or D — and kms-control rejects an impossible request at creation, in the same validation that already enforces the (protection_level, offload_mode) pairing of §10.3.

8.4 Migration between shards

Migration must exist from the start, or the fleet ossifies. The procedure:

  1. Control plane marks the tenant migrating, freezes key-management operations (data-plane operations continue).
  2. Existing key versions are moved by kms-crypto: for a mode-B key it reads the wrapped blob, unwraps it under the source shard’s wrapping key and re-wraps it under the destination’s, in memory, never writing plaintext anywhere in between. A mode-A key has no exportable representation by construction, so it is migrated by re-creating the transit key at the destination from the same wrapped blob held for this purpose, or — where the tenant chose mode A precisely to forbid that — it is not migrated, and draining its shard requires the tenant’s participation. That asymmetry is the price of mode A and is documented to the tenant at key creation.
  3. Verification pass: every key version is read from the destination and compared by digest of a deterministic test vector, not by comparing key material.
  4. Directory flip, cache invalidation, source namespace sealed and later destroyed after a retention period.

Because no ciphertext blob names a shard (§6.4), no tenant data needs to be re-encrypted. That property is bought in §6.4 and spent here, and it is the reason for the constraint.

8.5 The fleet as a control loop

kms-fleet is a reconciler, not a script collection. Desired state — shard count, version, seal configuration, tenant assignment — is declarative; the reconciler creates clusters, initializes them, configures seals, applies policy, runs upgrades one node at a time with quorum checks, takes and verifies backups, and reports drift. Every action it takes is an audit event with the same schema as an API call, because “what did the fleet do at 3 a.m.” is an audit question.

8.6 The OpenBao contract, deliberately narrow

The service depends on a stated subset of OpenBao and nothing else. This table is the contract; anything outside it is a feature the KMS may use but must be able to lose:

Depended onUsed for
The barrier and its seal/unseal lifecycleThe custody guarantee of §3, principle 2
Integrated Raft storage, snapshots, restoreShard durability, backup, the restore-time budget of §8.2
Namespaces, or strict mount-and-policy separation where they are absentPer-tenant isolation inside a shard (open question 3)
kv v2Wrapped CMK blobs (§6.3) and, if kept, tenant secrets
One transit wrapping key per shardOne decrypt per KEK per cache fill (§7.2, mode B)
The transit seal protocolShard auto-unseal against the seal cluster (§9.2)
sys/health, sys/seal-statusFleet reconciliation (§8.5) and readiness (§9.4)

Everything richer in the transit engine — per-key rotation and versioning, rewrap, datakey, signing, HMAC — is used by mode-A keys only. That is deliberate containment rather than an accident of the default: mode A is the tenant-visible opt-out that trades throughput for “key material never leaves the barrier”, and it is also the one place where the design takes a hard dependency on transit as a cryptographic engine rather than as a barrier. Losing the OpenBao dependency would therefore cost the service mode A and the kv surface of open question 5; it would not cost the service.

Two rules keep the contract from eroding by accretion, which is how such contracts normally die:

  1. The dependency lives in one crate. No code outside kms-bao (§17) constructs an OpenBao path, parses an OpenBao response, or knows that namespaces exist. A leak of OpenBao vocabulary into kms-api or kms-control is a review failure, not a shortcut.
  2. Nothing OpenBao generates is durable state anywhere else. Shard identity never appears in a ciphertext blob (§6.4), OpenBao token and lease lifetimes are never the basis of an authorization decision (§13), and every mapping that must survive a shard is a row in the control plane’s own database (§16).

9. Sealing, unsealing and the root of trust

9.1 The requirement

Shards must come up unattended — after a node reboot, an AZ failure, a rolling upgrade, or a full region cold start — without a human typing unseal keys, and without depending on any KMS outside this cloud.

9.2 The seal hierarchy

flowchart LR
    subgraph tier0["Tier 0 — root"]
        hsm["HSM partitions<br/>(optional)"]
        shamir["Shamir shares<br/>(operator-held, offline)"]
        tpm["TPM-sealed node keys<br/>(per seal node)"]
    end
    subgraph tier1["Tier 1 — seal cluster"]
        seal["Central OpenBao<br/>transit mount 'fleet-unseal'"]
    end
    subgraph tier2["Tier 2 — fleet"]
        a["Shard A"]
        b["Shard B"]
        c["Shard C"]
    end
    hsm -->|"pkcs11 seal"| seal
    shamir -.->|"or"| seal
    tpm -.->|"or"| seal
    seal -->|"transit seal,<br/>one key per shard"| a
    seal --> b
    seal --> c
    hsm -.->|"direct pkcs11 seal<br/>(alternative)"| a

Tier 1 exists to avoid N HSM client configurations and to give one place where “which shard may unseal” is authorized and audited. Each shard has its own transit key in the seal cluster, and a shard may unseal only against its own key.

The same authentication question as §5.6 arises here and has to be answered differently, because here the seal client is a stock OpenBao node, not something the KMS wrote: a shard node’s seal stanza needs a credential for the seal cluster at boot, before anything else on that node is running. The options, and why the last one wins:

How the shard node authenticates to the seal clusterProblem
A long-lived seal-cluster token in the node’s configA durable secret on disk whose theft is a shard compromise; rotation is manual
The node’s X.509 SVID via auth/certThe SPIRE agent must be up and attested before OpenBao starts, which inverts the cold-start order of §9.4
A short-lived token written by a local agent (recommended)The node runs a small credential helper that attests to SPIRE, logs into the seal cluster with the resulting SVID, writes a short-lived token, and renews it; OpenBao starts after it

With the third, the material on a shard node’s disk is a SPIRE agent configuration rather than a seal credential, so a stolen backup of a shard’s storage is useless without an identity the seal cluster accepts — but the property is bought by the credential helper, not by the seal stanza, and the cold-start order in §9.4 has to place SPIRE before the shards for exactly this reason.

9.3 Options for the root

OptionUnattended restartRoot protectionCostNotes
Shamir shares, operators presentNoHuman custodyNoneAcceptable only for the seal cluster, and only with a documented ceremony
TPM-sealed key per seal nodeYesHardware-bound per nodeLowNode replacement is a ceremony; quorum of nodes needed
HSM partition (PKCS#11), two+ failure domainsYesFIPS 140-3 L3 possibleHighThe compliance answer
Attested TEE nodes, evidence appraisedYesHardware-bound, host excludedMediumAn option and never a prerequisite: the service must start on ordinary hardware (§2.6)
Seal cluster sealed by another seal clusterYesRecursion—Rejected: moves the problem, does not solve it

Recommendation: TPM-sealed seal-cluster nodes as the no-HSM baseline (it reuses the mechanism Keystone’s PKCS#11/TPM plan already builds), with an HSM seal as a configuration change for deployments that need it. Shamir shares remain as the offline recovery path, held under split custody, exercised in drills. Attested nodes are listed because a deployment that already runs confidential compute has the mechanism to hand and may prefer it to TPM sealing; it competes on operational cost rather than on capability, and principle 6 forbids it becoming the only way to start a region.

9.4 Bootstrap: the chicken and the egg

Cold start of an entire region has a strict order, and it must be written down because it is the procedure nobody practises until they need it:

  1. HSM partitions online, or a quorum of seal-cluster nodes with intact TPM state, or operators with Shamir shares.
  2. Seal cluster unseals. It depends on no running service — in particular not on Keystone, not on a live SPIRE server, and not on the KMS’s own database. It does hold a static copy of the SPIRE trust bundle so that it can verify the shard credentials of step 4 once they appear; a static trust anchor on disk is not a runtime dependency, and keeping it to that is a hard constraint on the seal cluster’s configuration.
  3. SPIRE server comes up. Its own CA key may be KMS-held only if the KMS can serve it without SPIRE — which it cannot in the general case, so in Phase 1 SPIRE keeps its own root and the dependency is left uninverted (§9.6).
  4. Shards unseal against the seal cluster using node identities.
  5. kms-control connects to its database and to the shards.
  6. kms-crypto starts with a cold cache; kms-api starts last and only reports ready when a canary key round-trips end to end.

The readiness probe of kms-api performing a real cryptographic round trip against a canary key in each reachable shard — rather than returning 200 because the process is alive — is what keeps a half-initialized region out of the load balancer.

9.5 Seal rotation and rekey

Rotating a shard’s transit unseal key, rotating the seal cluster’s root, rekeying a shard’s barrier (rekey), and rotating the HSM’s root are four distinct operations with four distinct runbooks, all driven by kms-fleet, all quorum-approved, all rehearsed on a schedule. A KMS that cannot rotate its root of trust has no answer to a compromise.

9.6 Reflexive dependencies

The KMS must be careful about becoming the keeper of the keys that it itself needs. Three cases:

  • SPIRE’s CA key. Tempting, and circular: SPIRE issues the SVIDs that the KMS uses to authenticate its own tiers. Phase 1 keeps SPIRE’s root separate (HSM or TPM). A later phase may invert this if the KMS’s seal path is proven to work with certificate-less bootstrap for its own tiers.
  • Keystone’s signing keys (ADR 0026). Also circular, since the KMS authorizes against Keystone. Same resolution: keep separate initially; revisit once the KMS has a “break-glass, no external authorization” internal mode that is itself safe.
  • The KMS’s own database encryption. Solved without recursion: the metadata database holds no key material (§16), so it needs only transport and at-rest disk encryption, whose key comes from the same TPM/HSM tier as the seal cluster.

10. HSM integration

HSM support is a capability of a deployment, expressed per key and per shard, and it is never required for the service to function.

10.1 Three integration points

Integration pointWhat the HSM doesWhen to use
SealProtects the seal cluster’s (or a shard’s) barrier keyAny deployment with a compliance story (§9)
CMK custodyThe customer master key is generated in and never leaves the HSMregulated placement class; premium tier
Crypto offloadDEK generation, wrapping, signing performed in the HSMVery high assurance; the §7 option D

10.2 Mechanism

PKCS#11 via a vetted Rust binding, loaded as a dynamic module configured per deployment; no HSM vendor SDK is linked into the binary. The same abstraction carries a TPM (tpm2-pkcs11) and a software HSM (SoftHSM) so that CI and development exercise the same code path as production. This is exactly the approach Keystone’s PKCS#11/TPM KEK plan takes, and the crate is shared.

Sessions are owned by kms-crypto (never by kms-api), pooled, health-checked and re-established on partition failover. HSM throughput is finite and much lower than software crypto, so an HSM-backed key is rate-limited on its own budget and the limit is a property of the key, visible to the tenant, rather than an opaque cause of tail latency.

10.3 The honest documentation requirement

Whatever the deployment does, the key’s metadata states its protection level and the API returns it:

protection_levelMeaning
softwareKey material protected by the shard barrier; barrier sealed per §9
hsm-sealedAs above, and the seal chain terminates in an HSM
hsm-residentKey material generated in, and never leaving, an HSM partition

protection_level and offload_mode (§7.2) are not independent: an hsm-resident key has no representation outside the HSM, so it is restricted to mode A (every operation in the HSM) or mode D (DEK generation in the HSM), and the API rejects any other combination at creation rather than silently weakening one of the two. hsm-sealed places no constraint on the offload mode, because the seal chain is about how the barrier is protected, not about where the key is used.

A customer choosing a key’s protection level and being able to verify it later is a requirement, not a feature. So is refusing to let protection_level be downgraded by any API.


11. Regions, high availability and disaster recovery

11.1 Regions are hard boundaries

A key belongs to a region. Its identifier carries the region. Its material never leaves the region unless it is a multi-region key and the tenant requested replication. There is no global control plane, no global database, and no cross-region synchronous call on any data path. This mirrors the region model OpenStack already has and avoids the failure mode where a WAN partition takes out key operations in a healthy region.

11.2 Multi-region keys

OptionDescriptionUse case
Independent keys (default)A key per region; the application decidesMost workloads
Replicated keySame key material, same ID suffix, distinct regional key objects; replication is an explicit, audited, one-directional operation between two regionsCross-region backup and DR of encrypted data
Primary/replica with promotionOne region is authoritative for rotation; replicas are decrypt-capable and can be promotedCross-region DR with a single rotation authority

Replication moves key material between regions, so it happens between kms-crypto tiers over mutually authenticated SPIFFE mTLS with a federated trust domain, wrapped under a region-pair wrapping key, with both regions recording the transfer. A tenant must opt in per key, and the key’s metadata permanently records every region it has ever been replicated to — you cannot un-replicate material, and pretending otherwise would be dishonest.

11.3 In-region HA

  • kms-api, kms-crypto: stateless, N+2 across AZs, drained on deploy.
  • kms-control: the metadata database is synchronously replicated across AZs; schedulers are leader-elected.
  • Shards: 3 or 5 nodes across AZs; loss of one AZ leaves quorum. An AZ that cannot reach the seal cluster cannot unseal, so the seal cluster is also AZ-distributed and is the first thing recovered.
  • Load balancing: kms-crypto prefers the replica whose cache is warm for the requested key (consistent hashing on key ID with bounded load), falling back to any replica. Cache warmth is a performance property; correctness never depends on hitting a particular replica.

11.4 The Keystone dependency

Authorization is a live decision. That is right for a KMS — a revoked grant must stop working — but a Keystone brown-out must not become a data outage. The posture:

  • Decisions are cached per (subject, key, operation) with a short TTL (default 60 s for management operations, configurable up to a few minutes for data operations).
  • The check API is served from a region-local Keystone read path (Raft learners or read replicas, per vision §8).
  • Fail closed by default. When no cached decision applies and Keystone is unreachable, the request is denied.
  • A narrowly scoped, explicitly enabled, time-boxed and loudly audited “extended cache” mode lets an operator raise the data-path TTL during a Keystone incident. It is an incident action with an expiry, not a configuration default, and while it is active every served request carries a header saying so and every audit event records it.

11.5 Backup and disaster recovery

  • Shard snapshots are taken by kms-fleet, encrypted under a backup key held in the seal cluster (and in the HSM where present), stored in at least two failure domains, and restore-tested on a schedule into an isolated environment. A backup that has never been restored is not a backup.
  • The control-plane database is backed up with the same discipline; it holds no key material, so its exposure is metadata exposure — still serious, still encrypted.
  • The recovery objectives are stated per component and published to tenants, because the recovery time of a KMS is the recovery time of everything encrypted with it.
  • A documented, rehearsed region cold-start drill (§9.4) at least annually.

12. Network isolation

The zones of §5.1 map onto networks. This is where the “public versus internal” requirement becomes enforceable rather than aspirational.

flowchart TB
    subgraph n1["Public network (internet / tenant)"]
        lb1["Public LB :443, :5696"]
    end
    subgraph n2["OpenStack internal network"]
        lb2["Internal LB :8443"]
        svcs["Cinder, Glance, Nova, Swift"]
    end
    subgraph n3["KMS service network"]
        api["kms-api"]
        cry["kms-crypto"]
        ctl["kms-control"]
    end
    subgraph n4["Backend network"]
        shards["OpenBao shards"]
        db[("Metadata DB")]
    end
    subgraph n5["Seal network"]
        seal["Seal cluster"]
        hsm["HSM"]
    end
    subgraph n6["Management network"]
        fleet["kms-fleet, kms-manage"]
    end

    lb1 --> api
    lb2 --> api
    svcs --> lb2
    api --> cry
    api --> ctl
    cry --> shards
    ctl --> shards
    ctl --> db
    shards --> seal
    seal --> hsm
    fleet --> ctl
    fleet --> shards
    fleet --> seal

12.1 Rules

  1. No tenant-routable path to the backend network. The shards have no route to, and no listener reachable from, any tenant network. This is enforced by routing and by firewall/NetworkPolicy, and verified by an automated reachability test that runs in CI against the deployment, not by a diagram.
  2. The seal network is the most restricted. It carries exactly two traffic patterns: shard nodes to the seal cluster, and the seal cluster to its HSM partition. kms-api has no route to it. Neither does kms-control, except through kms-fleet on the management network. The HSM used for crypto offload (§7 option D, §10.1) is a different partition reached over a different path from the KMS service network, and the two must not be collapsed into one client configuration just because they are the same appliance: the seal partition is reachable only by the seal cluster, and a compromise of kms-crypto must not yield a seal credential. Where only one HSM exists, this is enforced by separate partitions with separate credentials, and it is a deployment review item.
  3. Each hop is mutually authenticated. SPIFFE mTLS everywhere inside; network controls are defence in depth, not the primary control. A compromise of a network boundary should not by itself yield key material.
  4. Egress is default-deny, and the allowed list is exhaustive. kms-crypto may reach the shards, its HSM crypto partition, the invalidation bus and the audit pipeline. kms-api may reach kms-crypto, kms-control, Keystone, OPA and the audit pipeline. kms-control may reach the shards, its database, the invalidation bus and the audit pipeline. Nothing else, in either direction, for any of them. A KMS with unrestricted egress is a KMS with an exfiltration path.
  5. Separate listeners, separate certificates, separate CAs. The public listener uses a public CA; internal listeners use the SPIFFE trust domain. No certificate is valid in two zones.
  6. KMIP gets its own address and its own budget. KMIP clients are long-lived connections with different failure behaviour from HTTP; sharing a process is fine, sharing a connection budget is not.
  7. Administrative interfaces are never on the public LB. No exceptions, no “temporarily”, no IP allowlist as a substitute.

12.2 Multi-tenant traffic hygiene

  • Per-project connection and request budgets at the edge, so one tenant cannot exhaust the shared front end.
  • Separate rate-limit governors per zone (ADR 0022): a tenant burst and a Cinder mass-attach draw on different budgets.
  • Request and response size caps on every endpoint, enforced before parsing.
  • The KMIP front end drops connections that do not complete a TLS handshake with an acceptable client certificate within a short deadline; unauthenticated TTLV is never parsed.

13. Authentication and authorization

13.1 External callers

Exactly as in vision §4.3 and §5: a scope-less JWT (or an SVID at the TLS layer) says who, the addressed key says where, and the operation says what. Ownership is never read out of the credential: the key’s owning project comes from the KMS’s own metadata, which is the authoritative statement, and a credential that names a project does not thereby gain access to it.

KMIP is the one surface where that has to be qualified, and the qualification matters enough to state rather than bury. KMIP has no field in which a client can name a target, so the connection is bound to a project derived from the client identity at handshake time (§5.5). That derived project is a default target, not an authorization: the KMS still evaluates the same per-object policy it would for a REST call, and an object the principal may not read is invisible to Locate and denied to Get. The binding chooses which target the request is about; it never decides whether the request is allowed.

13.2 Internal callers

SPIFFE mTLS peer identity, plus — when acting for a user — the user’s JWT or an on-behalf-of exchange. The KMS refuses a service-only identity for any operation on a tenant key unless the tenant has granted that service a standing grant (for example “Cinder may unwrap DEKs for key K”), because a service identity that can unwrap any tenant key is precisely the design flaw this architecture exists to avoid.

13.3 Policy

OPA, with the bundle layout the repository already uses: policy/kms/<resource>/<action>.rego — policy/kms/key/create.rego, policy/kms/key/decrypt.rego, policy/kms/grant/create.rego, and so on. The policy input carries the authentication chain, the operation, the key’s metadata (owner, state, protection level, offload mode), the grant set, and the request’s context — and never key material, wrapped or otherwise. That exclusion is a test, not a convention.

Rules that follow directly from the security model:

  • Decisions key on the authentication chain, never on a caller-supplied scope.
  • Delegated operations compare against input.credentials.delegated_project_id.
  • The scope-drift tripwire applies unchanged.
  • Every list endpoint re-checks each key individually with the per-item read policy.
  • Sensitive operations (deletion scheduling, export, grant creation, protection downgrade attempts) may require amr containing a phishing-resistant method, expressed as a policy rule rather than a separate credential type.

13.4 Grants

A grant is the KMS’s unit of delegation: “principal P may perform operations O on key K, optionally constrained by encryption context, optionally expiring”. It is deliberately the same shape as the permission grants of vision §6, and where Keystone gains resource-level targets (vision §6.5) the KMS is the first consumer: a grant on a key is a resource-level grant, and modelling it in Keystone rather than in the KMS’s own database is the preferred end state.

The condition slot is where attestation-bound release lands (§2.6): principal P may unwrap key K if it presents an attestation result satisfying appraisal policy A. The KMS verifies the result’s signature against a verifier it has been configured to trust and evaluates its claims in Rego; it does not appraise raw evidence. Attaching the condition to the grant rather than to the key is the deliberate part. A caller that cannot produce a result finds the grant unusable, which is the intended failure mode, while the key itself stays usable by the tenant’s ordinary tooling — so a tenant who mis-specifies an appraisal policy has a broken workload, not a key it can no longer reach.

Two implementation options:

OptionProsCons
Grants stored in the KMSShips now; no Keystone change; fast local evaluationA second authorization store to audit and keep consistent
Grants as Keystone resource-level grantsOne authority, one audit trail, one revocation pathRequires vision §6.5 to land; adds a hot dependency

Recommendation: store grants in the KMS initially with a schema that maps one-to-one onto the Keystone model, and migrate when vision §6.5 lands. The migration is a data move, not a redesign, precisely because the schema was chosen for it.

13.5 Quotas

Per project: number of keys, key versions, grants, secrets, operations per second by operation class, HSM-backed key count. Quota enforcement happens at the edge, is visible through /v1/quotas, and is a first-class defence against a compromised tenant account driving cost or backend load.


14. Audit, metrics and transparency

  • CADF events (ADR 0023) naming the initiator, the acting service if any, the key, the key version, the operation, the outcome, the encryption context digest (never the context’s secret parts), and the protection level. Three tiers, per principle 10: (a) management operations, every failure, every denial and every operation that touches key material — one event each, never aggregated, never sampled; (b) successful data-plane operations — recorded as per-(principal, key, operation, minute) aggregates carrying a count, so that “who used this key and when” stays answerable at a resolution of a minute without one event per object write; (c) a per-batch event for a batched request, with the item count and any per-item failures enumerated. An operator can promote a key or a project to tier (a) for everything, which is what an investigation needs and what a regulated tenant may pay for.
  • Tenant-visible audit. A tenant can read the audit events for their own keys. This is not a convenience feature: “who decrypted my data, and when” is the question a KMS exists to answer, and a KMS that only answers it to the operator is not usable for a regulated customer.
  • Metrics (ADR 0031): operation rate and latency by class and key, cache hit ratio and age distribution, backend call rate per shard, shard health and seal status, HSM session health and saturation, authorization decision latency and cache hit ratio, rate-limit rejections. Per-tenant cardinality is bounded deliberately; a tenant label on a high-cardinality metric is a monitoring outage waiting to happen.
  • No key material in any telemetry, ever. Enforced by a redaction layer with its own tests, and by the same discipline the audit pipeline already applies to PII.

15. Metering and billing

15.1 Why this belongs in the architecture

Metering is in this document, and not deferred to a commercial annex, for one reason: two of its requirements cannot be retrofitted. The billable party (§15.4) has to be derivable from the authentication chain, which means it is decided by the same model as authorization; and custody metering (§15.2) needs an inventory of key versions that only the control plane can produce. Everything else about pricing can change quarterly. These two cannot change at all once keys exist and invoices have been sent.

The second reason is a correction: audit is not a billing source. §14 deliberately aggregates successful data-plane events per minute and samples nothing else; that is right for answering “who used this key”, and wrong for an invoice, which needs completeness, deduplication and a retention period set by the tax authority rather than by the security team. The two streams share a vocabulary and nothing else.

15.2 What is metered

Three meters, because the service has three genuinely different cost drivers:

MeterUnitEmitted byCost it tracks
CustodyActive key version-hour, by protection levelkms-control inventory snapshotShard occupancy, backup volume, HSM key slots
OperationsOne record per operation, by operation classkms-apiMarginal compute; mostly an abuse signal (§15.6)
Dedicated capacityShard-hour, by placement classkms-fleetThe dedicated and regulated classes of §8.3, which cost a full quorum whether used or not

Custody is metered per key version, not per key, because a version is what occupies a barrier and a backup. Rotation therefore has a visible price, which is the honest signal: a tenant rotating hourly is buying something real.

The three meters are emitted by three different components on purpose. A discrepancy between the operation stream and the custody snapshot is the cheapest available detector for a metering pipeline that has silently stopped, and reconciliation between them should be a scheduled job, not an incident response.

15.3 Where metering happens, and what it may never do

Operation metering happens at kms-api. That is the only point where the native, Vault-compatible, Barbican-compatible and KMIP surfaces converge, and — more importantly — it is the only point that sees operations served from the kms-crypto KEK cache. An operation satisfied entirely from cache performs no backend work and must still be billed; metering at the crypto tier or at the shard would make the §7 offload look like a discount. The KMIP handler is the one that needs care: a KMIP session is long-lived and multiplexes many operations, so records are emitted per operation, never per connection.

Two rules constrain the implementation:

  1. A metering failure never fails a cryptographic operation. A KMS that refuses to decrypt because the rating pipeline is unavailable has converted a billing outage into a data outage. Records are written to a durable local queue and drained asynchronously; when the queue fills, the oldest records are spilled to disk and, past that, revenue is lost in preference to availability. This is a deliberate trade and it is the reason for the reconciliation job above.
  2. At-least-once, with an idempotency key. Every record carries (region, emitter_id, monotonic_sequence); the rating system deduplicates. Retried requests, batch items and re-driven queues must not double-bill, and exactly-once delivery is not available to us.

A batch request emits one record per item, with the batch identifier retained — consistent with the per-batch audit event of §14, which already carries the item count.

15.4 The billable party

The authenticated principal is not always who pays. The rule, keyed on the authentication chain exactly as authorization is:

SituationPays
A tenant workload using its own project’s keyThat project
A tenant using another project’s key under a grant (§13.4)The key’s owning project, unless the grant was created with billing = grantee
An OpenStack service acting for a user (§4.4)The internal placement class — not the tenant. See below
A tenant’s OpenBao auto-unsealingThe project owning the transit key
kms-fleet and the KMS’s own tiersNothing; internal traffic is not a meter

The third row is a product decision with an architectural consequence, so it is stated here rather than left to a price list. When Cinder unwraps a volume key on every attach, that traffic is the cloud’s, generated by the cloud’s own scheduler at a rate the tenant does not control. Billing it as tenant KMS operations charges a customer for an implementation detail and makes encrypted volumes look arbitrarily expensive; the cost belongs in the volume price. What is billed to the tenant is the custody of the customer-managed key itself.

Grants default to the key owner paying because the alternative — a project being billed for a key it does not control and cannot delete — produces disputes that no amount of usage data resolves. The billing = grantee option exists because the opposite arrangement is what a service-provider tenant actually wants.

15.5 Billing semantics of key state

The state machine of §6.2 needs a billing column, and the answers are not self-evident:

StateBilled for custodyReasoning
enabledYes
disabledYesThe material still occupies a barrier and a backup; free storage of disabled keys is an accumulation incentive
pending_deletionYesSame, and the waiting period is a service the tenant is receiving
pending_importNoNo material exists yet
destroyedNo
Versions below min_decryption_versionNoArchived versions are not retrievable, so charging for them sells nothing

Custody is metered hourly and prorated. A key created and destroyed within an hour is billed for an hour — a minimum charge that removes any incentive to churn keys in order to defeat the meter.

15.6 A reasonable tariff

The architecture argues for a specific weighting, and the argument is worth recording even though the numbers are not the architecture’s to set. §7 exists to make a data-plane operation cost approximately nothing at the margin: a cache hit performs no backend call and no shard I/O. Meanwhile the cost the fleet actually incurs is the one computed in §4.3 — quorum × availability zones × shard — which a tenant incurs by existing on a shard, not by issuing requests. A tariff weighted toward per-operation charging would therefore tax the one resource the design deliberately made free, and would reward a customer for defeating the cache the security model already constrains (§7.3).

So: custody dominates, operations are priced as a fairness and abuse signal, dedicated capacity is a flat charge. Indicative anchors, benchmarked against the public clouds this service is a substitute for, and not a price list:

LineIndicative
Software-protected key version~$0.50–1 / month
hsm-sealed key version~$2–3 / month
hsm-resident key version (§10)~$5+ / month
Data-plane operations~$0.03 / 10k
Asymmetric and HSM-bound operations~$0.15 / 10k
Key management operationsfree
dedicated / regulated shardmonthly platform fee
Free allowancefirst ~5 key versions, ~20k operations / month

The free allowance is not marketing; it is the same decision as the free-tier placement class of §8.3, and the two should be defined once.

The Phase 1 economics do not close on this tariff, and that is worth stating plainly. An auto-unseal customer needs exactly one key and performs on the order of ten operations a month. A thousand such customers, priced per key and per operation, do not pay for one region’s quorum, let alone the engineering. Phase 1 is therefore priced either as a flat per-project subscription for the capability, or — more honestly — is not a profit centre at all, but the capability that makes customer-managed encryption for Cinder, Swift and Manila possible and regulated workloads onboardable. Which of the two it is should be decided before Phase 1 ships, because it determines whether usage-based billing is needed in Phase 2 or can wait for Phase 3.

15.7 Regions, rollup and replicated keys

§4.3 forbids a global write path, so metering is regional and there is no transactional global view of a tenant’s usage. The consequences:

  • Each region emits to its own durable queue and is drained into a rating system outside the KMS’s trust boundary; late and out-of-order arrival is normal and the rating system, not the KMS, closes the billing period.
  • A replicated key (§11.2) exists in more than one region and is metered in each, because it occupies a barrier in each. The rating system must deduplicate on the logical key identifier for display while summing the per-region custody records for charge — a tenant reading their bill should see one key charged twice with the regions named, not two keys.
  • A region that is isolated continues to serve and continues to meter. Its records arrive when the partition heals. Nothing about billing may become a reason to fail closed.

15.8 Quotas are not entitlements

§13.5’s quotas exist to stop a compromised tenant account from driving cost and backend load. They are a defence, and they are enforced at the edge where that defence is cheap. A billing plan is a different thing, and the two are linked by exactly one rule: a plan may raise a quota, and a quota may never be the enforcement point for a payment decision. Suspension for non-payment is a lifecycle action on the project, taken by the cloud’s billing system through the management zone, audited as a management operation, and — critically — never expressed as a silent quota of zero, which would be indistinguishable to the tenant from an outage of the service they are disputing the bill for.

15.9 What the tenant can see

A GET /v1/usage endpoint on the public zone, returning the current billing period’s custody and operation counts per key and per operation class, from the same records that produce the invoice. The tenant-visible audit of §14 answers “who used my key”; this answers “why is my bill this number”, and a customer who cannot answer the second question without a support ticket will not adopt a KMS for anything that matters.


16. What lives where: storage model

DataStored inEncrypted by
Key material (CMK, all versions)OpenBao shard (transit)Shard barrier
Tenant secrets / kvOpenBao shard (kv v2)Shard barrier
Key metadata, state, tags, protection levelControl-plane databaseDisk/transport encryption
Grants, quotas, placement directoryControl-plane databaseDisk/transport encryption
Audit eventsAudit pipeline / SIEMPipeline’s own protection
Metering records (custody, operations, capacity)Durable local queue, drained to the rating systemQueue’s own protection; transport encryption
Wrapped CMK blobs (modes B, C)OpenBao shard (kv)Per-shard transit wrapping key
KEK cache entries (plaintext)kms-crypto memory onlyNever persisted; mlock-ed, zeroized
Shard topology, seal configurationControl-plane database + kms-fleet stateDisk encryption
Cache-invalidation epochsControl-plane database, fanned out on the busDisk encryption

The invariant worth restating: the control-plane database contains no key material. A full dump of it discloses what keys exist, who owns them and how they are used — serious, and a reason to encrypt and restrict it — but it does not disclose a single byte that decrypts customer data.


17. Implementation shape

A Rust workspace mirroring keystone-rs conventions, so that a contributor moving between the two projects finds the same structure. The five processes of §4.2 are five binaries in it; the rest are libraries:

CrateContents
kms-apiBinary: axum routers per zone, handlers (one per module), protocol translation
kms-controlBinary: metadata service, lifecycle state machine, schedulers, placement
kms-crypto-svcBinary: the crypto tier process; owns the KEK cache and the HSM sessions
kms-coreDomain providers and the backend traits: KeyCustody, CryptoEngine, CRUD providers
kms-core-typesShared types: key identifiers, states, algorithms, ciphertext blobs
kms-api-typesRequest/response models and conversions for all surfaces
kms-cryptoLibrary: envelope engine, cache, zeroizing key handling, algorithm registry
kms-baoOpenBao client: transit, kv, sys, namespaces, seal operations — the only crate that knows OpenBao exists (§8.6)
kms-fleetBinary: shard reconciler, migration, backup orchestration
kms-kmipTTLV codec and KMIP server state machine (fuzzed)
kms-vault-apiVault/OpenBao-compatible surface
kms-barbicanBarbican v1 compatibility surface
kms-pkcs11PKCS#11/HSM abstraction (shared with keystone-rs); implements CryptoEngine and a low-cardinality KeyCustody
kms-attestAttestation-result verification and claim extraction for policy input (§2.6); never on the data path
kms-meterMetering record model, durable emit queue, custody snapshot job (§15)
kms-sqlSeaORM persistence for control-plane metadata
kms-manageBinary: operator CLI
test_apiLive-server API tests against SPIRE + OPA + OpenBao + the service

The backend seam. Custody and cryptography are two traits, not one backend. KeyCustody answers “where does this key’s material live, and who may destroy it”; CryptoEngine answers “who performs this operation”. They are kept apart because their implementations differ in both cardinality and failure mode — a shard holds millions of key objects at software speed, an HSM partition thousands at hardware speed — and one trait spanning both would collapse either to their intersection, losing the transit versioning and min_decryption_version that §6.2 depends on, or to a union with runtime capability errors, which is worse than two honest implementations:

CrateKeyCustodyCryptoEngine
kms-baoOpenBao shard (barrier, kv)transit — mode A
kms-pkcs11HSM partition, low cardinalityPKCS#11 — modes A and D
kms-crypto—In-process aws-lc-rs/ring — modes B and C

Capability negotiation is not done with runtime flags. The permitted combinations are exactly the (protection_level, offload_mode) matrix of §10.3 crossed with the shard type of §8.3, validated by kms-control when the key is created. An unsupported combination is a rejection at creation time, never an Unsupported error surfacing on the data path with a DEK request already in flight.

Engineering rules carried over unchanged: no unwrap/expect/println!/ unsafe; thiserror error types; async on tokio; Apache-2.0 headers; CRUD naming on backend traits; at least three tests per CRUD handler (valid auth plus positive and negative policy, invalid auth). Two additions specific to this project:

  • Every cryptographic format has a test vector file committed to the repository, and a test that decrypts a blob produced by an older format version. Wire-format compatibility for ciphertext is forever.
  • The crypto tier is fuzzed and the KMIP codec is fuzzed, in CI, with a corpus that grows from every parsing bug found.
  • The custody seam is proven by a second implementation, in CI. The KeyCustody contract suite runs against kms-bao and against kms-pkcs11 on SoftHSM (§10.2), from the phase that introduces the trait onward. A seam with one implementation behind it is a guess, and it is always discovered to be the wrong guess at the moment a second backend becomes urgent.

18. Failure modes

FailureData planeManagement planeRecovery
One kms-api replicaNone (LB)NoneAutomatic
All kms-api in an AZNone if N+2NoneAutomatic
One kms-crypto replicaCache miss storm on its keys, brief latency riseNoneAutomatic; cache re-warms
Shard loses one nodeNoneNoneRaft heals
Shard loses quorumWarm keys keep working for the cache TTL; cold keys failFails for that shardRestore from snapshot; §8/§11.5
Shard cannot reach the seal clusterWorks while unsealed; a restart does not recoverDegradedRecover the seal cluster first
Seal cluster downNone while shards stay unsealedNo new shards, no restartsHighest-priority recovery
HSM partition downhsm-resident keys fail; others unaffectedDegradedFailover to the second partition
Keystone unreachableCached decisions serve; new ones fail closed (§11.4)Fails closedRegion-local read path; incident TTL mode
Control-plane DB downWarm keys work; placement lookups fail for cold keysFailsReplica promotion
Whole region downMulti-region keys serve elsewhere; single-region keys do notFailsRegion cold start (§9.4)

The row that deserves attention is “shard loses quorum”: the difference between a short storage incident and a customer-visible data outage is exactly the crypto tier’s cache, which is why §7.3 treats the TTL as an availability control as well as a security control.


19. Phased delivery

The guiding rule: each phase ends with something an operator can deploy and a customer can use. No phase is a refactor of the previous one.

Phase 1 — Auto-unseal for customer OpenBao (the first useful thing)

Goal: a customer’s OpenBao auto-unseals against the cloud KMS, using OpenBao’s own native transit seal, with no custom client software.

  • kms-api with the public zone only, and only transit/encrypt, transit/decrypt, auth/cert/login, auth/jwt/login, sys/health, sys/seal-status.
  • kms-crypto and kms-control as separate processes from the start, even though Phase 1 has no cache for them to hold and no fleet for them to place onto. Collapsing them into kms-api for one phase would put the network split of §12 — the thing that makes the isolation claims true — on the wrong side of a later refactor, and the isolation is the part a first customer is actually buying.
  • SVID (mTLS) and Keystone-JWT authentication; OPA policy; per-project rate limits; CADF audit; Prometheus metrics.
  • A single shard per region is acceptable here, but built behind the placement directory from day one, so that Phase 3 is a configuration change rather than a migration of the data model.
  • Seal cluster and shard unseal (§9), with the TPM-sealed baseline.
  • Native POST /v1/keys (create, list, describe, rotate, disable) so that the transit key has a lifecycle in the KMS’s own model rather than only in OpenBao’s.
  • kms-manage bootstrap, region cold-start runbook, restore-tested backups.

The choice of use case is deliberate: it is high-value (customers running OpenBao today either use a public cloud KMS, which is exactly what this cloud cannot offer them, or hold unseal keys by hand), it exercises the whole identity and audit stack, and the client side is already written by someone else.

Phase 2 — Envelope encryption and the crypto tier

  • The KEK cache and the invalidation bus (§7.3) in the kms-crypto process that Phase 1 already deployed.
  • The wrapped-CMK-blob representation of §6.3, alongside the transit key.
  • data-key, unwrap, rewrap, batch variants.
  • Offload modes A and B per key; mode A default for any key created before this phase.
  • Key state machine complete: scheduled deletion with waiting period, versions, min_decryption_version.
  • Grants, with the billing attribute of §15.4 decided at grant creation.
  • The KeyCustody/CryptoEngine split of §17, with the contract suite running against both kms-bao and SoftHSM in CI. The seam is introduced here, with the arrival of the crypto tier as its second consumer, rather than retrofitted later once three API surfaces have grown into the first implementation.
  • Metering (§15): operation records emitted at kms-api — including cache hits — with idempotency keys and a durable local queue that never fails an operation; the hourly custody snapshot in kms-control; the reconciliation job between the two. The billable-party rule and the per-state billing semantics land here because they are properties of the grant and key-state models this phase completes, not of the rating system. Dedicated shard-hours follow in Phase 3 with the fleet that emits them, and GET /v1/usage can follow whenever the rating system is ready to answer it.

Phase 3 — The fleet

  • Multiple shards, placement classes, the directory as the authority.
  • kms-fleet reconciler: provisioning, rolling upgrade, backup, health.
  • Shard migration with the re-wrap procedure (§8.4).
  • Per-tenant namespace isolation and generated policy.

Phase 4 — OpenStack integration

  • Internal zone, SPIFFE-authenticated, with delegated user identity.
  • Barbican v1 compatibility surface and castellan conformance.
  • Cinder, Glance, Swift and Manila integration with customer-managed keys.
  • Key-usage attestation and the re-wrap event stream.

Phase 5 — KMIP

  • TTLV codec, Baseline + Symmetric Key Lifecycle + Basic Cryptographic profiles.
  • Tenant CA registration and certificate-to-principal mapping.
  • Interoperability testing against the clients that matter to the cloud’s customers, published as a support matrix.

Phase 6 — HSM and assurance

  • PKCS#11 seal, hsm-sealed and hsm-resident protection levels.
  • Offload mode D, per-key HSM budgets.
  • regulated placement class.
  • FIPS-validated cryptographic module option; conformance evidence collection.

Phase 7 — Multi-region and the long tail

  • Replicated and primary/replica keys, cross-region replication path.
  • BYOK import and authorized export.
  • Asymmetric key types and signing at scale.
  • Tenant-visible audit API.
  • Post-quantum wrapping and signing as the fleet and the ecosystem allow.
  • Attestation-bound release for confidential workloads: the grant condition of §13.4, kms-attest, and — only if clients require it — a key-broker surface that speaks the attestation handshake (§2.6).

What could be dropped or reordered

Phases 4 and 5 are independent and can be swapped according to whether the cloud’s near-term demand is OpenStack-internal encryption or enterprise customers with existing KMIP estates. Phase 6 can start earlier if an HSM is already deployed for another purpose; nothing in Phases 1–5 has to change to accommodate it, which is the point of §10.


20. Compliance considerations

Not a compliance plan, but the architectural hooks that a compliance plan will need, so that they are not retrofitted:

  • Separation of duties: key custody (fleet operators) is separate from key use (tenants) and from policy administration. kms-manage operations that touch key material require quorum approval.
  • Dual control and split knowledge for the Shamir recovery path.
  • Key lifecycle states and cryptoperiods aligned with NIST SP 800-57.
  • Complete, tamper-evident audit — the HMAC chain the audit ADR already specifies — with tenant-visible export.
  • FIPS 140-3 boundary options: a validated software module, or the HSM.
  • PCI-DSS and eIDAS-adjacent requirements are deployment questions, but they all reduce to: where is the boundary, who can cross it, and can you prove what crossed it. §12, §13 and §14 are the answers.

21. Open questions

  1. Grant authority. Do key grants live in Keystone as resource-level grants (vision §6.5) from the start, accepting the dependency, or in the KMS with a migration later (§13.4)?
  2. Cache TTL as a product decision. The KEK cache TTL trades revocation latency against availability during a shard incident. Is the default an operator choice, a per-key tenant choice, or a per-tier product choice?
  3. Tenancy inside a shard. OpenBao namespaces versus mount-and-policy separation: namespaces are cleaner but tie the design to a namespace implementation; mounts are universal but produce very large policy sets.
  4. Shard sizing. What is M? The answer determines the fleet’s cost and the blast radius simultaneously, and it should be derived from a restore-time measurement rather than from intuition.
  5. Vault surface scope. Is kv v2 worth carrying at all, or does the KMS restrict itself to transit and push secret storage to the customer’s own OpenBao — which is, after all, what this service exists to make safe? §8.6 keeps kv inside the depended-on contract but not load-bearing, so dropping it stays a product decision rather than an architectural one.
  6. Barbican’s future. Is the compatibility surface a migration bridge with an end date, or a permanent OpenStack-facing contract?
  7. KMIP identity for non-SPIFFE clients. Tenant-registered CAs put a certificate-issuance trust decision in tenant hands. Is that acceptable, or should the cloud issue those certificates itself from a per-tenant intermediate?
  8. Reflexive dependencies (§9.6). Under what conditions, if any, should SPIRE’s and Keystone’s own keys move into the KMS?
  9. Cross-region replication and data residency. Replication is a jurisdictional event, not only a technical one. How is that expressed in policy rather than in documentation?
  10. The tariff itself. §15.6 argues for custody-weighted pricing from the cost structure and proposes anchors, but the numbers, the free allowance and whether Phase 1 is a profit centre at all are commercial decisions this document can only frame. The shape influences the API (batching, data-key reuse) more than it looks.
  11. A second custody backend. §2.5 rejects both the HSM-only and the no-barrier options for the general case, and §8.6 keeps the OpenBao dependency narrow enough that the decision stays reversible. What would justify actually shipping a second one? Two triggers are worth naming in advance, so that the answer is not improvised during an incident: the fleet’s operational cost coming to dominate the service’s cost structure (§15.6), and a regulated customer whose custody requirement the barrier cannot satisfy at any price. Neither is a Phase 1–3 concern; both are far cheaper to answer if the seam of §17 is real rather than notional.
  12. Attestation-bound release. §2.6 recommends owning the release decision and not the appraisal, which keeps the tenant-facing broker a Phase 7 feature rather than an architectural commitment. Two things would move it forward: a confidential-computing offering on the cloud’s own compute plane, which gives the feature a captive first customer, and a customer who would otherwise run their own broker — the same argument §2.5 makes about a customer’s own OpenBao. The service-side use is a separate question with a separate answer: attestation as a Tier-0 root (§9.3) competes with TPM sealing on operational cost, not on capability, and principle 6 already forbids making it a prerequisite.

22. References

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.

Cargo features

The default build keeps the dependency tree small (for example for distribution packaging). Optional functionality is enabled with --features on the openstack-keystone crate, e.g. cargo build --release -p openstack-keystone --features wasm_plugins.

FeatureEnables
wasm_pluginsDynamic WebAssembly auth plugins (pulls in extism/wasmtime)
openfgaOpenFGA based assignment driver
audit-syslogRFC 5424 syslog audit sink
otelOTLP trace and metric export over HTTP (see OpenTelemetry)
otlp-grpcOTLP/gRPC transport; use together with otel
pkcs11PKCS#11 hardware-backed KEK provider for the distributed storage
tpmTPM hardware-backed KEK provider for the distributed storage

None of these are enabled by default. CI runs the test suite with --all-features, so every feature is built and tested. A build without wasm_plugins ignores [auth_plugins] configuration and logs a warning at startup.

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>

Devstack integration

The devstack/ directory in the project source tree is a standard out-of-tree devstack plugin that automates the Apache reroute described above. Python Keystone still owns the base DB schema and initial admin bootstrap; the plugin builds/runs the rust binary alongside it, applies the additive DB migrations, and rewrites the Apache site devstack generated for Keystone so that /identity is proxied to the rust binary instead of python’s uwsgi socket.

To use it, add to local.conf:

[[local|localrc]]
enable_plugin key-rs https://github.com/openstack-experimental/keystone
enable_service key-rs

See .github/workflows/devstack.yml for a working example that runs a minimal devstack (mysql, rabbitmq, keystone) in CI with the plugin enabled.

General Information

Keystone-NG is a Rust implementation of OpenStack Identity capabilities. It can run beside Python Keystone, with a reverse proxy routing requests to the implementation that owns each endpoint.

Use this section for information that applies to operators, API consumers, and contributors:

  • Architecture describes the service and its dependencies.
  • Compatibility explains the relationship with Python Keystone and the v3 and v4 APIs.
  • Performance comparison explains how performance results are produced and where to find current measurements.

For task-oriented documentation, continue with the installation guide, user documentation, or administrator guides.

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 Python Keystone 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 Python Keystone 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). The keystone-manage token setup and keystone-manage token rotate commands manage the repository, and running services reload key changes without a restart. See Fernet tokens for configuration and operational procedures.

Compatibility

Keystone-NG augments Python Keystone rather than requiring an immediate full replacement. Deployments can route selected requests to either service while both implementations use compatible identity data and token formats.

API Compatibility

  • The v3 API implements an expanding subset of the OpenStack Identity API.
  • The v4 API contains Keystone-NG capabilities that are not provided by Python Keystone.
  • The generated OpenAPI document is the source of truth for routes and schemas implemented by the current build.

Do not assume that an API described by Python Keystone exists in Keystone-NG. Check the generated specification or the user API guide.

Parallel Deployment

Python and Rust Keystone can use the same deployment while a reverse proxy routes requests according to path ownership. Each implementation manages its own schema additions; database changes must not interfere with the other service. See Installation for the deployment model.

Fernet interoperability requires every node to use the same key repository. See the administrator Fernet guide.

Performance Comparison

The repository contains a Goose-based load-test crate that exercises selected API paths while recording latency and throughput. Pull-request benchmark workflows use it to detect regressions; results depend on the selected scenario, database, machine, concurrency, and build profile.

Treat benchmark artifacts from the current CI run as authoritative. Do not compare an isolated historical number with a different deployment or workload. The benchmark workflows under .github/workflows/benchmark*.yml record the commands and environment used for each result.

User Documentation

This section is for people and applications consuming Keystone-NG APIs, including delegated domain managers.

  • API guide covers version discovery, OpenAPI, and common resource requests.
  • Authentication covers password, TOTP, passkeys, and token headers.
  • Feature guides describe the client side of Fernet tokens, federation, OAuth2/OIDC, SCIM and its API keys, Kubernetes authentication, and identity mapping.

Each feature guide links to its administrator counterpart when deployment or configuration work is required.

API Guide

Keystone exposes version discovery at /, /v3, and /v4.

  • v3 contains the implemented OpenStack Identity resources, including tokens, domains, projects, users, groups, roles, assignments, catalog resources, credentials, and EC2 token validation.
  • v4 contains Keystone-NG features such as passkeys, federation, Kubernetes authentication, mapping rulesets, OAuth2/OIDC, SCIM, and token restrictions.

The deployment’s reverse proxy determines whether Python Keystone or Keystone-NG handles a request. See Compatibility.

OpenAPI and Swagger UI

  • Swagger UI provides the rendered API reference.
  • OpenAPI YAML is the generated specification.
  • A running server exposes Swagger UI at /swagger-ui/ and JSON at /api-docs/openapi.json.

The generated specification is authoritative for request fields, response fields, and status codes.

Common Resource Requests

The examples use an administrator token stored in TOKEN.

Projects

curl -X POST "${KEYSTONE_URL}/v3/projects" \
  -H "X-Auth-Token: ${TOKEN}" \
  -H 'Content-Type: application/json' \
  -d '{
    "project": {
      "name": "service",
      "domain_id": "default",
      "enabled": true,
      "is_domain": false
    }
  }'

A successful create returns 201 Created. The remaining lifecycle operations use GET /v3/projects, GET, PATCH, and DELETE on /v3/projects/{project_id}.

Users

curl -X POST "${KEYSTONE_URL}/v3/users" \
  -H "X-Auth-Token: ${TOKEN}" \
  -H 'Content-Type: application/json' \
  -d '{
    "user": {
      "name": "service-user",
      "domain_id": "default",
      "enabled": true,
      "password": "<initial-password>"
    }
  }'

The response never returns the supplied password. Use GET /v3/users and the GET, PATCH, and DELETE operations on /v3/users/{user_id} for the rest of the lifecycle.

Credentials

Credential blob is a JSON-encoded string, not a nested JSON object:

curl -X POST "${KEYSTONE_URL}/v3/credentials" \
  -H "X-Auth-Token: ${TOKEN}" \
  -H 'Content-Type: application/json' \
  -d '{
    "credential": {
      "type": "totp",
      "blob": "{\"seed\":\"<base32-seed>\"}"
    }
  }'

Credential responses can contain decrypted secret material. Treat the complete response as sensitive.

Authentication

Keystone supports multiple authentication methods. Password and TOTP use the v3 token API, while passkeys use the v4 WebAuthn flow.

Password

Password authentication uses POST /v3/auth/tokens:

curl -i -X POST "${KEYSTONE_URL}/v3/auth/tokens" \
  -H 'Content-Type: application/json' \
  -d '{
    "auth": {
      "identity": {
        "methods": ["password"],
        "password": {
          "user": {
            "name": "admin",
            "domain": {"id": "default"},
            "password": "<password>"
          }
        }
      },
      "scope": {
        "project": {
          "name": "admin",
          "domain": {"id": "default"}
        }
      }
    }
  }'

Success returns 201 Created; the token secret is in the X-Subject-Token response header.

TOTP

TOTP uses the same endpoint with method totp. The user supplies an existing user ID and the current passcode:

{
  "auth": {
    "identity": {
      "methods": ["totp"],
      "totp": {
        "user": {"id": "<user_id>", "passcode": "123456"}
      }
    }
  }
}

Using and Managing Tokens

Send a token as X-Auth-Token when calling authenticated APIs. To validate a token, send it as X-Subject-Token to GET /v3/auth/tokens; to revoke it, send the same headers to DELETE /v3/auth/tokens.

Other authentication mechanisms have dedicated feature guides. Direct application-credential authentication is not currently dispatched by the v3 token handler and is therefore not documented as supported.

Passkey Authentication (WebAuthn)

Keystone-NG supports registration of a hardware security device and a two-step passkey authentication ceremony. Platform passkeys that require an embedded browser are not currently supported by the client flow described here.

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

Registration

Registration uses these v4 endpoints for the target user:

  1. POST /v4/users/{user_id}/passkeys/register_start
  2. POST /v4/users/{user_id}/passkeys/register_finish

The first response contains the WebAuthn creation options. Pass those options to the authenticator and send its signed result to the finish endpoint.

Authentication

Authentication uses:

  1. POST /v4/auth/passkey/start
  2. POST /v4/auth/passkey/finish

The start response contains a challenge. The authenticator verifies user presence and signs that challenge; the finish request returns a Keystone token after successful verification.

See the administrator guide for relying-party configuration and deployment requirements.

Fernet Tokens for API Consumers

Fernet is Keystone-NG’s default v3 token format. Clients treat the value as an opaque bearer credential: do not parse it, log it, or depend on its encoded shape.

Use the token from the X-Subject-Token response header as X-Auth-Token on later requests. Validation and revocation use the v3 token operations described in Authentication.

Tokens expire according to deployment policy and may stop working after revocation or key-retention changes. Obtain a new token instead of persisting a Fernet token as a long-lived application secret.

Operators should use the Fernet administrator guide for key setup and rotation.

Federation

Federation exchanges an identity established by an external provider for a Keystone token. Mapping rules resolve external claims into a Keystone principal, scope, roles, and groups.

Provider registration, allowed redirect URIs, mapping rules, and vendor setup belong to the federation administrator guide.

Authentication Using the Authorization Code Flow

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 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)

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, revocation_endpoint, introspection_endpoint, userinfo_endpoint, 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). Without it, you get a minimal identity token good only for calling Keystone’s own /userinfo — not usable against Nova/Neutron/etc. Not yet supported on the authorization_code and device_code grants: requesting it there is rejected with invalid_scope. Use client_credentials or token exchange to obtain an OpenStack-capable token.
  • Omitting scope entirely defaults to the client’s full allowed_scopes — except 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.

    A refresh token family also has an absolute lifetime (default 90 days from the original login, [oauth2] refresh_token_absolute_lifetime_days). Rotating never extends it: once reached, the refresh fails with invalid_grant and the user must log in again. On every refresh the server re-checks that the user still exists, is enabled and that the domain is enabled; otherwise the whole family is revoked and invalid_grant is returned. The expires_in of the response is the plain access-token lifetime. Once roles are embedded in access tokens, a refreshed token carries the roles resolved at rotation time.

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.

Managing connected applications

When you approve an application and tick “Remember my decision for this application”, it is listed as a connected application and the consent page is skipped next time it asks for the same or fewer scopes. Asking for an additional scope shows the page again, as does prompt=consent.

List the applications you approved:

$ curl -H "X-Auth-Token: $TOKEN" \
    $KEYSTONE/v4/users/$USER_ID/oauth2/consents
{"consents": [{"client_id": "...", "client_name": "My App",
  "scopes": ["openid", "profile"], "authorization_target": null,
  "granted_at": 1790000000, "updated_at": 1790000000}]}

Withdraw one:

$ curl -X DELETE -H "X-Auth-Token: $TOKEN" \
    $KEYSTONE/v4/users/$USER_ID/oauth2/consents/$CLIENT_ID

This forgets the approval and revokes the application’s refresh tokens for your account, so it cannot renew its access; access tokens it already holds work until they expire. You can manage your own consents with an ordinary token, but not with an application credential or a trust token; an administrator can manage anyone’s. The endpoint answers 404 when there is neither a remembered consent nor a live refresh token for that application.

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 on a supported grant, or any client_credentials grant) — carries openstack_context (scope + effective roles at issuance time) and aud: "openstack-apis", valid against every OpenStack service that trusts the token’s issuer (the token’s domain). On the token-exchange grant you can narrow it with the RFC 8693 audience parameter (repeatable, one OpenStack service type per value, e.g. audience=compute), which yields aud: ["openstack-apis:compute", ...] accepted only by those service types. An unknown service type is rejected with invalid_target. 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 (and for revocation).

Access and ID tokens are short-lived (15 minutes by default) and stateless bearer tokens. You can end a session early with the revocation endpoint below (refresh tokens always; access tokens individually); otherwise wait out exp (or an operator triggers 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.

UserInfo endpoint

GET or POST /v4/oauth2/{domain_id}/userinfo (OIDC Core §5.3), advertised as userinfo_endpoint in the discovery document. Authenticate with the OIDC access_token as an RFC 6750 bearer credential:

curl -H "Authorization: Bearer $ACCESS_TOKEN" \
  https://keystone.example.com/v4/oauth2/$DOMAIN_ID/userinfo

The token must carry the openid scope, be unexpired and not revoked, and its user and client must still exist and be enabled. The response is plain JSON (userinfo_signing_alg_values_supported is ["none"]) with Cache-Control: no-store:

  • sub — always.
  • name, preferred_username, updated_at — with the profile scope.
  • email, email_verified — with the email scope, and only when the user has an email address. Keystone does not verify addresses, so email_verified is false unless the user record explicitly says otherwise.
  • Your client’s claims_template output, identical to the id_token.

Failures follow RFC 6750 §3: 401 with WWW-Authenticate: Bearer (no token) or Bearer error="invalid_token" (bad, expired, revoked or foreign token, disabled user or client), and 403 with error="insufficient_scope" when the token lacks openid. Access tokens issued with openstack:api (or by client_credentials) are not accepted here. The endpoint is rate limited per source IP.

Revoking tokens (RFC 7009)

POST /v4/oauth2/{domain_id}/revoke
Content-Type: application/x-www-form-urlencoded

token=<refresh or access token>&token_type_hint=refresh_token

Authenticate the client exactly as for /token (HTTP Basic, or client_id / client_secret in the body). token_type_hint (refresh_token or access_token) is optional and only affects lookup order.

  • Refresh token — the whole refresh family (the token and everything rotated from it) is revoked; any later refresh_token grant fails with invalid_grant.
  • Access token — its jti is added to the domain’s JTI revocation list (/jwks/revocation) until the token’s own exp. Services that enforce the list (see the admin guide) reject it from then on, within the list’s cache TTL (60s). If the token carries a sid claim (the id of the refresh family that minted it), that refresh family is revoked too, ending the session. ID tokens carry no jti and are ignored.

The endpoint always answers 200 with an empty body for an authenticated, well-formed request — including unknown, expired, already-revoked tokens and tokens that belong to another client — so it never reveals whether a token exists. A token is only revoked if it belongs to the authenticated client. Bad client credentials return 401 invalid_client, a missing token returns 400 invalid_request, and the endpoint is rate limited like /token.

Limitation: revocation is one-directional. Revoking an access token also ends its refresh family (via sid), but revoking a refresh token does not revoke access tokens already issued from it; they expire on their own (or revoke them individually). Access tokens from client_credentials / token-exchange have no refresh family and no sid.

Token introspection (RFC 7662)

POST /v4/oauth2/{domain_id}/introspect
Content-Type: application/x-www-form-urlencoded

token=<refresh or access token>&token_type_hint=access_token

Advertised as introspection_endpoint in the discovery document. Use it when a service cannot accept the stateless revocation window of access tokens: unlike offline verification it sees the domain’s JTI revocation list and the refresh-token store as they are now.

Only an authenticated confidential client of the same domain may call it. Authenticate exactly as for /token (HTTP Basic, or client_id / client_secret in the body); public clients get 401 invalid_client. token_type_hint (refresh_token or access_token) is optional and only affects lookup order.

The answer is always 200 with a JSON body and Cache-Control: no-store. A token that is unknown, malformed, expired, revoked, already used (refresh) or issued for another domain yields exactly {"active": false}; the response never says why. For an active token active is true and the body carries:

  • scope, client_id, sub, exp, iat, token_use. For a refresh token exp is bounded by the refresh family’s lifetime cap.
  • Access tokens additionally: token_type (Bearer), nbf, aud, iss, jti. OpenStack access tokens (openstack:api, client_credentials) also include openstack_context and delegation_context, the scope and effective roles the token will act with.

A missing token returns 400 invalid_request, bad client credentials return 401 invalid_client, and the endpoint is rate limited per source IP and per client.

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

SCIM v2 Support

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

API-Key Authentication

SCIM API keys are domain-owned machine credentials accepted only by the SCIM router. They are not substitutes for v3 Fernet tokens and are rejected by core v3 and v4 endpoints.

Send the key as a bearer credential:

Authorization: Bearer kscim_<secret>_<checksum>

The secret is shown once when an administrator creates or rotates the key. A client must store it as sensitive material and replace it when the administrator rotates or revokes the key.

See SCIM v2 Support for supported protocol behavior and the administrator guide for lifecycle and incident procedures.

Kubernetes TokenReview Authentication

Kubernetes workloads can exchange a service-account JWT for a Keystone token. Keystone validates the JWT with the configured Kubernetes cluster, converts the returned service-account identity into mapping claims, and applies the first matching identity-mapping rule.

Ask the domain administrator for the Kubernetes authentication instance ID and the mapping rule requirements before configuring a workload. The operator must register the cluster and enable the instance first.

Authentication request

Send the service-account JWT to the instance-specific endpoint:

POST /v4/k8s_auth/instances/{instance_id}/auth
Content-Type: application/json

{
  "jwt": "<service-account-jwt>",
  "rule_name": "ci-pipeline-admin"
}
FieldRequiredDescription
jwtyesKubernetes service-account JWT presented to TokenReview.
rule_namenoMapping-rule hint evaluated before normal ordered matching.

The client JWT is also used to authorize Keystone’s TokenReview request to the Kubernetes API. The service account therefore needs permission to create tokenreviews.authentication.k8s.io, normally through the Kubernetes system:auth-delegator role.

On success, Keystone returns 200 OK, places the encoded token in the X-Subject-Token response header, and returns the token document in the body.

Authentication flow

sequenceDiagram
    App->>Keystone: POST /v4/k8s_auth/instances/{id}/auth
    Keystone->>Keystone: Check JWT expiration
    Keystone->>Kubernetes: TokenReview using the client JWT
    Kubernetes->>Keystone: Authenticated service-account identity
    Keystone->>Keystone: Flatten claims and evaluate mapping rules
    Keystone->>Keystone: Upsert the shadow virtual user
    Keystone->>App: Keystone token in X-Subject-Token

The TokenReview result produces these mapping claims:

ClaimValue
k8s.serviceaccount.nameService-account name parsed from the reviewed username.
k8s.serviceaccount.namespaceKubernetes namespace parsed from the reviewed username.
k8s.audJWT audience, when present.

The mapping rule determines the resulting principal, scope, roles, and groups. If rule_name is supplied, Keystone evaluates that rule first. If it does not match, normal first-match-wins evaluation continues.

The virtual user ID is deterministic for the service-account name, namespace, cluster, and Keystone deployment. Keystone also records the mapping ruleset version so a token is rejected if its authorization rules change later.

See Identity Mapping Rules and API for Kubernetes claim examples and rule management. See the administrator guide for cluster registration, CA trust, and operational requirements.

Identity Mapping Rules and API

Domain managers use the Identity Mapping Engine to resolve external identities from federation, Kubernetes, SPIFFE, and API clients into Keystone principals and security contexts. Instead of hardcoding authentication logic for each provider, Keystone evaluates ordered rules that the domain manager controls.

Deployment configuration and cluster-wide operational requirements are covered in the administrator guide.

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": []
}

Ruleset workflow

After the operator registers the SPIFFE trust domain, create a mapping ruleset with source type spiffe and the appropriate trust_domain. Match rules against spiffe.id or spiffe.trust_domain claims. Operator-only SPIFFE and admin-interface configuration belongs in the administrator guide.

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/mappings/rulesetsCreate a new ruleset. Requires source, domain_resolution_mode, enabled, and rules.
ListGET /v4/mappings/rulesetsList rulesets. Filterable by domain_id, enabled, limit, and marker.
ShowGET /v4/mappings/rulesets/{mapping_id}Get detailed rule definitions for a ruleset.
UpdatePUT /v4/mappings/rulesets/{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/mappings/rulesets/{mapping_id}Remove a ruleset.
MutatePOST /v4/mappings/rulesets/{mapping_id}/rules/mutateInsert, update, or delete individual rules atomically.

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": []
      }
    ]
  }
}

Administrator configuration

The operator must configure the cluster salt and enforce cluster-wide security limits before mapping-backed authentication can be used. See Identity Mapping Administration.

Administrator Guides

This section is for operators and delegated domain administrators deploying and managing Keystone-NG.

  • Getting started covers initial bootstrap.
  • Configuration explains deployment-oriented configuration.
  • Operations covers health, metrics, upgrades, and recovery.
  • Command-line tools documents the service and administration commands used by operators.
  • Security collects production guardrails.
  • API policy enforcement documents OPA integration.
  • Fernet tokens and distributed storage provide dedicated runbooks.
  • Feature guides cover passkeys, federation, OAuth2/OIDC, SCIM and its API keys, Kubernetes authentication, identity mapping, LDAP, dynamic auth plugins, the audit trail, and OpenTelemetry export.

Client requests and authentication flows belong in the user documentation. Implementation and test guidance belongs in the contributor documentation.

Getting Started as an Administrator

Install Keystone-NG and configure its database, OPA service, and interfaces before bootstrapping identity resources. See Installation and Configuration.

Bootstrap

Bootstrap requires [interface_admin] and creates or updates the default domain, admin project, admin user, standard roles, implied-role hierarchy, and system/project admin assignments. Catalog endpoints are added when their URL options are supplied.

OS_BOOTSTRAP_PASSWORD='<password>' \
  keystone-manage --config /etc/keystone/keystone.conf bootstrap

The username and project default to admin. Use keystone-manage bootstrap --help for the current username, project, region, and endpoint options.

After bootstrap, authenticate through POST /v3/auth/tokens as described in the user authentication guide and check /health and /ready on the metrics listener.

Administrator Configuration Guide

Keystone reads an OpenStack-style INI file. Values are merged in this order, with later sources winning:

  1. The file selected with keystone --config.
  2. The optional file named by KEYSTONE_SITE_VARS_FILE.
  3. Environment variables prefixed with OS_, using __ between section and option.

For example, OS_API_POLICY__OPA_BASE_URL=http://opa:8181 overrides opa_base_url in [api_policy].

The configuration reference lists every section and directs operators to the authoritative detailed guide.

Interfaces

Use [interface_public] for the public HTTP listener. Terminate public HTTPS at a trusted reverse proxy or load balancer and restrict direct access to the listener. Public SPIFFE is not currently implemented.

The optional [interface_internal] listener supports SPIFFE mTLS and requires SPIFFE_ENDPOINT_SOCKET. [interface_admin] uses SPIFFE mTLS over a Unix-domain socket and can restrict peer UID and GID. [interface_metrics] exposes health, readiness, and Prometheus metrics and must not be placed on an untrusted network.

Core Services

  • [database] selects the SQL connection.
  • [api_policy] selects OPA and an optional local policy directory.
  • [auth] enables built-in and registered authentication methods.
  • [token] selects the token provider and lifetime.
  • [fernet_tokens] configures the Fernet key repository.
  • [distributed_storage] enables OpenRaft storage and its KEK and transport settings.

Feature-specific configuration belongs with the corresponding administrator guide. Do not copy partial distributed-storage examples into production; follow the complete storage runbook.

Operations

Health and Readiness

The dedicated [interface_metrics] listener defaults to 0.0.0.0:8099.

curl http://keystone:8099/health
curl http://keystone:8099/ready

Both endpoints report database, policy-engine, and distributed-storage status. /ready returns 503 Service Unavailable for degraded components; /health allows warning states to remain 200 OK.

With distributed storage enabled, a node is only ready when all of the following hold; otherwise the raft component reports warn with the reasons:

  • the Raft cluster is initialized and a leader is known;
  • when the node is the leader, a quorum acknowledged it within the last 6 seconds;
  • the applied index trails the cluster commit index by at most 1000 entries (a node installing a snapshot or catching up after a partition, including one that has not applied a new DEK epoch yet, is not ready);
  • no partition is quarantined on this node.

These states are not fatal, so they affect only readiness: use /health for liveness and startup probes. A Raft core that has stopped with a fatal error (storage failure, panic, shutdown) is the one exception — it is reported as error, which fails /health as well, so the liveness probe restarts the pod. In Kubernetes, run the storage StatefulSet with podManagementPolicy: Parallel, otherwise a full restart of a multi-node cluster waits for the first pod to become ready, which it cannot do without a quorum.

Metrics and Logging

GET /metrics returns Prometheus text format. Current exported metrics include audit event, drop, queue-depth, spool, sink and key-version series (keystone_audit_*, listed in ADR 0023 §Observability; ready-made alert rules are in deploy/prometheus/alert_rules.yaml) and dynamic-auth-plugin load failures, and HTTP, authentication, policy, token, rate-limit and cache series. The same data can be pushed over OTLP; see OpenTelemetry.

With distributed storage enabled, /metrics also exports the per-node keystone_raft_* series (alert rules in the keystone_raft group of the same file):

MetricMeaning
keystone_raft_is_leader, _term, _current_leader_idLeadership as seen by this node (_current_leader_id is -1 when none)
keystone_raft_membership_voters, _membership_learnersSize of the effective membership
keystone_raft_last_log_index, _last_applied_index, _apply_lagLog position; _apply_lag is cluster commit index minus applied index
keystone_raft_replication_lag{peer_id}Per-peer lag, on the leader only
keystone_raft_apply_duration_secondsState-machine apply latency histogram
keystone_raft_quarantined_partitions{partition}, _quarantined_partitions_countPartitions quarantined on this node
keystone_raft_gcm_failures_totalAES-GCM verification failures on state reads (quarantine trigger)
keystone_raft_dek_version, _dek_retired_epochs, _dek_revoked_epochsActive DEK epoch and epochs still held
keystone_raft_dek_pending_rotationEmergency rotations awaiting confirmation
keystone_raft_dek_reencrypt_migrated_total, _skipped_total, _last_skippedBackground re-encryption progress after a rotation
keystone_raft_log_nonce_counter, _log_nonce_remainingLog-encryption nonce counter and values left before the 2^31 limit
keystone_raft_write_rate_version_maxHighest per-record write version seen since start (limit 2^30)
keystone_raft_snapshot_last_index, _snapshot_size_bytes, _snapshot_age_secondsLatest snapshot
keystone_raft_disk_space_bytes, _log_disk_space_bytesFjall database and Raft log disk usage
keystone_raft_audit_spool_bytes, _audit_dropped_total, _audit_channel_depth, _audit_channel_capacityStorage audit spool size, dropped records, queued records and channel capacity

Keystone uses structured logging. Protect logs because request and identity metadata can be sensitive, and never enable logging that records bearer tokens, credentials, or decrypted policy input.

Upgrades

  1. Back up the database, distributed storage, and key repositories.
  2. Review configuration and migration changes.
  3. Apply database migrations with keystone-manage db sync.
  4. Upgrade one instance at a time where the deployment supports rolling replacement.
  5. Verify health, readiness, token issuance, token validation, and representative authorized API calls before continuing.

Coordinate Fernet and distributed-encryption key changes separately from binary upgrades. Follow the dedicated Fernet and distributed-storage procedures.

Recovery

Preserve the database, all Fernet and credential key repositories, distributed storage, the configured KEK, and OPA policies. A database backup without its matching encryption material is not sufficient for recovery.

Command-line tools

Keystone-NG provides two command-line programs:

  • keystone runs the API service or emits its OpenAPI schema.
  • keystone-manage performs bootstrap, catalog, database, key, OAuth2, and distributed-storage administration.

Run either command with --help, and run nested keystone-manage commands with --help, for the exact options in the installed version.

keystone service command

keystone runs the service using /etc/keystone/keystone.conf by default.

keystone --config /etc/keystone/keystone.conf
OptionMeaning
-c, --config <path>Select the configuration file.
-v, --verboseIncrease logging verbosity; repeat for more detail.
`–dump-openapi <yamljson>`
-h, --helpShow help.
-V, --versionShow the version.

The documentation workflow generates its schema with:

cargo run -p openstack-keystone --bin keystone -- --dump-openapi yaml \
  > doc/src/openapi.yaml

The generated file is ignored by Git and regenerated by the documentation workflow before each build.

keystone-manage administration command

keystone-manage performs administrative operations using the configured database, admin interface, key repositories, or distributed-storage endpoint.

keystone-manage --config /etc/keystone/keystone.conf <command>
CommandPurpose
bootstrapCreate or update the initial domain, project, user, roles, assignments, and optional catalog endpoints.
catalog serviceCreate, list, show, update, or delete catalog services.
catalog endpointCreate, list, show, update, or delete catalog endpoints.
`credential setupmigrate
`db syncup
`oauth2 client createlist
oauth2 revokeRevoke the refresh token families of --user, --client or --all of a --domain (POST /v4/oauth2/{domain_id}/revocations).
oauth2 ensure-signing-keyProvision a missing domain signing key.
`oauth2 rotate-signing-keyconfirm-rotate-signing-key`
`oauth2 list-local-emergency-candidatesreconcile-local-emergency-key`
`storage initjoin
`storage backuprestore
`storage rotate-dekconfirm-rotate-dek`
`storage list-dek-local-emergency-candidatesreconcile-dek-local-emergency`
`token setuprotate`

Use <command> --help before execution. Follow the linked administrator runbook for Fernet tokens, OAuth2/OIDC, or distributed storage; command help describes syntax, while the runbook describes ordering and safety constraints.

Security Guidance

  • Terminate public TLS at a trusted proxy and isolate the plain HTTP listener.
  • Restrict the admin Unix socket and SPIFFE identities to trusted operators.
  • Keep the metrics listener on a monitoring network.
  • Keep OPA enabled in production and treat the policy bundle as trusted code.
  • Store Fernet, credential, OAuth2 signing, audit, and distributed-storage keys as secrets; never print their contents during verification.
  • Keep the audit spool directory ([audit] spool_dir) readable and writable by the Keystone user only, keep hmac-key.bin at mode 0600, and back it up like the Fernet keys. Investigate every *.quarantine-* file; Keystone never deletes one. See Audit trail.
  • Keep insecure_allow_null_key = false except during a controlled migration.
  • Use PKCS#11 or TPM KEK protection and authenticated transport for production distributed storage.
  • Configure stable, cluster-wide WebAuthn decoy-key material to prevent account enumeration differences between nodes.
  • Match identity mapping rules (SPIFFE, Kubernetes, federation, API client) on the narrowest claim that identifies the intended workload, and reserve is_system: true for genuine control-plane identities — an overbroad match or an unwarranted is_system grants real access, unlike an unmapped caller which is denied outright. See Identity Mapping Administration.

The contributor security model is normative for scope, delegation, rescope, credentials, tokens, and OPA input. Operator procedures must not weaken those invariants.

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.

Fernet Tokens

Fernet is the default token provider. Keystone-NG uses the same integer-indexed key repository model as Python Keystone so that both services can issue and validate tokens when they share an identical key set.

Fernet tokens are bearer credentials. Protect the key repository as secret material and use TLS for every client-facing connection.

Configuration

[token]
provider = fernet
expiration = 3600

[fernet_tokens]
key_repository = /etc/keystone/fernet-keys/
max_active_keys = 3
insecure_allow_null_key = false

expiration is the token lifetime in seconds. key_repository defaults to /etc/keystone/fernet-keys/, and max_active_keys defaults to 3.

insecure_allow_null_key is a migration-only escape hatch. Keep it false in production. Keystone refuses to start when the repository contains the well-known Null Key unless this option is explicitly enabled.

Key Repository

Each key is stored in a file named with an integer:

  • 0 is the staged key for the next rotation.
  • The highest positive index is the primary key used to encrypt new tokens.
  • Lower positive indexes remain available to decrypt tokens issued before a rotation.

max_active_keys includes the staged key. With the default value of 3, the repository retains 0 and up to two positive-index keys. Choose the rotation schedule and retained-key count so a key is not pruned while tokens encrypted with it can still be valid.

All Rust and Python Keystone nodes must use the same complete file set. Do not initialize or rotate separate repositories independently on different nodes. Use a shared secret volume or distribute the complete repository to every node before a node begins issuing tokens with a new primary key.

Initial Setup

Run setup once against the deployment’s canonical repository before serving token requests:

keystone-manage --config /etc/keystone/keystone.conf token setup

The command creates key 0. It overwrites an existing staged key, so do not rerun it as a routine startup action. Run the command as the account that should own the repository; generated key files are written with owner-only permissions.

Verify file names, ownership, and modes without printing key contents:

find /etc/keystone/fernet-keys -maxdepth 1 -type f -printf '%f\n' | sort -n
stat -c '%a %U:%G %n' /etc/keystone/fernet-keys/*

Rotation

Rotate the canonical repository with:

keystone-manage --config /etc/keystone/keystone.conf token rotate

Rotation performs three operations:

  1. Promotes staged key 0 to the next positive primary index.
  2. Generates a new staged key at index 0.
  3. Removes the oldest positive-index keys beyond max_active_keys.

Tokens are not re-encrypted. They remain usable only while the key that issued them is retained. The command does not check token lifetime before pruning, so operators must make the retention window longer than the maximum lifetime of tokens that may still be accepted.

The running Rust service watches the repository for changes and also polls it every 30 seconds. A valid rotation is loaded without restarting Keystone. If a reload fails, the service logs the failure and continues using the previous valid in-memory key set.

Multi-Node Rotation

For a shared repository, run the rotation command once. Every Rust process watching that repository reloads the resulting key set.

For repositories copied to each node:

  1. Back up the current canonical repository securely.
  2. Rotate one canonical copy.
  3. Distribute the complete new file set to every Rust and Python node.
  4. Confirm every node has the same indexes before returning normal traffic.

Do not rotate one node and leave it issuing tokens before the other nodes have the new primary key; those nodes will be unable to validate its tokens.

Troubleshooting

Keystone reports that no usable Fernet keys were found

  • Confirm key_repository points to the mounted directory.
  • Confirm setup has been run and the service account can read the key files.

Tokens fail validation after rotation

  • Compare the key file indexes on every node.
  • Check whether max_active_keys pruned a key before its tokens expired.
  • Check the service log for a failed repository reload.

Keystone refuses to start because of the Null Key

  • Replace the Null Key through a controlled repository migration.
  • Do not enable insecure_allow_null_key as a production workaround.

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>. Every subcommand except init and join takes --cluster-addr <URI>, the cluster member to contact; it defaults to node_cluster_addr from the config file when omitted. init always targets that local address, and join takes the member to contact as its positional argument.

SubcommandTargetDescription
initlocal nodeBootstrap a new single-node cluster
join <cluster-addr>leaderAdopt the cluster DEK, then join as a learner
promote [--cluster-addr] <node-id>leaderPromote a learner to voting member
demote [--cluster-addr] <node-id>leaderDemote a voter to non-voting learner
remove-peer [--cluster-addr] <node-id>leaderRemove a voter or a learner from the cluster membership
transfer-leader [--cluster-addr] [<node-id>]leaderHand leadership over to another voter
list-peers [--cluster-addr]any nodeShow cluster peers in a table
metrics [--cluster-addr]any nodeShow raw cluster metrics and leader status
status [--cluster-addr]any nodeShow DEK, rotation, quarantine and nonce state of the node
clear-quarantine [--cluster-addr] [--partition <name>]leaderClear a GCM-failure quarantine (default partition data)
rotate-dek [--cluster-addr] [--emergency]leaderRotate the Data Encryption Key
rotate-dek [--cluster-addr] --local-quorum-bypass --justification <text>given nodeStage a node-local emergency DEK candidate (no quorum)
confirm-rotate-dek [--cluster-addr] --rotation-id <id>leaderConfirm a pending emergency DEK rotation
list-dek-local-emergency-candidates [--cluster-addr]given nodeList node-local emergency DEK candidates
reconcile-dek-local-emergency [--cluster-addr] --rotation-id <id>leader onlyInstall a node-local emergency DEK candidate through Raft
backup [--cluster-addr] --output <file>leaderCreate an encrypted Fjall snapshot
restore [--cluster-addr] --snapshot <file> [--elect]leader *Restore an encrypted snapshot to the cluster

Leader targeting. Membership changes, DEK rotations, quarantine clearing, backup and restore into a running cluster must be proposed by the Raft leader. A follower answers these RPCs with UNAVAILABLE plus the leader’s id and address (x-openraft-leader-id, x-openraft-leader-endpoint), and the subcommands marked leader retry against that address, so --cluster-addr may name any member. The call is not proxied between nodes: the operator’s own identity reaches the leader, which is where the request is authorized, rate limited and audited. reconcile-dek-local-emergency (leader only) does not follow the redirect; it prints the leader’s address so the operator can first check that the leader holds the candidate. (*) Disaster-recovery restore into an uninitialized node runs on the given node.

demote and remove-peer of the current leader first hand leadership over to another voter (the same operation as transfer-leader), wait until it leads, and then apply the change on the new leader, so writes are not interrupted by an election. Run transfer-leader before a planned restart of the leader node for the same reason. A voter that lags the leader rejects the transfer; the command then tries the next voter. The transfer is audited as LEADERSHIP_TRANSFERRED. Successors are tried in node-id order, not ranked by replication lag. The leader cannot tell a target that refuses from one that simply does not answer, so each candidate that does not take over within 5 seconds (lagging, unreachable or not yet upgraded) adds that wait before the next one is tried; if none does, the error lists every candidate. If the transfer succeeds but the new leader cannot be reached afterwards, the command fails with a message saying so; the transfer is not rolled back, so retry the command.

transfer-leader relies on a node-to-node TransferLeader RPC added in this release. During a rolling upgrade a voter that is not yet upgraded answers it with UNIMPLEMENTED and never takes over. The transfer needs an upgraded leader (it serves the admin RPC) and at least one upgraded remaining voter; each non-upgraded voter tried before an upgraded one adds the wait above.


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        │ Reads (ReadIndex)
┌───────▼───────────────────────▼─────────────────────────────────┐
│  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

  • Every tier: A ReadIndex (linearizable read) is issued to OpenRaft first, ensuring no stale follower can return a value that has since been revoked. Tiers are not carried on writes yet, so there is no cheaper local read path for Tier 0 / 1 (planned).

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(salt, DEK, "keystone-raft-log-v1",    L=32)
  ├── State DEK   HKDF(salt, DEK, "keystone-fjall-state-v1", L=32)
  └── Backup DEK  HKDF(salt, DEK, "keystone-backup-v1"
                               ++ dek_version_u32_BE,          L=32)

Audit HMAC key  HKDF(salt, DEK, "keystone-audit-dek-v1"
                              ++ dek_version_u32_BE
                              ++ node_id_u64_BE,               L=32)

The DEK sub-keys use full HKDF with the fixed salt keystone-dek-hierarchy-v1. The DEK is already uniformly random, so Extract adds no entropy; it is run because Extract-then-Expand is the flow the FIPS module documents as an approved service. 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 DEK of the epoch it signs for, so it rotates with every DEK epoch swap, 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 >= write_rate_threshold (default 2^30, approximately 1 billion writes per record), further writes to that key are blocked with a WRITE_RATE_EXCEEDED violation and a CRITICAL log entry is emitted; a WARN is logged from 90% of the threshold. The per-record version keeps counting across DEK rotations (re-encryption increments it too), so a rotation does not lift the block for a record that reached it.

Backup Encryption

Function: backup_encrypt(bdek, snapshot_bytes, dek_version, utc_epoch, nonce_salt) → Vec<u8>

On-disk layout: [dek_version_u32_BE; 4] ++ [utc_epoch_u64_BE; 8] ++ [nonce_salt_u64_BE; 8] ++ [nonce_12b][ciphertext][tag_16b]

FieldValue
Associated datab"keystone-backup-v1" ++ utc_epoch_u64_BE ++ dek_version_u32_BE ++ nonce_salt_u64_BE

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. nonce_salt is a fresh random 64-bit value generated per snapshot and mixed into the nonce derivation alongside utc_epoch, so two snapshots written within the same wall-clock second (including across a process restart, when a sequential in-memory counter would otherwise reset to 0) still get distinct nonces; it is stored directly in the header, so decryption reads it rather than searching for it. The header also carries a DEK manifest: the current DEK and every retired DEK the snapshot may still need, each wrapped under the KEK. A node holding the same KEK can therefore decrypt the backup even if it does not know these DEKs yet.

Nonce Management

The NonceManager (storage-crypto/src/nonce.rs) maintains a durable monotonic counter for Raft log nonces:

  • Keeps one counter per DEK epoch, persisted node-locally in Fjall under _meta:nonce:<node_id>:ctr:<epoch> with its high-water mark under _meta:nonce:<node_id>:hwm:<epoch>. A DEK rotation therefore starts a fresh counter. A counter is never reset, so an epoch that becomes current again (after a restore) continues where it stopped. Snapshot installs and restores keep the node’s own counters.
  • Nodes upgraded from the single per-node counter (_meta:nonce_ctr:<node_id>) record the epoch current at upgrade time; that epoch and every older one start no lower than the old counter.
  • 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 is behind the HWM (operator intervention required).
  • Emits a WARN when fewer than 10% of the 2^31 threshold remain. From that point the leader rotates the DEK automatically (see DEK Rotation). At 2^31 the node can no longer append log entries under that DEK. The warning is rate limited, and the counter is exposed as keystone_raft_log_nonce_counter and keystone_raft_log_nonce_remaining.

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 read (opt-in)Feature flags, role display names
1INTERNALLocal read (opt-in)Display 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.

Local reads are off by default. Setting local_reads_mode = "local_for_public" lets a consumer ask for a stale local read of a single call; the node then serves Tier 0/1 records from its own state machine without contacting the leader and may return data older than the latest committed write; the staleness is unbounded, since a lagging or partitioned node serves whatever it last applied. Records without metadata or with an unknown tier are never served locally, and records written without an explicit tier default to Tier 1. Without the option, or for Tier 2/3 records (and prefix scans that match one), the request is ignored and the read goes through ReadIndex. Index key listings are always linearizable.


Intra-Cluster Transport (mTLS)

All cluster communication uses mutual TLS with the rustls defaults (TLS 1.2 is not disabled; the node does not restrict protocol versions or cipher suites itself). Every peer must present a valid mTLS identity, including a node that auto-joins through retry_join_nodes.

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 are mapped to a role (see Peer Roles); anything that maps to no role is rejected with PERMISSION_DENIED at the gRPC interceptor.
  • Each node pins the SVID it presents: the first allowed_peer_svids entry’s path when that list is set (the cluster’s shared storage identity), else spiffe://<trust-domain><spiffe_path_prefix>node. Register that identity in SPIRE for every storage workload; if it is missing, storage initialization stalls in the SVID source retry loop and the node never becomes ready.
  • allowed_peer_svids must be non-empty when dev_mode = false; the configuration is rejected otherwise.

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. It only logs; it does not shut the node down at expiry. It watches a certificate given inline (tls_cert_content), not one given as a file.

  • The peer role is read from the first URI SAN of the client certificate. Set tls_role_san_prefix to the SAN URI prefix; the role name follows it. In TLS mode the role names are fixed to node and storage-operator; operator_role is not honoured:

    [distributed_storage]
    tls_role_san_prefix = "spiffe://keystone/storage/"
    # node cert SAN URI:     spiffe://keystone/storage/node
    # operator cert SAN URI: spiffe://keystone/storage/storage-operator
    
  • Without tls_role_san_prefix the configuration is valid only with dev_mode = true, where every CA-signed peer is accepted for every RPC (legacy permissive behaviour, a warning is logged). With dev_mode = false it is a configuration error.

  • The role is never derived from the certificate CN.

Peer Roles

Every inter-node and operator RPC is authorised by the role of the calling certificate. In SPIFFE mode (spiffe_path_prefix defaults to /keystone/storage/, operator_role to storage-operator):

Peer SVIDRole
spiffe://<td><spiffe_path_prefix>nodeNode
spiffe://<td><spiffe_path_prefix><operator_role>Operator
exact entry of allowed_peer_svidsNode
anything else (including the former /ns/ form)rejected

The /ns/... SVID fallback has been removed. An allowed_peer_svids entry that lies under spiffe_path_prefix is resolved by the role convention, not by the allow-list, so it cannot be used to grant the operator role to an arbitrary name.

RPCNodeOperator
RaftService.*yesno
StorageService.{Command, ForwardedGet, ForwardedPrefix, ForwardedPrefixIndex, ReportQuarantine}yesno
ClusterAdmin.{FetchDek, GossipLocalEmergencyCandidate}yesno
ClusterAdmin.{AddLearner, Metrics}yesyes
ClusterAdmin.{Init, ChangeMembership} and all other operator RPCs (backup, restore, rotate DEK, clear quarantine, …)noyes

Which identity each command uses. In SPIFFE mode keystone-manage takes its SVID from the Workload API of the workload it runs in:

  • join presents the node’s own SVID (allowed_peer_svids[0], else <spiffe_path_prefix>node) and therefore needs the Node role. Run it on the joining node, whose node_id and node_cluster_addr it announces.
  • Every other subcommand presents the workload’s only SVID. list-peers and metrics accept Node or Operator; init, promote, demote, remove-peer and all other operator RPCs need Operator. Run these from an operator workload registered as <spiffe_path_prefix><operator_role> (and nothing else), not from a storage node: a node holds the Node role only, and a workload holding several SVIDs presents an arbitrary one. The workload needs a distributed_storage section so the CLI knows trust_domains and the cluster address (node_cluster_addr; subcommands with a --cluster-addr option can override it). The skaffold test cluster does this with the keystone-storage-operator Deployment (tools/k8s/tests/keystone-storage-operator.yaml).
  • When SPIRE is run by the helm chart’s controller-manager, every ClusterSPIFFEID must set spec.className (default spire-spire); classless ones are silently ignored and the workload never receives the SVID.

The data-plane StorageService.Command RPC accepts only a Transaction made of Set, Remove, RemoveIndex, CreateIfAbsent and SetIndex mutations. Restore, DEK install, quarantine and rotation commands are rejected with PERMISSION_DENIED there; they are proposed in-process only.

Upgrade note (breaking change). Role resolution is stricter than in earlier builds. The role path convention is resolved first: any SVID under spiffe_path_prefix must be exactly <spiffe_path_prefix>node or <spiffe_path_prefix><operator_role>. An SVID such as spiffe://<td>/keystone/storage/node-1 is under the prefix with an unknown role name and is denied even when listed in allowed_peer_svids; re-register it as <spiffe_path_prefix>node. allowed_peer_svids is only for SVIDs outside the prefix (for example spiffe://<td>/ns/<namespace>/sa/<sa>), which must be listed verbatim. Before upgrading, register every storage workload under <spiffe_path_prefix>node, and the operator under <spiffe_path_prefix><operator_role>. In TLS mode, add the role SAN and set tls_role_san_prefix.

Rate limits

Per-identity rate limiters (for example 2 DEK rotations per hour, 10 ClearQuarantine calls per hour, 30 quarantine reports per hour) are held in memory on each node. They are not replicated through Raft and reset on restart. These RPCs are only served by the leader (a follower redirects before taking a token), so the limit applies per leader: it starts afresh after a leader change.

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), the node logs a prominent warning and starts anyway: failing closed there would make recovery impossible when every node of the cluster restarts at the same time. The live check against a peer’s membership runs only when retry_join_nodes is configured.


Audit Log

Every security-relevant operation is signed and written to a durable, fsynced local spool. An external shipper (for example Filebeat or Vector) tails the spool and forwards it to the SIEM; the storage engine itself does not open a network connection to the SIEM.

Spool line structure (one JSON object per line):

{
  "record": {
    "timestamp": 1750000000,
    "event_type": "DEK_ROTATION",
    "actor": "operator@example.org",
    "node_id": 1,
    "dek_version": 3,
    "details": {}
  },
  "key_version": 3,
  "hmac": "<hex>"
}

Signature: HMAC-SHA256(AuditHmacKey, record_bytes), where record_bytes is the exact bytes of the record object as written in the line (do not re-serialise it before verifying).

Key derivation: the key is derived from the DEK of the epoch it signs for, not from the KEK: HKDF(salt, DEK, info = "keystone-audit-dek-v1" ‖ dek_version_u32_be ‖ node_id_u64_be). It rotates with every DEK epoch swap, on every node, and the key_version field names the epoch whose key signed the line — verifiers select the key by key_version. The node_id binding gives each node a distinct key. Because the key derives from the DEK, a verifier needs the epoch’s key material exported from a cluster node; export and retention of epoch keys is an operator responsibility and is not automated.

Availability: the spool is bounded by audit_max_spool_bytes (default 256 MiB, directory audit_spool_dir, default <path>/audit-spool). At 90% a CRITICAL alert is logged; at 100% the oldest sealed segment is deleted and an ERROR is logged. Spool contents are not encrypted at rest (files are created 0600). Spool exhaustion or write failure never blocks writes to the identity store, but dropped records are counted in keystone_raft_audit_dropped_total; the current spool size is exposed as keystone_raft_audit_spool_bytes.

Shipping: when [audit] sink is configured (see Configuration options), Keystone ships the sealed raft-audit-<node_id>.jsonl.seg-* segments to that sink verbatim and deletes each one after the sink acknowledged it; the live file is not touched. Delivery is at-least-once, so receivers should deduplicate. Without a sink, an external shipper must tail the spool as before. The records keep their own signature scheme described above; they are not CADF events.

Ordering and failures: a record is emitted only after the Raft write it describes has been attempted. A failed operation produces <EVENT>_FAILED with the error in details.error, never the success event. Every node additionally emits DEK_INSTALLED when it applies a DEK epoch swap.

Audited events include: DEK_ROTATION, DEK_ROTATION_EMERGENCY_STAGED, DEK_ROTATION_EMERGENCY_CONFIRMED, DEK_ROTATION_EMERGENCY_ABORTED, DEK_ROTATION_LOCAL_EMERGENCY_STAGED, DEK_ROTATION_LOCAL_EMERGENCY_RECONCILED, DEK_INSTALLED, LEADERSHIP_TRANSFERRED, CLUSTER_DEK_ADOPTED, QUARANTINE_CLEARED, BACKUP_CREATED, BACKUP_RESTORED, and the _FAILED variants of the Raft-committed operations.

Every restore attempt by an authenticated operator leaves exactly one record: BACKUP_RESTORED on success, BACKUP_RESTORED_FAILED when it is refused, aborted (stalled or disconnected upload, size overrun or mismatch), rejected by the cluster at apply time, or fails during a disaster recovery install. The failure record carries the mode (cluster or disaster_recovery, null if the attempt failed before it was known), declared_len, received_len, the gRPC code and the error message in error. It never contains backup contents or key material. Like the success record it is best effort and does not mask the restore error. A restore sent to a follower is redirected to the leader without a record: nothing was attempted on that node.


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:<partition>:<node_id>) and therefore persists across restarts and is visible to all cluster members. A marker blocks reads only on the node that reported it; clear-quarantine clears the partition’s markers of all nodes. 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.

Reporting Quarantine from Followers

A follower that detects a GCM failure asks the leader to commit the quarantine marker through the dedicated StorageService.ReportQuarantine RPC (Node role). The leader validates the report against the current Raft membership, applies a rate limit of 30 reports per hour per reporter identity, and writes an audit record.

All nodes share the node role, so the reported node_id is not bound to the caller’s certificate. A compromised node can therefore report a quarantine for the partition of any cluster member. The exposure is bounded by the membership check, by the rate limit (which, because every node presents the same identity, is effectively one bucket per serving node), and by the audit record. Recovery is ClearQuarantine, which is operator-only. Binding each node to its own SVID is deferred (see ADR 0016-v2 addendum on peer roles).


DEK Rotation

The Raft leader rotates the DEK automatically when either holds:

  • Age: the current DEK was installed dek_rotation_days ago (default 90; 0 disables this trigger). Each node records when it applied the DEK under _meta:dek:installed_at; nodes upgraded from a version that did not record it start the interval at their first start after the upgrade.
  • Volume: the leader’s log nonce counter for the current DEK has used 90% of its 2^31 space. This trigger cannot be disabled.

The leader checks every 5 minutes and skips the check while an emergency rotation waits for its confirmation. An automatic rotation is the same InstallDek proposal as a manual one and is audited as DEK_ROTATION with the actor system:dek-rotation and the trigger in the details. A rotation proposes the next DEK version; when two rotations race for the same version (for example an automatic and a manual one), the second is rejected with a STALE_DEK_VERSION violation and the installed DEK is kept. 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: Each node re-encrypts its own Fjall records under the new DEK in a background task, keyspace by keyspace in key order. A record is read, re-encrypted with version + 1 and written while apply() is excluded from that record, so a concurrent Raft write is never reverted. A record that cannot be migrated after 3 attempts is skipped and logged at WARN. The sweep runs when a rotation is applied and again every 5 minutes for every retired DEK that still has records under it, so skipped records are retried without waiting for the next rotation. A pass that finds nothing left to migrate marks the retired DEK as done (_meta:dek:reencrypt_done:<version>). Retired DEKs are kept for reading older snapshots and backups.

Progress: Every 1000 records the sweep checkpoints its position and counts to _meta:dek:rotation_progress:<node_id>:<version>. After a restart the next pass resumes behind the checkpoint, and records skipped before the restart still keep the retired DEK from being marked done. The checkpoint is removed when a pass completes and is not carried by snapshots.

Not implemented: a post-rotation verification report, the CRITICAL alert for records left unmigrated for 24 hours, and blocking DEK retirement until they are resolved. Skipped records are only visible in the WARN logs and are retried by the periodic sweep.


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
# (default: 0.0.0.0:8081).
node_listener_addr = "0.0.0.0:8310"

# Development mode: accepts the env KEK and, in TLS mode without
# tls_role_san_prefix, any CA-signed peer. Must be false (default) in
# production; tools/check_no_dev_mode.sh flags service definitions that set it.
# dev_mode = false

# Peers to auto-join on startup, as comma separated `id=address` pairs. Node
# id 0 is the bootstrap node: it initializes itself with the full list as the
# initial membership. Any other node adds itself as a learner on the first
# listed peer that answers (after adopting the cluster DEK). The join is still
# authenticated by mTLS. Without the list the nodes are joined manually with
# `init` and `join`. All nodes must share the same KEK material.
# retry_join_nodes = "0=https://keystone-0:8310,1=https://keystone-1:8310,2=https://keystone-2:8310"

# Set to false on nodes that are about to receive a disaster recovery
# `restore`, so that they are not initialized on first start (default: true).
# auto_bootstrap = true

# Reads that cannot be confirmed linearizable are retried this many times
# (default: 80) with this delay in milliseconds (default: 50) before they
# fail with `Unavailable`.
# ensure_linearizable_retries = 80
# ensure_linearizable_retry_delay_ms = 50

# Allow consumers to request stale local reads of Tier 0/1 records
# (`disabled` (default) or `local_for_public`).
# local_reads_mode = "disabled"

# Raft timing and log compaction. Raise the heartbeat and election timeouts for
# clusters spread across availability zones. `raft_snapshot_logs_since_last = 0`
# disables automatic snapshots. Defaults shown.
# raft_heartbeat_interval_ms = 500
# raft_election_timeout_min_ms = 1500
# raft_election_timeout_max_ms = 3000
# raft_snapshot_logs_since_last = 5000
# raft_max_in_snapshot_log_to_keep = 1000
# raft_purge_batch_size = 1

# Audit spool directory (default: <path>/audit-spool) and its size bound
# (default: 256 MiB).
# audit_spool_dir = "/var/lib/keystone/storage/audit-spool"
# audit_max_spool_bytes = 268435456

# Directory where Fjall database files are stored.
path = "/var/lib/keystone/storage"

# Age in days after which the leader rotates the DEK automatically
# (default: 90). 0 disables the age trigger; the log nonce volume trigger
# always stays on.
dek_rotation_days = 90

# Per-record write version at which further writes to the record are
# rejected (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"
# SVIDs outside spiffe_path_prefix that are accepted with the node role.
# Required when dev_mode = false. The first entry is also the SVID this node
# itself presents.
# allowed_peer_svids = "spiffe://example.org/keystone/storage/node"
# Role of the operator workload (default: storage-operator) and the path prefix
# of all storage SVIDs (default: /keystone/storage/).
# operator_role = "storage-operator"
# spiffe_path_prefix = "/keystone/storage/"

# --- 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 = "..."
# URI prefix of the SAN that carries the peer role (required when
# dev_mode = false), e.g. spiffe://keystone/storage/
# tls_role_san_prefix = "spiffe://keystone/storage/"

# The [distributed_storage.pkcs11] and [distributed_storage.tpm] subsections
# are described below; node-local emergency writes (quorum loss) are configured
# in the separate [local_emergency] section (ADR 0028).

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. tools/check_no_dev_mode.sh checks for this and runs in the Linters CI workflow.

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"

# Let the token generate the GCM IV when wrapping the DEK (PKCS#11 3.0
# message API, CKG_GENERATE_RANDOM). Needed for FIPS 140-3 HSMs that reject
# caller-supplied IVs. The token must implement C_MessageEncryptInit
# (SoftHSM2 does not). Default: false.
# pkcs11_token_generated_iv = true

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.

init registers the node_id and node_cluster_addr of the config it runs with as the first member, so its config must describe node 1. In SPIFFE mode it also needs the Operator role (see CLI Reference): run it from an operator workload whose distributed_storage section carries node 1’s values:

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

join first asks the local node, which must already be running, to fetch the cluster’s data encryption keys from the leader and install them, and only then registers it as a learner. The node therefore never replicates under the DEK it generated at first boot. All nodes must share the same KEK material, otherwise the adoption fails and the join is aborted. Re-running join on a node that is already a member is harmless. If the registration fails after the adoption (for example because the id is registered at a different address), the node keeps the adopted DEK, which is correct for the cluster; fix the cause and run join again. The local node must run a version that supports the adoption, otherwise join stops with an error asking you to upgrade it first.

Step 4 — Promote learners to voting members.

Run from an operator workload (SPIFFE mode: storage-operator SVID). The command locates the leader through --cluster-addr (default: the workload’s node_cluster_addr), so it can contact any member. 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 an operator workload):
keystone-manage storage promote 4

Kubernetes Reference Deployment

tools/k8s/keystone/overlays/production is the reference for running the Raft storage on Kubernetes. The skaffold overlay is for development only: it runs in dev mode with an environment KEK and keeps the storage on an emptyDir.

kubectl apply -k tools/k8s/keystone/overlays/production/spire
kubectl apply -k tools/k8s/keystone/overlays/production

What it sets up, and why:

  • Persistent storage. path (/storage) is a volumeClaimTemplates volume. It holds the Raft log, the state machine, the wrapped DEK, the nonce high-water mark (_meta:nonce_hwm), the quarantine markers and the audit spool. On an emptyDir a restarted pod comes back as a new node with the same node_id and a new DEK, and the nonce rollback protection is void.
  • Three voters. replicas: 3 and a matching retry_join_nodes list. Keep both in sync and use an odd number. podManagementPolicy: Parallel starts all members together; rolling updates still replace one pod at a time and wait for it to become ready.
  • Disruption and placement. A PodDisruptionBudget with maxUnavailable: 1 keeps a quorum through node drains, required podAntiAffinity keeps two members off the same node, and a topologySpreadConstraints entry spreads them over zones.
  • Identities. The StatefulSet runs under its own keystone-rs service account. The ClusterSPIFFEID entries in spire/ bind the <spiffe_path_prefix>node identity to that namespace, label and service account, so no other workload can become a Raft peer. Operator commands run from the keystone-storage-operator Deployment, the only workload holding <spiffe_path_prefix>storage-operator. It ships with replicas: 0; scale it up for a maintenance window only.
  • Trust domain. The SPIRE registrations in spire/ use {{ .TrustDomain }} and adapt to your cluster, but the keystone-side config hardcodes the example.org placeholder. Before applying, replace every example.org with your SPIRE trust domain in: the init-container heredoc in statefulset-patch.yaml (trust_domains, allowed_peer_svids), conf/keystone.conf (interface_internal.trust_domains, interface_admin.trust_domains, interface_admin.admin_svid), and storage-operator.yaml (trust_domains, allowed_peer_svids). A mismatch silently breaks intra-cluster mTLS and admin auth, because SVIDs that do not match the pinned values are rejected.
  • KEK. kek_provider = "pkcs11" with the token PIN mounted from the keystone-pkcs11-pin Secret (create it out of band). The published ghcr.io/openstack-experimental/keystone:main image is built with both the PKCS#11 and TPM features (see .github/workflows/container-publish.yml), so this overlay works as-is and a TPM-backed cluster can switch the config to kek_provider = "tpm" without changing the image. A self-built image must pass the same build argument, e.g. docker build --build-arg KEYSTONE_FEATURES=openstack-keystone/pkcs11,openstack-keystone/tpm . (add openstack-keystone/wasm_plugins as well if the image should keep dynamic auth plugin support, as the published image does). The image must also contain the vendor’s PKCS#11 module and client configuration at pkcs11_module_path. For a TPM, set kek_provider = "tpm" and give the pod access to the TPM device (/dev/tpmrm0), typically through a device plugin or, for a single-node test, a hostPath char-device mount; the image already ships the libtss2 runtime libraries required by tss-esapi.
  • Pre-flight. The pods run as non-root with all capabilities dropped and a read-only root file system. Keystone sets RLIMIT_CORE = 0 and PR_SET_DUMPABLE = 0 itself; neither needs a privilege. The container command raises the RLIMIT_MEMLOCK soft limit to the hard limit set by the container runtime. Outside of dev mode the node refuses to start when that is below 64 KiB; raise the runtime’s default (e.g. LimitMEMLOCK of the containerd systemd unit, default_ulimits in CRI-O) if it is. CAP_IPC_LOCK is not needed and has no effect for a non-root process.

After the first rollout keystone-rs-0 (node_id = 0) has initialized the cluster and the other members have joined as learners. Promote them from the operator workload:

kubectl -n keystone scale deploy/keystone-storage-operator --replicas=1
kubectl -n keystone exec deploy/keystone-storage-operator -- keystone-manage storage promote 1
kubectl -n keystone exec deploy/keystone-storage-operator -- keystone-manage storage promote 2
kubectl -n keystone scale deploy/keystone-storage-operator --replicas=0

keystone-rs-0 stays the bootstrap node: whenever it starts with an empty volume it initializes a new single-node cluster, splitting it off from the running one. If its volume is lost, start it with auto_bootstrap = false so it stays uninitialized, remove the stale member with keystone-manage storage remove-peer 0 from the operator workload, run keystone-manage storage join <address of a live member> in keystone-rs-0 and promote it again (see Adding Nodes).

The readiness probe uses /ready, which returns 503 while the storage is not initialized or reports a recoverable Raft issue (no known leader, a stale quorum acknowledgement, or a quarantined partition). The liveness probe uses /health, which fails only on an unrecoverable error such as a stopped Raft core.

Single-voter k3s smoke test

tools/k8s/keystone/overlays/k3s-test is a throwaway, single-voter overlay for verifying the two production KEK providers on a single-node k3s cluster. It is not a quorum test: the overlay drops the required pod anti-affinity and runs only keystone-rs-0.

The overlays render the same dependencies as the other Keystone overlays: a CloudNativePG Cluster for the bootstrap job’s database, SPIRE ClusterSPIFFEID registrations, and the keystone-py image used by the bootstrap job. Install CloudNativePG and the SPIRE controller manager on the k3s cluster first (the main keystone Skaffold module installs both).

Use the Skaffold modules described in the Raft KEK overlays on k3s section of the contributor development guide:

skaffold run -f skaffold-k3s-test.yaml \
  -p pkcs11 --default-repo localhost:5000 --tag raft-test --cleanup=false
skaffold run -f skaffold-k3s-test.yaml \
  -p tpm --default-repo localhost:5000 --tag raft-test --cleanup=false

The PKCS#11 variant uses SoftHSM2. It initialises a token on the StatefulSet volume and generates the keystone-kek AES-256 key automatically.

The TPM variant runs a swtpm sidecar on pod loopback and sends TPM2_Startup before starting keystone. This avoids depending on a host TPM, which may lack TPM2_EncryptDecrypt2 (for example, some ARM/virt implementations return 0x000b0143 — “command code not supported” — for it). Provision the sidecar state and the KEK context files on the k3s host before deploying the TPM module; the Skaffold module creates the keystone-tpm-kek Secret from those files.

A real host TPM can be used instead by pointing tpm_tcti at device:/dev/tpmrm0, removing the swtpm sidecar and the tpm2_startup call from tools/k8s/keystone/overlays/k3s-test/tpm/statefulset-patch.yaml, and provisioning the context files against the host TPM. The TPM must support TPM2_EncryptDecrypt2.

Tear down either variant with:

skaffold delete -f skaffold-k3s-test.yaml \
  -p pkcs11 --default-repo localhost:5000
skaffold delete -f skaffold-k3s-test.yaml \
  -p tpm --default-repo localhost:5000

The StatefulSet volume claim is deleted with the namespace.

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 (the raw metrics are one long line, shown as printed by the contacted node):

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:1, vote:<T1-N1:Q>, last_log:5, local_committed:1.5, cluster_committed:1.5, last_applied:1.5, leader:1(quorum_acked_time:10:32:00.294800, 483.80772ms ago), membership:{log_id:1.5, {voters:[{1:Node { node_id: 1, rpc_addr: "10.0.0.1:8310" },2:Node { node_id: 2, rpc_addr: "10.0.0.2:8310" },3:Node { node_id: 3, rpc_addr: "10.0.0.3:8310" }}], learners:[]}}, committed_membership:{log_id:1.5, {voters:[{1:Node { node_id: 1, rpc_addr: "10.0.0.1:8310" },2:Node { node_id: 2, rpc_addr: "10.0.0.2:8310" },3:Node { node_id: 3, rpc_addr: "10.0.0.3:8310" }}], learners:[]}}, snapshot:None, purged:None, replication:{1:1.5,2:1.5,3:1.5}, heartbeat:{1:10:32:00.294800,2:10:32:00.294800,3:10:32:00.294800}}

For a formatted peer table use list-peers instead.

Storage Status

The DEK, rotation, quarantine and nonce state the runbooks below refer to is reported per node by status:

keystone-manage storage status --cluster-addr https://10.0.0.2:8310

Sample output:

Node                  : 2
State                 : Follower
Current leader        : 1
Current term          : 3
Last log index        : 1042
Last applied index    : 1042
DEK version           : 4
Retired DEK versions  : 2, 3
Revoked DEK versions  : none
Pending rotations     : none
Quarantined (local)   : none
Quarantine records    : none
Nonce counter         : 18432 / 2147483648 (0.00%)

Quarantined (local) lists the partitions whose reads are blocked on this node; Quarantine records lists every marker the node holds, including those reported by other nodes. Pending rotations shows emergency rotations awaiting confirm-rotate-dek (authoritative on the leader). The nonce counter is the persisted reservation point (at most 1024 ahead of use); the node stops accepting log writes when it reaches the threshold. Run the command against each node of interest: DEK state can briefly differ while a follower applies a rotation, and quarantine is node-local.

Scheduled DEK Rotation

Automatic rotation fires after dek_rotation_days (default: 90) or when the leader’s log nonce counter for the current DEK reaches 90% of 2^31 (see DEK Rotation). Manual rotation:

keystone-manage storage rotate-dek \
  --cluster-addr https://10.0.0.1:8310

Monitor the audit log (event_type = "DEK_ROTATION"; automatic rotations use the actor system:dek-rotation) and the node logs for records the re-encryption sweep skipped (record skipped after exhausting CAS retries).

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 restarts when the confirmed rotation installs the new DEK.

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 the quarantine state of the affected node:
keystone-manage storage status --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>

Note: the partition is now the --partition option (default data). Scripts that passed it positionally (clear-quarantine <partition>) must be updated.

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, bound to the snapshot timestamp and current DEK epoch. Its header carries the DEK manifest: the current and retired DEKs, wrapped under the KEK.

Restore into a running cluster (the usual case):

keystone-manage storage restore \
  --cluster-addr https://10.0.0.1:8310 \
  --snapshot /mnt/backups/keystone-20260101.snap

The backup must reach the leader; a follower answers with the leader’s address and the command retries there. The backup is committed through the Raft log, in 256 KiB chunks followed by a single apply entry, so every node replaces its data at the same log index. The cluster membership (node list) and each node’s Raft state are unchanged. Everything else, including all data keyspaces and the DEKs, is replaced by the backup’s contents, so anything written after the backup was taken is gone. The DEKs recorded in the backup’s DEK manifest replace the cluster’s current DEK on every node, so the restored data stays readable. The cluster must use the same KEK material as the cluster that produced the backup; a backup that cannot be unwrapped is rejected by the apply entry and leaves the data untouched. The upload is streamed into the Raft log as it arrives, so the receiving node never buffers it; the client’s declared size (sent with the first chunk) lets an oversized backup be refused before any of it is read. A stalled upload is dropped after two minutes. The DEKs the restore replaces are kept on every node so that the node’s own Raft log and older snapshot files stay readable. Backups larger than 1 GiB are refused on this path (every node holds the reassembled and decrypted backup in memory while applying it); use the disaster recovery procedure for those. That procedure buffers the backup on each node (up to 4 GiB) and also needs it to be decrypted in full. A restore that fails part-way discards its staged chunks.

Disaster recovery (the cluster is gone, no leader exists):

# 1. Start every node with `auto_bootstrap = false` so it stays uninitialized.
# 2. Restore the same backup on each node; pass --elect on exactly one of them:
keystone-manage storage restore \
  --cluster-addr https://10.0.0.1:8310 \
  --snapshot /mnt/backups/keystone-20260101.snap --elect
keystone-manage storage restore \
  --cluster-addr https://10.0.0.2:8310 \
  --snapshot /mnt/backups/keystone-20260101.snap

This follows OpenRaft’s “restore from snapshot” procedure: the backup is installed as a Raft snapshot on each node and the backup’s Raft state, including its membership, is restored. The original node ids and addresses must therefore be reachable again. The elected node leads the next term. A node that is not part of the backup’s membership stays a learner and can be joined afterwards. A node that is already initialized always takes the live path instead, so a node that auto-initialized before the restore arrives restores into its own single-node cluster and keeps that membership. --elect on an initialized node is rejected with FailedPrecondition.

The restore validates the AES-256-GCM backup envelope (AD binding: epoch and dek_version) and decrypts it with the Backup DEK derived from the DEK epoch the backup names, unwrapped from the backup’s DEK manifest (or found on the node). The restoring cluster therefore needs the KEK the backup’s DEKs are wrapped under.

Retired DEK retention: Retired DEKs stay in the cluster, wrapped under the KEK, and every backup carries the ones it needs, so archived backups remain readable as long as the KEK is. There is no separate KMS role for backup or retired DEKs; access to archived backups is controlled through the KEK.


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. Every inbound connection must present a valid SPIFFE SVID or an operator-managed certificate signed by the cluster Intermediate CA. Auto-join through retry_join_nodes exists, but the joining node is authenticated like every other peer.

  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 rejects production service definitions containing --dev-mode or KEYSTONE_ALLOW_ENV_KEK. It runs in the Linters CI workflow; development artefacts (the skaffold overlay, tools/k8s/tests/) are allow-listed in the script. Run it as part of the deployment pipeline as well.

  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.

Passkey Administration

Passkeys are disabled by default. Configure the WebAuthn relying party before enabling the feature:

[webauthn]
enabled = true
driver = raft
relying_party_id = identity.example.com
relying_party_name = Example Cloud
relying_party_origin = https://identity.example.com
fake_credential_hmac_key = <stable-random-secret>

relying_party_id must be an effective domain of the origin and cannot be changed without invalidating existing credentials. The origin includes its scheme and must match the client-visible origin.

Use a random fake_credential_hmac_key of at least 16 characters, keep it stable across restarts, and configure the same value on every node. If it is unset, Keystone creates a per-process value suitable only for single-node development; inconsistent decoy identifiers can expose account-existence differences.

Registration and authentication endpoints are documented in the passkey user guide.

Federation Support for Administrators

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)

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.

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.

Enabling the provider

The provider requires Raft storage: the client, session and signing-key stores only exist in [distributed_storage]. Configure that first.

  • [oauth2] enabled unset (default): the provider is on exactly when [distributed_storage] is configured and off otherwise.
  • enabled = false: /v4/oauth2 is not mounted (404, absent from the OpenAPI document), the OAuth2 janitors do not run and no signing key is provisioned for new domains.
  • enabled = true without [distributed_storage] is a startup error.
  • enabled_domains = ["<domain_id>", ...] restricts the provider to the listed domains; any other domain answers 404. An empty list serves no domain. Use it to pilot the provider on one domain of a multi-tenant deployment.
  • Both settings are read at startup to decide which routes and janitors exist: changing enabled requires a restart.

If the Raft store cannot be reached while the provider is enabled, /token answers 503 temporarily_unavailable (RFC 6749 §5.2) with a Retry-After header instead of a 500.

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
enabledunsetServe the provider. Unset: on when [distributed_storage] is configured. true without it is a startup error.
enabled_domainsunsetAllowlist of domain ids served by the provider; other domains answer 404. Unset serves every domain.
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 (never beyond the absolute lifetime).
refresh_token_absolute_lifetime_days90Absolute lifetime of a refresh token family from root issuance; rotation never extends it.
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.
device_code_invalid_quiet_period_seconds300Quiet period after an invalid or expired device_code: further polls with it get 429 + Retry-After before any lookup.
device_user_code_free_failures3Failed /device lookups of one (user_code, client IP) pair before exponential backoff (429 + Retry-After, 1s doubling to 300s) engages.
device_ip_free_failures10Failed /device lookups from one client IP, across all codes, before backoff engages. 0 disables it; do so if many users share one resolved IP (NAT, or a proxy missing from [rate_limit_trusted_proxies]).
revoked_family_retention_days30Days a revoked-family refresh token tombstone is kept after its own expiry (forensics only) before the session janitor purges it.
session_janitor_interval_seconds300Interval of the leader-only janitor that purges expired pre-auth sessions, authorization codes, device grants and refresh tokens from Raft.
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.
allow_host_header_issuerfalseDevelopment only. Lets the issuer and cookie Secure flag follow the request Host/X-Forwarded-Proto headers when [DEFAULT] public_endpoint is unset.
templates_dirunsetDirectory with operator-supplied page templates (see Customising the login pages). Read once at startup.
static_dirunsetDirectory with operator static assets (stylesheets, logos) served at /v4/oauth2/static/.
ui_product_nameOpenStackProduct name in page titles, logo alt text and the footer.
ui_logo_urlunsetLogo at the top of every page: an absolute path such as /v4/oauth2/static/logo.svg (external URLs are blocked by the page CSP).
ui_support_url, ui_privacy_url, ui_terms_urlunsetFooter links (https:// URL or absolute path).
ui_show_client_logosfalseRender the logo_uri registered for a client on its login and consent pages. Lets the CSP load https: images, which leaks sign-in activity to the client’s host.
ui_default_localeenLocale used when Accept-Language matches no available locale.
sso_max_sessions_per_user10Most SSO sessions one user may hold; a new sign-in beyond it ends the oldest.
logout_revokes_refresh_tokensfalseRP-initiated logout naming a client also revokes the user’s refresh tokens of that client.
upstream_logoutfalseLogout of a session that came from a federated IdP also ends the IdP session (see Upstream provider logout).
mfa_max_attempts5Second-factor codes accepted for checking per login attempt before the pre-auth session is discarded and the user must start over. Best-effort and per session; the per-user rate limit ([rate_limit_user_auth]) is the primary brute-force control.
sso_session_lifetime_minutes480Lifetime of the browser SSO session started by a successful sign-in. Later authorization requests in the same browser skip the login form while it lives. 0 is not allowed.

Exceeding a rate limit returns 429 Too Many Requests.

Second factor (TOTP and passkeys)

After the password step a user holding a second factor must pass it on both the browser flow and the device-code flow; the consent page is unreachable until then. Two factors are offered:

  • TOTP: the user has an enabled totp credential. A verification-code form is shown.
  • Passkey / security key: the WebAuthn extension is enabled ([webauthn] enabled = true) and the user has a registered passkey. The page shows a “Use a passkey or security key” button that runs the browser WebAuthn ceremony against the same [webauthn] relying party the /v4/auth/passkey API uses. Without the extension no passkey is ever offered or required.

A user holding both factors may pass either one. The issued id_token and refresh tokens carry amr of ["pwd", "otp", "mfa"] for TOTP and ["pwd", "hwk", "mfa"] for a passkey instead of ["pwd"]. Users with multi_factor_auth_enabled = false skip the step. If a factor lookup or challenge cannot be produced the login is refused (fail closed).

Each ceremony challenge is single use and bound to the user that passed the password step; a wrong assertion re-renders the page with a fresh challenge. The challenge is stored in a slot of its own, derived from the pre-auth session (or device code) of the login, so calls to the public /v4/auth/passkey/start endpoint and other simultaneous sign-ins of the same user can neither replace nor consume it. Attempts count against the same per-session budget (mfa_max_attempts) and the same per-user rate limit ([rate_limit_user_auth]) as TOTP codes.

The passkey page is the only OP page with JavaScript: an inline script allowed through a per-response CSP script-src 'nonce-...'. Every other page (and the TOTP-only page) keeps script-src 'none'. A customised mfa.html template must keep the passkey-form markup and its script block to keep the passkey flow working.

Signing in through an upstream identity provider

If the domain has federation identity providers (/v4/federation/identity_providers) the login page shows one “Sign in with <name>” button per provider next to the password form. A provider is offered only when it is enabled, belongs to the domain (global providers are not offered), has an OIDC discovery URL and a default mapping, and openid is in [auth] methods. GET /authorize?...&idp_hint=<idp id> skips the chooser and sends the browser straight to that provider; an idp_hint that is not one of those providers is ignored.

The browser returns to <public_endpoint>/v4/oauth2/<domain_id>/authorize/federated/callback. Add that URL to the provider’s allowed_redirect_uris when the provider restricts them. The upstream ID token is verified exactly as for /v4/federation/oidc/callback, the user is resolved (or shadowed) through the mapping engine, and the login continues to consent. Federated users skip the local TOTP step, even when a TOTP credential is enrolled: the upstream provider is the only authenticator and is responsible for strong authentication. The amr of the issued tokens is the upstream amr claim when it is a short list, otherwise ["federated"]; those values are attested by the provider, not by Keystone. Set public_endpoint in production: without it the callback URL is derived from the Host header. The provider id and the upstream sid claim are stored with the session, the authorization code and the refresh token family, so logout can later chain to the upstream session.

The device-code verification page offers the same providers. Its browser returns to <public_endpoint>/v4/oauth2/<domain_id>/device/federated/callback, which also has to be allowed by a provider that restricts redirect URIs; the upstream state is bound to the device grant exactly as it is bound to the pre-auth session in the browser flow, and the upstream amr, provider id and sid are copied to the grant and the refresh token family.

Not covered yet: re-checking expiring group membership (ADR 0013) at refresh-token rotation (the OP issues no group or role claims today).

Single sign-on and logout

A successful sign-in (password, TOTP or upstream provider) starts an SSO session stored in Raft and tied to the browser by the keystone_oauth2_sso cookie (HttpOnly, SameSite=Lax, and Secure when the public endpoint is https, like the pre-auth and device cookies; with a plain http endpoint the session can be replayed from the network path and Keystone logs a warning at startup). While it lives, /authorize skips the login form and resumes the original sign-in time and amr, so auth_time in the ID token is the real login time. The session is ignored when its user is disabled or belongs to another domain, and is deleted when the user or domain is disabled or deleted, or when the user’s password changes.

Session hardening:

  • Enrolling a TOTP credential, or turning multi_factor_auth_enabled on for a user, ends that user’s SSO sessions (refresh tokens are kept), so a session that started with a password alone cannot outlive the stronger requirement.
  • sso_max_sessions_per_user (default 10) caps the SSO sessions of one user; a sign-in beyond the cap ends the user’s oldest session.
  • With logout_revokes_refresh_tokens = true, RP-initiated logout that names a client (verified id_token_hint or client_id) also revokes the user’s refresh tokens issued to that client. It needs the browser’s own SSO session, applies to that client only, and does not depend on sid. Access tokens already issued stay valid until they expire.

The authorization endpoint honours the OIDC request parameters:

  • prompt=none never shows a page: it answers with login_required or consent_required redirects, or issues a code when the user is signed in and the client is pre-authorized. It never sets cookies.
  • prompt=login and prompt=select_account force a fresh login.
  • max_age forces a fresh login when the SSO session is older than the value (0 always does).
  • login_hint pre-fills the user name; ui_locales selects the page language.

GET/POST /v4/oauth2/{domain_id}/logout implements OpenID Connect RP-Initiated Logout 1.0 and is advertised as end_session_endpoint. An id_token_hint is verified against the domain keys (expired tokens are accepted) and must belong to the signed-in user. A post_logout_redirect_uri is only followed when it is registered in the client’s post_logout_redirect_uris. Without an id_token_hint the user is asked to confirm first, because any page could otherwise log them out. On success the SSO session is deleted and the cookie cleared. Logout only ends the browser SSO session: access tokens and refresh-token families already issued stay valid until they expire or are revoked (RFC 7009 revocation, user or domain disable/delete, password change).

Session ids (sid)

Every id token issued under a browser SSO session carries a sid claim: an opaque, per-client value derived from the session (a one-way hash of the SSO session and the client id), stable across code redemption and refresh. Two clients never see the same sid, and the sid cannot be turned back into the SSO cookie. Grants without a browser session (device code) have no sid.

Back-channel logout

A client may register a backchannel_logout_uri (https://, no credentials; set to an empty string on update to clear it) and backchannel_logout_session_required. OIDC Back-Channel Logout 1.0 is advertised as backchannel_logout_supported and backchannel_logout_session_supported. When a user’s sign-in ends, every enabled client that was issued an authorization code under it and has such a URI receives POST logout_token=<JWT> (form encoded, Cache-Control: no-store). The token is signed with the domain’s active key, carries a JOSE type header of logout+jwt, iss, aud (the client), sub (the user), iat, a two-minute exp, a jti, the sid of the ended session and the http://schemas.openid.net/event/backchannel-logout event, and has no nonce.

The notification is sent when an SSO session ends:

  • on RP-initiated logout;
  • when the session expires (janitor), is evicted by sso_max_sessions_per_user or is removed because its domain is disabled or deleted;
  • for every session of the user, when the user is disabled, deleted or changes their password.

A session end only reaches clients through that session: the relying parties are recorded per SSO session when a code is issued and removed when notified. A client that does not set backchannel_logout_session_required may treat the token as “end all sessions of the user”, so it is only notified about a single session when the user has no other SSO session left. A client that requires the sid is always notified with it, and skipped for grants that carry none. Replacing a session by signing in again in the same browser sends nothing: the relying parties stay recorded for the next logout.

Delivery never blocks the request that caused it. A relying party that does not answer 200 or 204 (or cannot be reached within 5 seconds) is retried twice, after 2 s and 10 s, then given up on with a warning in the log. There is no persistent queue: a notification pending when the node stops is lost.

The endpoint is untrusted input. Its host is resolved by the server on every attempt and refused when any address is private, loopback, link-local, cloud-metadata or otherwise internal; the connection is pinned to the checked address and redirects are never followed. Only https:// is delivered.

Front-channel logout

A client may register a frontchannel_logout_uri (https://) and frontchannel_logout_session_required. OIDC Front-Channel Logout 1.0 is advertised as frontchannel_logout_supported and frontchannel_logout_session_supported. On RP-initiated logout the page that reports the sign-out loads each such URI of the ended session in a hidden frame; with frontchannel_logout_session_required the URI gets iss and sid query parameters. The page’s Content-Security-Policy allows framing only the origins of those URIs. If a post_logout_redirect_uri was requested, the browser follows it two seconds later (the page runs no scripts, so this is a <meta http-equiv="refresh">). The relying party must allow being framed by the provider (frame-ancestors, SameSite cookies still apply). Front-channel logout happens only in the browser that logs out; sessions that end without a browser (expiry, disabled user) use back-channel logout.

Upstream provider logout

With [oauth2] upstream_logout = true, logging out a session that was started through a federated identity provider also ends the session at that provider. After the local logout the browser is redirected to the provider’s end_session_endpoint (read from its discovery document on every logout; it must be https://) with client_id and a post_logout_redirect_uri of {public_endpoint}/v4/oauth2/{domain_id}/logout/complete, which has to be registered at the provider. logout/complete then sends the browser on to the relying party’s post_logout_redirect_uri (remembered in a short-lived cookie, checked against the client’s registered URIs again) or shows the signed-out page. A provider without an end_session_endpoint, a discovery failure or a non-https endpoint leave the logout local-only, with a warning in the log. The setting is off by default because the provider session may be shared with applications that must stay signed in. No id_token_hint is sent upstream.

On the consent page (browser and device flows) the user can tick “Remember my decision for this application”. The approved scopes are then stored per (domain, user, client) in Raft, and later requests asking for no more than those scopes skip the page. The box is ticked by default only for openid, profile and email; it is not offered for pre_authorized clients, which skip consent anyway. prompt=consent always shows the page.

A consent is removed together with its user, domain or client, and by the user (see the user guide, “Managing connected applications”). Administrators can list and withdraw any user’s consents with the same endpoints; the policies are policy/oauth2/consent/list.rego and delete.rego. Withdrawing a consent revokes the user’s refresh tokens of that client and emits a consent_revoked audit event with the real user as initiator (storing one emits consent_granted). Access tokens already issued stay valid until they expire.

Customising the login pages

The browser pages of the authorization-code and device flows are rendered from Jinja-style templates (minijinja, HTML autoescaping on). The built-in templates are in crates/keystone/templates/oauth2/; copy the ones you want to change into [oauth2] templates_dir. A page whose file is missing there falls back to the built-in one, so a single page can be rebranded on its own.

Every template additionally receives branding (product_name, logo_url, support_url, privacy_url, terms_url), locale and the translation function t("key", name=value). The page frame (viewport meta tag, lang attribute, stylesheet link, logo, footer) lives in base.html, which the other templates extend; override it to change all pages at once.

FileContext variables
login.htmlclient.*, csrf_token, error (optional), action
consent.htmlclient.*, scopes (list), csrf_token, action
device_entry.htmlerror (optional), prefill, action
device_result.htmlgranted (bool), client.*
error.htmlmessage

client carries id, name, description, logo_uri (only with ui_show_client_logos), policy_uri and tos_uri; the optional ones are unset when the client has none.

Keep the form field names (csrf_token, username, password, decision, user_code) and the action URL: the handlers depend on them.

Templates are parsed at startup: an unreadable directory or a template with a syntax error stops Keystone from starting. Changes need a restart.

Styling and languages

The default pages ship with a small responsive stylesheet (system fonts, centred card, dark-mode aware, visible focus rings) served at /v4/oauth2/static/style.css. Put a style.css into static_dir to replace it, or link additional files from your templates.

Page texts come from per-locale TOML bundles, selected by the request’s Accept-Language header (exact tag, then primary language, then ui_default_locale). Only en is built in (crates/keystone/templates/oauth2/locales/en.toml). To add a language copy that file to <templates_dir>/locales/<lang>.toml (for example de.toml) and translate the values; keys missing from a bundle fall back to English. The quoted keys at the end of the file translate the messages produced by the server (for example "invalid username or password").

Reference stylesheets and logos with /v4/oauth2/static/<file> (served from [oauth2] static_dir; the route answers 404 when it is not configured and refuses paths that leave the directory, symlinks included). Responses carry Cache-Control: public, max-age=3600. The pages are sent with Content-Security-Policy: default-src 'self'; style-src 'self'; img-src 'self' data:; script-src 'none'; frame-ancestors 'none', so stylesheets and images must be served from the same origin and inline styles or scripts do not run.

Revoking sessions

During an incident, revoke refresh token families from the command line (the access tokens already issued stay valid until they expire):

keystone-manage oauth2 revoke --domain <domain_id> --user <user_id>
keystone-manage oauth2 revoke --domain <domain_id> --client <client_id>
keystone-manage oauth2 revoke --domain <domain_id> --all --reason "incident 42"

This calls POST /v4/oauth2/{domain_id}/revocations; a domain manager may revoke in their own domain, admin in any. Clients are managed with keystone-manage oauth2 client ....

Monitoring

The provider exports keystone_oauth2_* Prometheus series (token requests and latency, refresh reuse, device polls, key rotations and age, janitor activity, operator revocations); see the OAuth2 table in ADR 0031. The shipped alert rules (deploy/prometheus/alert_rules.yaml, group keystone_oauth2) page on refresh token reuse and warn about stale signing keys and janitors.

Production checklist

  • Set [DEFAULT] public_endpoint to the externally visible URL of Keystone (for example https://keystone.example.com). It pins the issuer (iss claim, discovery issuer, token_endpoint, jwks_uri, the device flow verification_uri) so no request header can influence them, and its scheme decides whether the pre-auth and device cookies carry Secure (https → Secure, independent of X-Forwarded-Proto), and so does the SSO session cookie. Use https: with an http endpoint all of them travel unprotected and Keystone logs a warning at startup.
  • When public_endpoint is unset, Keystone logs an error at startup and /authorize, /device_authorization, /token and discovery answer 503 (server_error). [oauth2] allow_host_header_issuer = true restores the Host-header behaviour and must only be used for local development.

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 non-API 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.
  • name (required on create, 1-128 characters) is what users see on the login and consent pages instead of the raw client_id; users are otherwise asked to approve an opaque UUID, which is what consent phishing relies on. Clients registered before names existed show their provider_id. Optional display metadata follows the RFC 7591 names: description (up to 1024 characters), logo_uri, policy_uri and tos_uri (absolute https:// URLs without credentials) and contacts (up to 10 entries). On update, an empty string clears description, logo_uri, policy_uri and tos_uri. GET .../clients?name=<text> filters by case-insensitive name substring.
  • The consent page links policy_uri and tos_uri. logo_uri is only rendered when [oauth2] ui_show_client_logos = true, because the browser then loads the image from the client’s host (the CSP img-src gains https:), which tells that host who is signing in.
{
  "oauth2_client": {
    "provider_id": "grafana",
    "name": "Grafana",
    "description": "Operations dashboards",
    "logo_uri": "https://grafana.example.com/logo.png",
    "policy_uri": "https://grafana.example.com/privacy",
    "tos_uri": "https://grafana.example.com/terms",
    "contacts": ["ops@example.com"],
    "confidential": true,
    "token_endpoint_auth_method": "client_secret_basic",
    "grant_types": ["authorization_code", "refresh_token"],
    "redirect_uris": ["https://grafana.example.com/login/generic_oauth"],
    "allowed_scopes": ["openid", "profile", "email"]
  }
}
  • 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 soft-deletes the client and tombstones every refresh token family issued to it (revocation reason client_revoked) before returning. Setting enabled: false through PATCH does the same, since a disabled client must not keep live refresh families. Both emit an oauth2/client_revoked CADF event carrying the number of revoked families. Pending device grants and pre-auth sessions of the client are not purged eagerly: they are rejected at redemption because the client is disabled and are removed by the expired record janitor. 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. The janitor also rotates the key automatically once it is older than 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 for the full node-local mechanism (guardrail, gossip, scope limits) shared with DEK rotation. This section covers the OAuth2-specific commands.

Requires [local_emergency] enabled = true on the responding node, and the Raft leader must have been unknown for at least leaderless_grace_period_seconds (guardrail refuses otherwise).

1. Stage — during quorum loss, on a guardrail-enabled node:

keystone-manage oauth2 rotate-signing-key \
  --domain <domain_id> --local-quorum-bypass \
  --justification "suspected key compromise, quorum lost"

Writes a rotation candidate to that node’s local Fjall keyspace only — never touches Raft. A background sweep gossips it (best-effort) to reachable peers every gossip_interval_seconds, marking it conflicted: true on any node where a different active candidate already exists for the same domain.

2. List candidates — once quorum returns, on every node that may have been reached during the outage:

curl -X GET https://keystone:5000/v4/oauth2/<domain_id>/local-emergency-candidates \
  -H "X-Auth-Token: $ADMIN_TOKEN"

CLI equivalent: keystone-manage oauth2 list-local-emergency-candidates --domain <domain_id>. Check conflicted before choosing a rotation_id — true means gossip saw a different candidate elsewhere and you must decide deliberately which one wins.

3. Reconcile — a different operator than the one who staged it, against the specific node holding the chosen candidate (reconciliation does not fan out cluster-wide):

keystone-manage oauth2 reconcile-local-emergency-key \
  --domain <domain_id> --rotation-id <rotation_id>

Promotes the candidate’s key to Primary via the same Raft transaction path normal rotation uses (requires quorum), demotes the prior Primary to Previous, clears the candidate on this node, and revokes any other active candidate for the domain on this node. Rejects if the confirming operator matches the initiator (dual-control) or if the candidate was already revoked. Emits oauth2/local_emergency_key_reconciled (CADF) with a _local:emergency:audit:<rotation_id> pointer recorded in the local emergency store back to the event — staging itself is not audited (consistent with the ordinary emergency path’s stage/confirm asymmetry); only reconciliation is.

Production checklist

  • Raft storage is configured and [oauth2] enabled resolves to on (see Enabling the provider).
  • [DEFAULT] public_endpoint is set to the external https URL. Without it the issuer cannot be pinned and the endpoints answer 503 server_error.
  • TLS is terminated in front of Keystone and [oslo_middleware] enable_proxy_headers_parsing is on, so the client IP and scheme are correct.
  • [rate_limit_global_ip] and token_rate_limit_burst_size / token_rate_limit_replenish_per_minute are sized for the expected traffic.
  • A signing-key rotation cadence is chosen (signing_key_rotation_days) and the manual rotation runbook has been rehearsed.
  • The emergency (dual-control) rotation has been drilled at least once.
  • The JWKS and revocation endpoints are monitored and kept highly available: downstream verifiers depend on them.
  • Raft quorum is monitored. While it is lost /token answers 503 temporarily_unavailable.
  • The metrics endpoint is scraped and the alert rules in deploy/prometheus/alert_rules.yaml (group keystone_oauth2) are loaded, see ADR 0031.

Downstream control-plane enforcement (Nova/Neutron/etc.)

Design only. The Python middleware described here is not part of this repository and is not yet published (repository link: to be added). What ships here is the Rust reference verifier (openstack_keystone_core::oauth2_client::verify_openstack_access_token) and an integration test that mints a token through POST /token and verifies it fully offline. The configuration below is the intended interface.

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 existing Fernet filter chain, so rollout is incremental per service/region with instant rollback (just remove the filter).

Required config per service:

# Trust this Keystone: any issuer https://keystone.example.com/v4/oauth2/<domain_id>
# is accepted, with its JWKS and revocation list derived from the issuer URL
# (<issuer>/jwks, <issuer>/jwks/revocation) and fetched lazily. No per-domain
# configuration is needed when domains are created or deleted.
keystone_issuer_base_url = https://keystone.example.com
# Optional: trust only these domains (domain ids). Empty/unset = all domains.
# keystone_allowed_domains = <domain_id>,<domain_id>
# Optional: this service's OpenStack service type (e.g. compute). Lets it also
# accept tokens narrowed to `openstack-apis:<service type>`.
keystone_service_type = compute
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 the control-plane value openstack-apis (or a narrowing to openstack-apis:<service type>), so one token works across all services and across every domain a service trusts. Domain isolation does not rely on aud:
    • a token is verified only against the JWKS of the issuer it names, and the service fetches that JWKS only from under keystone_issuer_base_url (never from a host named by the token);
    • the token’s scope must lie inside the domain its iss names (project_domain_id / domain_id equal to the issuer’s domain). A compromised domain key can therefore only forge tokens acting inside that domain, never in another one. System-scoped tokens are refused by this check, and an issuer that does not name a domain is rejected.
  • A service accepts every token from every domain it trusts. To trust only some domains, set keystone_allowed_domains; leave it unset on a public cloud where domains come and go.
  • A deleted domain serves no JWKS, so its tokens stop validating without any change on the services. Unknown issuers are cached negatively (60 s) so a forged iss cannot trigger a fetch per request.
  • Without keystone_service_type a service accepts only un-narrowed tokens, so enabling audience narrowing for clients never breaks services that have not been upgraded.
  • The revocation list is currently fetched per issuer (60 s cache), which is one poll per active domain. A cluster-wide revocation feed is the planned follow-up for very large deployments (ADR 0026 §5, “Revocation at scale”).

Relying-party token revocation (RFC 7009)

POST /v4/oauth2/{domain_id}/revoke lets a client revoke its own refresh or access tokens (see the user guide). Operationally:

  • Refresh-token revocation tombstones the family with reason rp_revoke and emits oauth2/refresh_family_revoked; every request also emits a revoke session audit event whose initiator is the authenticated client. A client presenting another client’s refresh token gets 200 (no oracle) but a failure audit event is recorded.
  • Access-token revocation writes the jti to the same per-domain revocation list that emergency rotation uses (/jwks/revocation), so it is only enforced by services that fetch that list (see “Downstream control-plane enforcement”), after the list’s 60s cache TTL. Entries are dropped once the token’s exp passes.
  • The endpoint is rate limited per client (token_rate_limit_*) and per source IP (global IP limiter) before any token lookup.
  • OIDC access tokens carry a private fid claim (the refresh family id; tokens issued before it was renamed carry it as sid, which revocation still accepts). The sid claim of id and logout tokens is the SSO session. Revoking such an access token also revokes that family. The reverse does not hold: revoking a refresh token does not revoke access tokens already minted from its family (the list is keyed by jti; the server does not track access tokens per family).
  • Writes to the per-domain JTI list use optimistic concurrency with bounded retries, so concurrent revocations (or a revocation racing an emergency rotation) do not lose entries; under sustained contention the call fails with a 500 rather than silently dropping a revocation.

Introspection for high-criticality services (RFC 7662)

Offline JWT verification cannot see a revocation until the service next refreshes /jwks/revocation (and an un-listed token stays valid until exp). A service that cannot accept that window for its most sensitive operations (deleting volumes, changing quotas, …) should validate the presented token with the back-channel endpoint instead:

POST /v4/oauth2/{domain_id}/introspect
Authorization: Basic base64(client_id:client_secret)
Content-Type: application/x-www-form-urlencoded

token=<access or refresh token>&token_type_hint=access_token
  • Who may call it: an enabled confidential client of the same domain, authenticated exactly as on /token (client_secret_basic or client_secret_post). Public clients get 401 invalid_client. No dedicated capability flag is needed: register the resource server as an ordinary confidential client of the domain. The token does not have to belong to the calling client, since a resource server introspects tokens minted for other clients; it must belong to the same domain.
  • Active token: 200 with active: true, scope, client_id, sub, exp, iat, nbf, aud, iss, jti, token_type: "Bearer" and token_use. For OpenStack access tokens (openstack:api) the response also carries openstack_context (user, scope and roles) and delegation_context, the authorization data the caller will act on. Refresh tokens return client_id, sub, scope, exp and iat.
  • Anything else (unknown, malformed, expired, revoked, spent, bad signature, token of another domain) is 200 {"active": false}; the response never says why.
  • Access tokens are checked against the live JTI revocation list (no cache), so a token revoked through RFC 7009 or emergency rotation is inactive immediately. Refresh tokens are active only while the record exists, is not spent or revoked, and neither the token nor its family has expired.
  • If the revocation list or the refresh-token store is unavailable the endpoint answers 500 rather than risk reporting a revoked token as active; treat that as “deny”.
  • The endpoint is rate limited per client (token_rate_limit_*) and per source IP before any lookup, responses are Cache-Control: no-store, and every call emits a CADF read audit event initiated by the calling client. Discovery advertises it as introspection_endpoint.

Recommended pattern: verify offline for ordinary calls and introspect only for the operations whose revocation latency you cannot tolerate.

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, see “Introspection for high-criticality services”) — record that acceptance in your deployment’s migration runbook.

Upgrading to the absolute refresh-token lifetime

  • Existing refresh families (issued before refresh_token_absolute_lifetime_days existed) carry no cap. On their first rotation after the upgrade they are backfilled with now + refresh_token_absolute_lifetime_days, so they are capped from then on rather than staying uncapped forever.
  • Rolling upgrades of a Raft cluster: create_refresh_token commands now carry a family_expires_at field. Nodes still running the old version cannot apply them, so upgrade all nodes before serving refresh-token traffic from the new version (stored records stay readable in both directions).
  • Revoking a family because its user or domain is no longer valid emits an oauth2/refresh_family_revoked audit event (family id and reason: user_deleted, user_disabled, user_domain_changed, password_changed, domain_disabled, domain_deleted).
  • Disabling or deleting a user, changing a user’s password, or disabling or deleting a domain revokes the affected refresh families right away (one audit event per family) and drops pending authorization and device flows for that user or domain, matching the v3 token revocation on the same events. Disabling a user or domain is now audited as disable and a password change as password_change instead of update.

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>
503 server_error on /authorize, /device_authorization, /token or discovery (“issuer mismatch” / issuer differs between environments)[DEFAULT] public_endpoint is not set — set it to the external URL; the issuer is otherwise not pinned and the endpoints refuse to serve
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

Service grants

Service grants (ADR 0036, phase 1) let a user delegate a bounded set of their roles on one project to a registered OpenStack service, so the service can keep acting on the user’s behalf after the user’s own token has expired. Typical users are Heat (stack updates and autoscaling), Magnum (cluster operations) and Glance (long-running imports). They replace the parts of /v3/OS-TRUST those services rely on.

A grant is a stored record. The service redeems it at the OAuth2 token endpoint, authenticating with its own SPIFFE identity, and receives a short-lived JWT access token scoped to the grant’s project. Every redemption re-validates the grant, the grantor and the grantor’s current roles, so revoking a grant or removing a role takes effect at the next redemption.

Nothing changes for a deployment until an administrator registers a delegatable service.

Concepts

TermMeaning
GrantorThe user who creates the grant. Delegated roles never exceed the grantor’s live roles.
GranteeA registered OAuth2 client with a delegation policy accepting service grants.
TargetThe single project the grant applies to. It may be in another domain than the grantor.
Role subsetThe roles the grant carries, chosen at creation and fixed afterwards.
RedemptionPOST /v4/oauth2/{domain_id}/token with the service grant grant type, over SPIFFE mTLS.

Prerequisites

  • The [interface_internal] listener is configured with SPIFFE mTLS (see Configuration). Redemption is only accepted on that interface, and a client can only list SPIFFE IDs in the listener’s trust_domains.
  • Raft storage is enabled. Grants are stored only there; with Raft disabled the endpoints answer 501.
  • The target project’s domain has an OAuth2 signing key. Keys are provisioned when a domain is created through the API, so the bootstrap default domain has none (see OAuth2 / OIDC Provider).

Registering a delegatable service

A service becomes a grantee by registering an OAuth2 client that authenticates with tls_client_auth, lists the SPIFFE IDs its workloads present, and carries a delegation policy. Setting or changing spiffe_ids or delegation requires an administrator.

{
  "oauth2_client": {
    "provider_id": "heat",
    "name": "Orchestration (Heat)",
    "confidential": false,
    "token_endpoint_auth_method": "tls_client_auth",
    "grant_types": [],
    "allowed_scopes": ["openstack:api"],
    "spiffe_ids": ["spiffe://cloud.example/service/heat-engine/host/*"],
    "delegation": {
      "service_grants": true,
      "cross_domain": true,
      "max_role_ids": ["<member-role-id>", "<reader-role-id>"],
      "max_grant_lifetime": 31536000,
      "max_grants_per_project": 100
    }
  }
}
  • spiffe_ids patterns match segment by segment. * matches exactly one non-empty segment and is only allowed as the whole last segment. The trust domain must match exactly.
  • cross_domain: true lets users of any domain delegate to the client, on projects of any domain. Without it both the grantor and the project must be in the client’s own domain; otherwise creation is rejected and redemption answers as if the grant did not exist.
  • max_role_ids is a ceiling on the roles a grant to this client may carry. An empty list means the grant is bounded only by the grantor’s own roles. Every id added to the ceiling must be an existing role; ids already stored are not re-checked, so a role deleted later simply never matches and does not block edits.
  • max_grant_lifetime (seconds) caps expires_at. Without it a grant may be created without an expiry.
  • max_grants_per_project caps live grants per (project, client). The [delegation] max_grants_per_project backstop applies on top. Exceeding either answers 429.
  • on_behalf_of and max_operation_lifetime are stored for the phase 2 on-behalf-of exchange and have no effect yet.

Removing the policy ("remove_delegation": true on update), disabling the client, or deleting it disables every grant naming it with reason grantee_removed.

Users discover delegatable services with GET /v4/delegatable-services, which returns the client id, name, domain and ceilings, never the SPIFFE IDs.

Configuration ([delegation])

OptionDefaultMeaning
max_grants_per_project10000Deployment-wide backstop on live grants per project.
last_redeemed_update_interval3600Seconds between two last_redeemed_at writes of one grant.
expired_grant_retention2592000 (30 d)Seconds an expired grant is kept before the janitor deletes it.
janitor_interval3600Seconds between two janitor runs. Re-read every tick.
list_limitas other list APIsPagination limits of GET /v4/service-grants.

Managing grants

GET    /v4/delegatable-services
POST   /v4/service-grants
GET    /v4/service-grants
GET    /v4/service-grants/{grant_id}
DELETE /v4/service-grants/{grant_id}

A user creates a grant with a regular Keystone token:

{
  "service_grant": {
    "grantee_client_id": "<heat client_id>",
    "project_id": "<project id>",
    "role_ids": ["<member-role-id>"],
    "expires_at": "2027-10-01T00:00:00Z",
    "description": "stack web-frontend"
  }
}
  • role_ids must be a non-empty subset of both the grantor’s effective roles on the project (group and implied roles included) and the client’s max_role_ids. Omitting it delegates every effective role within the ceiling.
  • Grants cannot be created from a delegated authentication (trust, restricted application credential, service grant) or a restricted token. An unrestricted application credential may create grants only on its own project, bounded by its own roles.
  • Grants are immutable. To change roles or expiry, create a new grant and delete the old one.

Who may see and delete a grant (policy/service_grant/): the grantor, an administrator, a manager of the target project or of its domain, and the grantee service itself. A grantee service calls GET or DELETE /v4/service-grants/{grant_id} on the internal interface with its SVID and no token, which is how Heat cleans up grants when a stack is deleted.

GET /v4/service-grants accepts user_id, project_id, domain_id (the project’s domain), grantee_client_id and enabled filters. A non-admin must filter on their own user_id, or on a project or domain they manage. Every returned grant is re-checked against the show policy.

Redeeming a grant

The grantee service calls the token endpoint of the grant’s domain_id, which is the project’s domain, over the internal interface, presenting its X.509-SVID:

POST /v4/oauth2/{domain_id}/token
Content-Type: application/x-www-form-urlencoded

grant_type=urn:openstack:params:oauth:grant-type:service-grant
&grant_id=<grant id>
&client_id=<client id>      (optional; must match when sent)
&roles=<role id> <role id>  (optional; narrows the grant's roles)
&scope=openstack:api        (optional; within the client's allowed_scopes)
&audience=<service type>    (optional; narrows `aud`, as for token exchange)

On every redemption Keystone checks that the grant exists and is enabled and unexpired, that the SVID matches one of the grantee client’s spiffe_ids, that the client is enabled and still accepts grants (its current max_role_ids and max_grant_lifetime, counted from the grant’s creation, apply to existing grants too), that the grantor, the project and its domain are enabled, and that the grantor still holds at least one of the requested roles. The token carries grant.role_ids ∩ grantor's live roles (further narrowed by roles). No refresh token is issued.

Errors follow RFC 6749: invalid_grant (400) for an unknown or foreign grant or an authentication failure, invalid_client (401) when the SVID does not match the grantee, invalid_request (400) for a roles value outside the grant, and invalid_grant with status 403 when the grant is disabled or the grantor lost every delegated role. Redemption is rate limited per SVID (service_grant:<svid>) with the token endpoint’s [oauth2] quota, so it never shares a budget with the client-keyed grants.

Token content

{
  "sub": "<grantor user id>",
  "amr": ["service_grant"],
  "act": {
    "client_id": "<grantee client id>",
    "sub_type": "spiffe",
    "sub": "spiffe://cloud.example/service/heat-engine/host/h1"
  },
  "delegation_context": {
    "auth_method": "service_grant",
    "delegated_project_id": "<project id>",
    "grant_id": "<grant id>"
  },
  "scope_type": "project",
  "project_id": "<project id>",
  "project_domain_id": "<project domain id>",
  "scope_roles": [{ "id": "<member-role-id>", "name": "member" }]
}

The token is issued and signed by the project’s domain (iss is /v4/oauth2/<project domain>), so each domain’s key only vouches for its own projects. The remaining OpenStack claims (user_id, user_domain_id naming the grantor’s own domain, roles) are those of any project-scoped access token. Downstream services treat it like any delegated token: the scope-drift tripwire (project_id equals delegated_project_id) is enforced by verify_openstack_access_token and must be kept by policy. act identifies the acting service in audit records and is also returned by introspection.

Keystone itself does not accept these tokens as its own bearer credential, so a service grant token cannot be used to create grants, application credentials, EC2 credentials or trusts at Keystone.

Lifecycle

ChangeEffect on grants
Grantor disabledDisabled, reason grantor_disabled
Grantor deletedDeleted
Target project disabledDisabled, reason target_disabled
Target project deletedDeleted
Role assignment or group membership removed, no delegated role leftDisabled, reason role_lost
Grantee client deleted, disabled as delegatable, or policy removedDisabled, reason grantee_removed
expires_at passedDisabled by the janitor, reason expired, deleted after expired_grant_retention
User deletes the grantDeleted

A grantor who lost only some of the delegated roles keeps the grant; tokens carry the remaining intersection. Disabled grants are never re-enabled automatically. Re-enabling a user or project, or restoring a role, requires a new grant.

These hooks are housekeeping. Redemption re-runs every check, so a change the hooks miss (for example an inherited assignment or an external assignment driver) still stops the grant from producing tokens.

Audit

Grant creation and deletion emit service_grant CADF events. Each redemption emits an oauth2 authenticate event with the grantor as initiator and the grantee client as target. The event carries a signed service_grant attachment with grant_id, grantor_user_id, actor_client_id, actor_spiffe_id and delegated_project_id. Lifecycle disables are written with the internal context and appear in the grant’s disabled_reason.

Known gaps

  • Only tls_client_auth over the internal interface is supported. JWT-SVID private_key_jwt on the public interface is phase 3.
  • The on-behalf-of exchange (phase 2) and EC2 credentials under a grant (phase 4) are not implemented. Trusts remain available.
  • A 429 for a grant quota carries no Retry-After header: unlike a rate limit, waiting does not free quota, only revoking or expiring grants does.

SCIM v2 Support: 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 SCIM v2 Support. 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 SCIM v2 Support 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 is audited through the provider layer: user and group writes emit a CADF event (Create/Update/Disable), and the writes to the realm and ownership index (scim_realm, scim_resource, including the purge of a realm’s index) are audited the same way. Each record has 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 API-Key Administration

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. Create, update and delete are audited the same way (fail-closed attempt record before the change); the last-use timestamp and the transparent re-hash of the secret are not, because the authentication that triggers them is audited at the perimeter. 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

Kubernetes TokenReview Administration

Operators and domain administrators register Kubernetes clusters as authentication instances. Workloads then exchange their service-account JWTs through the user-facing authentication endpoint documented in the Kubernetes authentication guide.

Register an authentication instance

Create instances under /v4/k8s_auth/instances. Each instance records:

FieldPurpose
domain_idDomain that owns the authentication instance.
hostBase URL of the Kubernetes API server.
ca_certOptional PEM CA certificate for the Kubernetes API.
disable_local_ca_jwtDisable fallback to the in-cluster CA certificate.
enabledAllow or deny authentication through the instance.
nameOptional operator-facing name.

For example:

{
  "instance": {
    "domain_id": "domain-infra",
    "host": "https://kubernetes.default.svc",
    "ca_cert": null,
    "disable_local_ca_jwt": false,
    "enabled": true,
    "name": "production-cluster"
  }
}

Use the instance API to manage registrations:

ActionEndpoint
Create or listPOST or GET /v4/k8s_auth/instances
Show, update, or deleteGET, PATCH, or DELETE /v4/k8s_auth/instances/{instance_id}

The current request and response schemas are available in the OpenAPI reference.

Kubernetes API permissions

Keystone sends the workload’s JWT both in the TokenReview request and as the bearer credential for that Kubernetes API call. Bind workloads that use this flow to a role that permits creation of tokenreviews.authentication.k8s.io, normally system:auth-delegator.

When ca_cert is omitted and disable_local_ca_jwt is false, Keystone reads the Kubernetes service-account CA certificate from its local pod filesystem. Set ca_cert explicitly for a remote cluster or when the local CA does not represent the registered API server.

Configure identity mapping

Every enabled instance needs an enabled mapping ruleset whose source type is k8s and whose cluster_id identifies that instance. Domain managers can match these claims:

  • k8s.serviceaccount.name
  • k8s.serviceaccount.namespace
  • k8s.aud

Rules define the resulting virtual user, scope, roles, and groups. Keep cluster registration and authorization rules separate: operators maintain connectivity and CA trust, while authorized domain managers maintain mappings through /v4/mappings/rulesets.

See Identity Mapping Administration for cluster-wide configuration and Identity Mapping Rules and API for rule syntax and Kubernetes examples.

Operational checklist

  1. Verify the Kubernetes API URL and CA chain from every Keystone node.
  2. Restrict instance management with OPA policy.
  3. Confirm the workload can create TokenReview requests before enabling it.
  4. Create and review the domain’s Kubernetes mapping ruleset.
  5. Test with a short-lived projected service-account token.
  6. Disable the instance before changing its API host or trust configuration.

Identity Mapping Administration

Identity mapping rules are owned by domain managers through the v4 API. The operator is responsible for enabling the mapping engine, supplying stable cluster-wide configuration, and protecting the boundaries enforced during rule evaluation. See the user guide for rule semantics, provider claim contracts, examples, and API operations.

Security considerations

  • Rulesets containing is_system: true are immutable after creation to prevent privilege escalation through later rule mutation.
  • AllOfStrict with require_all_keys prevents a lower-trust assertion from suppressing claims used by a higher-priority rule.
  • Templates cannot reference enclosing_domain_id through ${claims.*} and resolved values are capped at 256 characters.
  • Compiled regular expressions use a bounded cache, and claim values accepted for evaluation are size limited.

These application safeguards do not replace policy. Operators must restrict mapping administration to the intended domain managers and review rules that can produce system-scoped authorization.

An unmapped or misconfigured caller (no cluster salt, no matching ruleset, disabled ruleset) is denied outright — authenticate_by_mapping fails closed and the request never reaches a handler. That is not the risk to guard against; the real risk is a ruleset that matches too broadly and grants real access to callers it was never meant to cover:

  • Match on the narrowest claim that actually identifies the workload. For SPIFFE sources, match the exact spiffe.id of the intended workload (spiffe://<trust-domain>/service/nova-api), not spiffe.trust_domain alone — a trust-domain-wide match authorizes every workload issued an SVID in that trust domain, including ones added later for unrelated purposes. The same applies to MatchesRegex: a pattern broader than the specific workload path it was written for silently grows the set of identities a rule accepts as SPIRE registration entries are added.
  • Reserve is_system: true for genuine control-plane identities. A system-scoped rule bypasses domain/project boundaries entirely; grant it only to the specific control-plane workload that needs it (e.g. a service’s own auth-token back-channel), never as a default for convenience. flat_spiffe_claims’ derived spiffe.project_id/ spiffe.instance_id claims exist so data-plane (per-project) workloads can be mapped to project scope instead of system scope.
  • Grant the least-privilege role the caller actually needs. A control-plane SVID that only validates tokens needs the service role (see policy/auth/token/show.rego), not admin — admin at system scope is authorized for every policy-checked operation in the deployment.
  • Review every ruleset change that widens a match or adds is_system before it goes live, the same way a firewall-rule or IAM-policy change gets reviewed — a broadened rule takes effect immediately for every future request matching it, not just new registrations.

The admin interface can also authorize a configured admin_svid without a mapping ruleset. Limit that SVID to administrative workloads, distribute the configuration consistently, and keep it separate from ordinary SPIFFE mapping rules.

Cluster salt

The mapping engine requires mapping.cluster_salt before SPIFFE, Kubernetes, federation, or API-client authentication can use mapping rules. Without it, mapping-backed authentication fails because Keystone cannot derive a stable virtual user identifier.

Set a distinct, stable value for every Keystone cluster:

mapping:
  cluster_salt: "<random-secret>"

Generate the value with a cryptographically secure random source, for example:

openssl rand -hex 32

Keystone derives deterministic virtual user IDs with HMAC-SHA256(cluster_salt, workload_id || provider_id). Reusing a salt across clusters creates overlapping identifier namespaces. Changing it after virtual users exist assigns new identifiers to the same workloads and orphans the previous shadow records.

Store the salt in the deployment secret manager, distribute the same value to every Keystone node, and include it in backup and disaster-recovery procedures. Do not store it in a plain ConfigMap.

Operational checklist

  1. Configure the cluster salt consistently on every node.
  2. Restrict mapping-rule APIs through OPA policy.
  3. Review system mappings and broad regular expressions before enabling them.
  4. Monitor mapping failures and shadow-user version mismatches.
  5. Treat a planned cluster-salt rotation as an identity migration.

LDAP Identity Backend

The LDAP identity backend (crates/identity-driver-ldap) is a read-only IdentityBackend implementation that reads users and groups directly from an LDAP directory (OpenLDAP, FreeIPA, Active Directory, etc.). Its [ldap] config section is field-for-field compatible with Python Keystone’s keystone.conf.ldap options, so a config file already in use with Python Keystone works against keystone-rs largely unmodified — enabling a config-only driver = sql → driver = ldap switch during a rolling upgrade. See ADR-0027 for the full design rationale and the Python-compatibility findings from its implementation review.

Enabling the backend

[identity]
driver = ldap
default_domain_id = default

[ldap]
url = ldaps://ldap.example.com
user = cn=service,dc=example,dc=com
password = servicepassword
suffix = dc=example,dc=com
user_tree_dn = ou=Users,dc=example,dc=com
group_tree_dn = ou=Groups,dc=example,dc=com

[identity] driver is a single, deployment-wide setting — there is no per-domain driver selection yet, so a keystone-rs deployment runs either fully against LDAP or fully against SQL, not a mix. All LDAP users and groups are exposed under the single default_domain_id domain, since the LDAP directory itself has no concept of Keystone domains.

LdapBackend::new() performs one real bind against url/user/password at startup and fails fast if it cannot reach the directory — a misconfigured [ldap] section prevents the server from starting rather than surfacing as runtime errors on the first request.

Read-only

Every mutating IdentityBackend method (create_user, update_user, delete_user, create_group, add_user_to_group, update_user_password, etc.) returns IdentityProviderError::Readonly, mapped to an HTTP 403. Manage users and groups directly in the directory (or via whatever tool your organization already uses for that) — Keystone only reads from it.

A few capabilities that don’t exist in an LDAP directory at all return IdentityProviderError::NotImplemented (mapped to 500) rather than Readonly, since there’s no LDAP concept to even be read-only about: create_service_account, find_federated_user, and the expiring-group- membership methods. get_service_account returns Ok(None) unconditionally.

Configuration reference

Connection

OptionDefaultNotes
urlldap://localhostOne or more server URLs, comma/whitespace-separated (matches Python’s re.split(r'[\s,]+', ...)). Each candidate is tried in turn on connect/bind failure.
userunsetService bind DN, used for all read queries.
passwordunsetService bind password.
use_tlsfalseUse StartTLS on the plain ldap:// connection. Use ldaps:// in url for implicit TLS instead.
tls_cacertfile / tls_cacertdirunsetParsed for config compatibility but not applied — see Known limitations.
tls_req_certdemanddemand | allow | try | never.
connection_timeout-1 (no timeout)Seconds.
randomize_urlsfalseShuffle the candidate list from url before trying servers (matches Python’s random.shuffle).
pool / pool_size / pool_retry_max / pool_retry_delay / pool_connection_timeout / pool_connection_lifetimetrue / 10 / 3 / 0.1 / -1 / 600Service (read) connection pool.
auth_pool / auth_pool_size / auth_pool_connection_lifetimetrue / 100 / 60Dedicated pool for end-user password-auth binds, isolated from the service pool so a burst of failed logins can’t starve directory reads.

Query

OptionDefaultNotes
query_scopeoneone (single-level under the tree DN) or sub (whole subtree). Governs the scope of every read search — get, get_by_name, and list — not just id/DN resolution.
page_size0 (disabled)Entries per page (RFC 2696) for list_users/list_groups.
alias_dereferencingdefaultParsed but not applied — see Known limitations.
chase_referralsunset
debug_level0

User mapping

OptionDefault
user_tree_dn"" (must be set)
user_objectclassinetOrgPerson
user_id_attributecn
user_name_attributesn
user_mail_attributemail
user_description_attributedescription
user_pass_attributeuserPassword
user_enabled_attributeenabled
user_enabled_maskunset
user_enabled_invertfalse
user_enabled_default"True"
user_additional_attribute_mapping{}
user_filterunset
user_attribute_ignore{default_project_id}
user_enabled_emulationfalse
user_enabled_emulation_dnunset
user_enabled_emulation_use_group_configfalse

user_id_attribute falls back to the entry’s DN (with a logged warning) when the configured attribute is absent or multi-valued on a given entry, rather than failing the whole request.

Enabled-attribute strategies

user_enabled_attribute is interpreted by one of four strategies, evaluated in the same precedence order as Python Keystone:

  1. Group-membership emulation (user_enabled_emulation = true): a user is enabled iff they’re a member of user_enabled_emulation_dn.
  2. Bitmask (user_enabled_mask set): enabled = (raw_value & mask) != mask — the user is enabled unless all masked bits are set. This is the Active Directory userAccountControl convention, where mask = 2 corresponds to the ACCOUNTDISABLE bit. user_enabled_invert is ignored entirely once a mask is configured, matching Python.
  3. Invert (user_enabled_invert = true, no mask): the raw boolean value is negated.
  4. Plain boolean: the raw attribute value is parsed directly.

When user_enabled_attribute is absent from an entry, the backend falls back to parsing user_enabled_default, which is why that option is a string rather than a boolean — under the bitmask strategy it’s expected to hold an integer literal (e.g. "512") instead of "True"/"False".

Group mapping

OptionDefault
group_tree_dn"" (must be set)
group_objectclassgroupOfNames
group_id_attributecn
group_name_attributeou
group_desc_attributedescription
group_member_attributemember
group_members_are_idsfalse
group_attribute_ignore{}
group_additional_attribute_mapping{}
group_filterunset
group_ad_nestingfalse

group_members_are_ids supports directories where group_member_attribute holds Keystone user IDs directly instead of member DNs — most commonly posixGroup’s memberUid, which stores POSIX usernames rather than distinguished names. Leave it false for the default groupOfNames + member (DN-valued) convention.

group_ad_nesting resolves nested Active Directory group membership using the LDAP_MATCHING_RULE_IN_CHAIN (1.2.840.113556.1.4.1941) matching rule in list_groups_of_user.

General

OptionDefault
suffixcn=example,cn=com

Known limitations

  • tls_cacertfile / tls_cacertdir are parsed but not applied. The ldap3 crate has no ready-made hook for a custom CA trust root short of manually constructing a rustls::ClientConfig; TLS verification always uses the platform/rustls default trust store instead. A startup warning is logged when either option is set. Work around this by installing the directory’s CA into the system trust store, or by setting tls_req_cert = never for a test/lab directory.
  • alias_dereferencing is parsed but not applied. The ldap3 crate (v0.11) has no per-search or per-connection alias-dereferencing control; every search uses the underlying client library’s default behavior regardless of this setting.
  • No per-domain driver selection. [identity] driver is global; running some domains against LDAP and others against SQL simultaneously isn’t supported yet.

Testing

Beyond crate-level unit tests (cargo test -p openstack-keystone-identity-driver-ldap), the crate ships a live_tests module that exercises the driver’s read and authentication paths against a real OpenLDAP (slapd) instance — real network round trips, real schema validation, real bind/search behavior, not mocks.

Run it via the dedicated nextest profile:

cargo nextest run -p openstack-keystone-identity-driver-ldap --profile ldap

This uses a nextest setup script (tools/start-ldap-test.sh) to start a throwaway local slapd on port 3890, seed it from crates/identity-driver-ldap/tests/fixtures/base.ldif, and export KEYSTONE_LDAP_TEST_URL/_BASE_DN/_ADMIN_DN/_ADMIN_PW for the test binary to pick up. tools/teardown-ldap-test.sh stops it afterwards (nextest does not run teardown scripts automatically, so run it by hand after a manual cargo nextest run --profile ldap).

Under any other profile, live_tests’ functions skip themselves gracefully (printing a skip message) rather than failing, since KEYSTONE_LDAP_TEST_URL is only set by the ldap/ci-ldap profiles — plain cargo test for the crate never requires a live directory.

The fixture directory (base.ldif) seeds users and groups covering the enabled/disabled cases, groupOfNames membership, and a posixGroup for exercising group_members_are_ids; see the file itself for the full seed data.

Dynamic Authentication Plugin Operations

Dynamic authentication plugins are WebAssembly modules loaded from paths named by [auth_plugins]. Each plugin has a corresponding [auth_plugin.<name>] section containing its path, pinned SHA-256 digest, operating mode, capabilities, network allowlist, and resource limits.

Dynamic plugins require a Keystone binary built with the wasm_plugins cargo feature, which is not part of the default build because it pulls in the extism/wasmtime runtime. The published container image enables it. Without the feature [auth_plugins] is ignored and a warning is logged at startup. See Cargo features.

Before deployment:

  1. Build and review the module from a trusted source.
  2. Pin its SHA-256 digest.
  3. Grant only the required host capabilities and outbound hosts.
  4. Configure time, fuel, memory, concurrency, and invocation-rate limits.
  5. Restart Keystone and verify load status without logging secrets.

When replacing a module, update valid_since so tokens issued through the old version can be rejected according to deployment policy. Use the administrative identity-link and revoke-all APIs for controlled linking and compromise response.

See Authentication plugin development for the guest contract, host functions, and testing workflow.

Audit trail

Keystone writes a signed, tamper-evident audit record for every authentication attempt and every state-changing operation. This page is the operator guide; the design is in ADR 0023.

What is audited

KindRecordsWhen
Perimeterauthenticate (success, failure)Login and token-issuing handlers (token, EC2, OAuth2 token, federation JWT/OIDC), plus one completion record for the other authentication surfaces: token validation and revocation (/v3/auth/tokens), WebAuthn, Kubernetes auth, API-key (SCIM), vendordata, and requests rejected early (for example 429). With [audit] perimeter_all_requests = true every request gets such a record. Best effort: records are dropped, and counted, if the in-memory channel is full.
Providercreate, update, delete, enable, disable, revoke, …Every state-changing provider operation writes an attempt record before the change and a success/failure record after it. If the attempt record cannot be queued the operation is not performed (fail-closed).

Ordinary validated-token requests to resource endpoints are not recorded at the perimeter; the mutations they make are audited by the provider records.

A few bookkeeping operations are deliberately not audited because the authentication that triggers them is: the last-use timestamp and transparent secret re-hash of an API key, and janitor housekeeping.

Record format

Records are JSON lines using the DSP0262 (CADF) key names:

{
  "typeURI": "http://schemas.dmtf.org/cloud/audit/1.0/event",
  "eventType": "activity",
  "id": "keystone-0:550e8400-e29b-41d4-a716-446655440000",
  "eventTime": "2026-06-16T10:15:00+00:00",
  "action": "delete",
  "outcome": "failure",
  "reason": {"reasonType": "keystone", "reasonCode": "Conflict"},
  "initiator": {
    "typeURI": "service/security/account/user",
    "domain_id": "0123456789abcdef0123456789abcdef",
    "host": {"address": "203.0.113.9"},
    "id": "fedcba9876543210fedcba9876543210",
    "project_id": null
  },
  "target": {"id": "0123456789abcdef0123456789abcdef", "typeURI": "data/security/identity/user"},
  "observer": {"id": "service/security/keystone/keystone-0", "typeURI": "service/security/keystone"},
  "tags": ["correlation_id:req-3f0c7a3e5b3d4d0f9a52f1c8f1f1e0aa"],
  "attachments": [{
    "name": "integrity",
    "contentType": "application/json",
    "content": {
      "seq": 42,
      "boot_session_id": "9a1c3f0e-6c0b-4c5d-8f55-2f2c3b6d2a10",
      "hmac_key_version": 1,
      "version": "1.1",
      "domain": "0123456789abcdef0123456789abcdef",
      "observer_node_id": "keystone-0"
    }
  }],
  "signature": "<hex HMAC-SHA256>"
}
  • outcome is one of the DSP0262 values success, failure, pending (provider record written before the change) or unknown; no other value is written, and a record carrying one is rejected on read. A rejected request such as a rate limit is a failure whose reason.reasonCode is TooManyRequests or ClientError. Rate-limited or otherwise denied plugin invocations are likewise a failure with the cause in reason.reasonCode.
  • reason.reasonCode is a fixed vocabulary word (an error variant name such as Conflict or UserLocked), never error text. The reason key is omitted when there is none.
  • OAuth2 token-endpoint records (authenticate for the authorization_code, refresh_token and device_code grants) carry a second attachments entry, oauth2, with content {"client_id": "...", "grant_type": "..."}. It is covered by the signature. Look attachments up by name, never by position. A rejected device_code client is a failure whose reason.reasonCode is the OAuth2 error (invalid_client, unauthorized_client).
  • action is a lowercase verb or a /-separated name such as oauth2/refresh_reuse_detected.
  • initiator.host carries the client address and, for pre-authentication failures, a non-secret identifier such as the EC2 access key id. It is omitted when empty, whereas project_id and domain_id are null.
  • The correlation id is the correlation_id: tag: the server-generated x-openstack-request-id; a client-supplied value is discarded.
  • The integrity attachment carries the sequence number, boot session, key version, schema version, the initiator’s domain and the observer node.
  • Records contain identifiers only: no passwords, tokens, secrets or free text.

The spool

Records are first written to a local spool in [audit] spool_dir (default /var/lib/keystone/audit), so nothing depends on the downstream system being up.

FileMeaning
audit-spool-<node_id>.jsonlThe live file, appended by Keystone.
audit-spool-<node_id>.jsonl.seg-<timestamp>A sealed, immutable segment (sealed at spool_max_segment_bytes or spool_max_segment_age_secs).
*.quarantine-<timestamp>A segment whose signatures did not verify at startup. Keystone never deletes it: investigate it (tampering, a wrong key, disk corruption) and archive or remove it by hand. The metric keystone_audit_spool_quarantined_total counts these.
audit-spool-<node_id>.lockA lock that keeps two Keystone processes from writing one spool.
hmac-key.binThe audit root key (see below).

The spool grows until segments are shipped. Configure a sink so sealed segments are shipped and deleted once the sink has accepted them; without one, segments stay in the spool for an external shipper (for example a log forwarder tailing the directory) and nothing deletes them unless you set spool_max_segments (the oldest are then deleted with an ERROR log). Plan disk for spool_max_segments * spool_max_segment_bytes.

Configuration

OptionDefaultMeaning
spool_dir/var/lib/keystone/auditSpool and key directory. Make it writable by the Keystone user only (for example mode 0700); Keystone does not change the directory mode.
node_id$HOSTNAME, else unknown-nodeIdentifies the node in the integrity attachment and file names. Must be unique per node; set it explicitly. unknown-node is a placeholder, not a valid production value.
spool_max_segment_bytes256 MiBSeal the live file at this size.
spool_max_segment_age_secs86400Seal the live file after this age.
spool_max_segmentsunset (keep all)Keep at most this many sealed segments.
spool_drain_timeout_secs10Time to write already-queued events on shutdown; the rest are dropped and logged at ERROR.
sinknoneDownstream sink, e.g. sink = { type = "stdout" }.

Keys

hmac-key.bin holds at least 32 random bytes. It is created with mode 0600 on first start; a file that is too short makes startup fail. Each node derives its own signing key from it with HKDF-SHA256 (Extract-then-Expand) using the salt keystone-audit-hmac-salt-v1 and the info string keystone-audit-hmac-v1:<node_id>, so a compromised node cannot forge records attributed to another node. Back the file up with the same care as the Fernet keys: without it the spool cannot be verified, with it an attacker can forge records. The key version in use is exported as keystone_audit_hmac_key_version and stamped on every record (hmac_key_version in the integrity attachment), so old records stay verifiable; automated rotation is not available yet.

SIEM implementors reproduce the key as HKDF-SHA256(salt, IKM = KEK, info, L = 32) with the standard RFC 5869 Extract and Expand steps. Releases before this derivation used Expand-only HKDF (no salt); their records do not verify with the key above.

FIPS builds

Build with the cadf/fips feature (--no-default-features --features fips to leave ring out) to run the HMAC signature, the key derivation and KEK generation on the FIPS 140-3 validated AWS-LC module. Call cadf::assert_fips() at start-up to refuse to run without it. The syslog TLS stack follows the service’s fips feature (the TLS provider crate), not this one.

Verifying records in a SIEM

  1. Parse the JSON line.
  2. Remove the signature key.
  3. Serialize the rest in JCS canonical form (RFC 8785).
  4. Compute HMAC-SHA256 with the key for hmac_key_version (and node_id).
  5. Compare with signature.

Cache every key version you have seen. Reference vectors, including the host cases, are in tests/audit/hmac_vectors.jsonl, and tools/audit_vectors.py verify is an independent Python implementation of the procedure. Cross-check with outcome: a pending record with no success/failure for the same correlation id and target within a few minutes means the process died or the post-record was lost.

Monitoring

Keystone exports keystone_audit_* metrics on /metrics (event, drop, queue-depth, spool and sink series; see ADR 0023 §Observability). Ready-made alert rules are in deploy/prometheus/alert_rules.yaml; at minimum page on keystone_audit_dropped_total, keystone_audit_postaudit_dropped_total, keystone_audit_spool_write_failures_total, keystone_audit_spool_quarantined_total and sink errors.

Accepted risk: records live in memory for a short window before they reach the spool, and the spool is not fsynced per record, so a hard crash or power loss can lose the last few milliseconds of records. A graceful shutdown drains the queue first.

OpenTelemetry: traces and metrics

Keystone can export traces and metrics over OTLP, and it always serves the same metrics as Prometheus text on /metrics. This page is the operator guide; the design is in ADR 0040. For what to collect, alert on and look at, see Observability.

Build

OTLP export is compiled in only with a cargo feature:

FeatureAdds
otelOTLP over HTTP (protobuf)
otlp-grpcOTLP over gRPC; enable it next to otel
cargo build --release -p openstack-keystone --features otel

The [otel] section is parsed and validated in every build. A build without the feature that is asked to export logs that it cannot, and runs without export. /metrics does not need the feature.

Enable export

[otel]
enabled = true
endpoint = http://otel-collector:4318
service_name = keystone
sampling_rate = 0.1

traces_enabled and metrics_enabled both default to enabled; set one to false to export a single signal. For HTTP the signal path (/v1/traces, /v1/metrics) is appended to endpoint, and the URL scheme decides whether TLS is used. Options:

OptionDefaultMeaning
enabledfalseMaster switch
traces_enabled, metrics_enabledenabledPer-signal switch
endpointhttp://localhost:4318 (:4317 for gRPC)Collector URL
protocolhttp/protobufor grpc
headersnonename=value,name2=value2, for example an API token. Never logged
timeout10Exporter timeout, seconds
insecurefalsegRPC only: connect without TLS. Ignored, with a warning, for HTTP
service_namekeystoneservice.name resource attribute
resource_attributesnoneExtra key=value,key2=value2 resource attributes
samplerparent_based_ratioparent_based_ratio, ratio, always_on, always_off
sampling_rate1.0Ratio in 0.0..=1.0 for root spans (every span with ratio)
span_levelinfoMost verbose span exported: error, warn, info, debug, trace
metrics_interval60Metric push interval, seconds
include_user_idsfalseExport user, project and domain ids, user names, list filters and the raw URL path on spans
include_client_addressfalseAdd the caller’s IP to request spans
legacy_http_attributesfalseAlso emit the http.method, http.user_agent and http.status_code names that oslo.middleware uses
response_traceparentfalseReturn traceparent in responses

Spans and metrics leave the host, so the two personal-data options are off by default. Keep them off unless the collector and its backend are trusted with that data.

Migrating from [oslo_middleware_tracing]

If other OpenStack services run oslo.middleware’s tracing, an existing [oslo_middleware_tracing] section is accepted as it is. It only drives traces, as in oslo.middleware.

[oslo_middleware_tracing][otel]
enabledenabled (traces)
otlp_endpointendpoint
otlp_protocolprotocol
service_nameservice_name
sampling_ratesampling_rate
insecureinsecure

To move over, copy the values into [otel] and remove the old section. If both sections set the same option the [otel] value wins and a warning is logged. Keystone joins the traces of other services through the W3C traceparent and tracestate request headers. osprofiler headers (X-Trace-Info, X-Trace-HMAC) are not supported.

Differences to know about:

  • Spans are named {method} {route} with the route template (/v3/users/{user_id}), not the raw path, so the name has bounded cardinality.
  • url.path and user ids are exported only with include_user_ids.
  • A client chooses the trace id and, under the default sampler, whether its request is sampled. Use sampler = ratio to ignore the caller’s decision.
  • Only spans are exported, never log events.

Metrics

GET /metrics on the [interface_metrics] listener (default port 8099) returns Prometheus text, as before. Series names, labels and values are unchanged, so existing dashboards and deploy/prometheus/alert_rules.yaml keep working. What differs in the text:

  • Labels are sorted by name and families by name.
  • A series appears only after it was first recorded. A family with no sample yet has no # HELP/# TYPE lines (the two audit counters labelled by result).
  • The Raft series are now served; they were not before.

With metrics_enabled the same data is pushed every metrics_interval seconds. A scrape does not cause an extra push. Pick one path per series: scrape Keystone, or collect the pushed data, but do not ingest both into one store, or the values are counted twice.

Collector example

An OpenTelemetry Collector that receives Keystone’s OTLP and forwards traces to Tempo and metrics to Prometheus remote-write:

receivers:
  otlp:
    protocols:
      http:
        endpoint: 0.0.0.0:4318

processors:
  batch: {}

exporters:
  otlp/tempo:
    endpoint: tempo:4317
    tls:
      insecure: true
  prometheusremotewrite:
    endpoint: http://prometheus:9090/api/v1/write

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch]
      exporters: [otlp/tempo]
    metrics:
      receivers: [otlp]
      processors: [batch]
      exporters: [prometheusremotewrite]

Keystone side:

[otel]
enabled = true
endpoint = http://otel-collector:4318
resource_attributes = deployment.environment=prod

Checking it works

  1. Start with [otel] enabled = true and check the startup log for telemetry warnings, such as a build without the otel feature.
  2. curl http://keystone:8099/metrics lists keystone_http_requests_total after the first request.
  3. At the collector, a POST /v1/metrics should arrive every metrics_interval seconds, and a POST /v1/traces once sampled requests were served.

Observability

This page describes how to run Keystone so that its health and behaviour can be watched: which signals exist, what to collect, what to alert on and how to find the cause of a slow or failing request. The options of the exporter are in the OpenTelemetry guide, the audit trail has its own page.

Signals

SignalSourceUse it for
MetricsGET /metrics (Prometheus text) on [interface_metrics], port 8099Dashboards and alerts: rates, errors, latency, saturation
TracesOTLP export ([otel], needs the otel build feature)Finding where one slow or failed request spent its time
Logsstdout of the processStartup problems and errors
Audit[audit] sinksWho did what, tamper-evident. Not a monitoring signal

Metrics tell that something is wrong, traces tell where. Start with metrics and add tracing when the first questions of the form “which step is slow?” arrive.

Reference architecture

keystone-rs pods ──/metrics──▶ Prometheus ──▶ Grafana ◀── Jaeger / Tempo
        └────────── OTLP/HTTP traces ───────────────────────▲
  • Metrics are scraped, not pushed. Prometheus pulls /metrics from every pod. Leave metrics_enabled = false (with enabled = true for traces) unless a collector is the only path into your metrics store: ingesting a series through both paths counts it twice.
  • Traces are pushed over OTLP to a collector or directly to a backend that accepts OTLP (Jaeger v2, Tempo, a vendor agent).
  • Run a collector close to Keystone when the backend needs authentication, batching or fan-out; set headers when the endpoint needs a token.
  • The [interface_metrics] listener is unauthenticated. Bind it to a cluster internal address and never publish it through the public ingress.

Scraping

With a fixed set of hosts:

scrape_configs:
  - job_name: keystone
    static_configs:
      - targets: [keystone-1:8099, keystone-2:8099, keystone-3:8099]

On Kubernetes, scrape every pod, not the load-balanced service, so that each instance has its own series (Raft leadership, per-node latency). A headless service gives that without access to the Kubernetes API:

scrape_configs:
  - job_name: keystone
    dns_sd_configs:
      - names: [keystone-internal]
        type: A
        port: 8099

The manifests in tools/k8s/observability in the repository are a complete, non-production example of this layout. See the contributor page for how to run them.

What to watch

The catalog of all series is in ADR 0031. These are the ones to build a first dashboard from.

QuestionQuery
Is it up and serving?sum by (status) (rate(keystone_http_requests_total[5m]))
Error ratiosum(rate(keystone_http_requests_total{status=~"5.."}[5m])) / sum(rate(keystone_http_requests_total[5m]))
Slowest routes (p95)histogram_quantile(0.95, sum by (le, route) (rate(keystone_http_request_duration_seconds_bucket[5m])))
Login failuressum by (method) (rate(keystone_auth_attempts_total{outcome="failure"}[5m]))
Brute-force lockoutsincrease(keystone_auth_lockouts_total[15m])
Throttled clientssum by (scope) (rate(keystone_rate_limit_rejections_total[5m]))
Policy engine troublerate(keystone_policy_errors_total[5m])
Cache effectivenessrate(keystone_cache_hits_total[5m]) / (rate(keystone_cache_hits_total[5m]) + rate(keystone_cache_misses_total[5m]))
Raft leader existssum(keystone_raft_is_leader) == 1
Follower falling behindkeystone_raft_replication_lag

Route labels are the route templates (/v3/users/{user_id}), so the number of series stays bounded.

Alerts

deploy/prometheus/alert_rules.yaml ships the audit alerts (dropped records, spool failures, sink errors, backlog). Load it as a rule file and add rules for the service itself, for example:

groups:
  - name: keystone-service
    rules:
      - alert: KeystoneHighErrorRatio
        expr: |
          sum(rate(keystone_http_requests_total{status=~"5.."}[5m]))
            / sum(rate(keystone_http_requests_total[5m])) > 0.05
        for: 10m
        labels: { severity: page }
      - alert: KeystoneSlowTokenIssue
        expr: |
          histogram_quantile(0.95, sum by (le)
            (rate(keystone_http_request_duration_seconds_bucket{route="/v3/auth/tokens"}[5m]))) > 1
        for: 10m
        labels: { severity: warning }
      - alert: KeystoneNoRaftLeader
        expr: sum(keystone_raft_is_leader) != 1
        for: 1m
        labels: { severity: page }

Tune the thresholds to the observed baseline. Keep the for: long enough to ride out a rolling restart.

Traces

Structure

A trace of one request is a tree. The root is the HTTP request, named {method} {route}. Below it are the layers that did the work:

LayerSpan nameTells you
HTTPGET /v4/role_assignmentsTotal time, status code
API handlerapi::<operation>Handler time without the transport
Authentication, policyauth.*, policy.*Token validation, scoping, OPA round trip
Providerprovider.<domain>.<method>Business logic and caching of one domain
Driverdriver.<backend>.<domain>.<method>One storage call (sql, raft, ldap, …)

Provider and driver spans carry the identifiers or filter of the call (for example role_id, params), never request bodies, passwords or tokens. A driver span is the number to read when a database or the Raft store is suspected: its duration is the storage time, and a parent much longer than the sum of its children is time spent in Keystone itself.

Choosing the verbosity

Only spans at or above span_level are exported:

span_levelExportsCost
info (default)The HTTP root span onlyLowest. Enough for latency and status by route
debugPlus handler, authentication, policy, provider and driver spansA request produces tens of spans. Use for investigations and staging
tracePlus per-item helper spans in loopsVery large traces. Short debugging sessions only

With the default you see that POST /v3/auth/tokens is slow but not why. For production, a good compromise is span_level = debug with a low sampling_rate (for example 0.01), so a small share of requests is traced in full. span_level is read at startup; changing it needs a restart.

Sampling

  • parent_based_ratio (default) follows the decision of the caller and samples sampling_rate of the requests that arrive without one. This keeps traces whole across services.
  • Because the caller decides, an untrusted client can force sampling by sending a traceparent header. Use sampler = ratio on a Keystone that is reachable by clients you do not control.
  • To follow one request, send a traceparent with the sampled flag, or set response_traceparent = true so that responses carry the trace id for support tickets.

Finding a slow request

  1. In the metrics, find the route and the time window (p95 by route).
  2. In the trace backend, select the service (service_name), the operation {method} {route}, a minimum duration near the p95 and the same window.
  3. Open a trace and read the longest driver span. Many identical spans in a row point at a loop that should be one query; a single long driver span points at the storage; a gap between a parent and its children is time inside Keystone, or in policy evaluation when a policy.* span is long.
  4. Compare with a fast trace of the same route.

Spans are exported in batches, so a trace shows up in the backend a few seconds after the request.

Privacy and data handling

Spans leave the host. By default they contain route templates, status codes and the identifiers of the resources a call touched, but not user, project or domain ids, user names, list filters, the raw URL path or the client address. The exporter strips the former from every span, including the debug provider and driver spans, so raising span_level does not change this. The options that add them (include_user_ids, include_client_address) are off for that reason; switch them on only when the collector and the backend are trusted with personal data and covered by your retention rules.

Keep the stack’s own access in mind: anyone who can read the trace backend sees what operations happen on the cloud, and anyone who can write to Grafana can change the dashboards and alerts you rely on. Authenticate and TLS-protect Prometheus, Grafana and the trace UI. The development stack allows anonymous admin access and must not be used as a model for that.

Capacity

  • Metrics cost is bounded: labels are route templates and small enums. A scrape every 15 s per pod is typical.
  • Trace volume is requests/s × sampling_rate × spans per request. At span_level = debug that is 30 to 60 spans per request, so 1 % sampling at 500 requests/s is about 150 to 300 spans/s. Size the backend retention and storage for that, and prefer a collector with a batch processor in front.
  • Export is asynchronous and bounded. When the collector is down, spans are dropped rather than queued without limit, so a failing observability stack does not slow Keystone down; it shows as missing data.

Troubleshooting

SymptomCheck
No keystone service in the trace backendThe binary was built with --features otel; the startup log has no telemetry warning; endpoint is reachable from the pod; a sampled request was served
Only root spans, no provider or driver spansspan_level is info; set debug and restart
Traces missing for some requestsSampling; the caller sent a traceparent without the sampled flag under parent_based_ratio
Metrics series appear twiceBoth scrape and OTLP push feed one store; set metrics_enabled = false or drop the scrape job
Prometheus target is down[interface_metrics] binds to 127.0.0.1; network policy blocks port 8099
Raft series missingThe deployment does not use the Raft storage driver
Exporter errors in the logWrong endpoint or protocol; the headers token is missing or expired; for gRPC without TLS set insecure = true

Configuration Reference

Keystone configuration uses INI sections and supports three ordered sources: the file passed to --config, an optional KEYSTONE_SITE_VARS_FILE, and OS_ environment variables. Environment names use __ between section and option, for example OS_DATABASE__CONNECTION.

  • Configuration options inventories the public sections and option names parsed by crates/config.
  • Administrator configuration provides deployment guidance and listener security requirements.
  • Feature administrator guides explain valid combinations and operational consequences.

Secret-valued options may also have file/content forms in their owning section. Never place secret values in documentation, logs, or command output.

Configuration Options

This reference follows the public fields of openstack-keystone-config. Defaults come from the corresponding Rust Default implementation; options without a usable default are required when their feature is enabled. Consult the feature guide for constraints and safe production values.

Service and Interfaces

SectionOptions
[DEFAULT]debug, log_dir, public_endpoint, use_stderr, use_journal, log_rotation_type, log_rotate_interval, log_rotate_interval_type, max_logfile_count
[database]connection, connection_debug
[interface_public]tcp_address, listener type, and listener-specific TLS/SPIFFE fields
[interface_internal]tcp_address, listener type, trust_domains, optional svid_path (SVID the listener presents), and TLS content/file fields
[interface_admin]socket_path, trust_domains, peer_uid, peer_gid, admin_svid, optional svid_path
[interface_metrics]tcp_address
[oslo_middleware]enable_proxy_headers_parsing, trusted_header, trusted_proxies

The public interface is enabled by default. Internal and admin interfaces are optional. The metrics listener defaults to 0.0.0.0:8099.

Telemetry

SectionOptions
[otel]enabled, traces_enabled, metrics_enabled, endpoint, protocol, headers, timeout, insecure, service_name, resource_attributes, sampler, sampling_rate, span_level, metrics_interval, include_user_ids, include_client_address, legacy_http_attributes, response_traceparent
[oslo_middleware_tracing]enabled, otlp_endpoint, otlp_protocol, service_name, sampling_rate, insecure

Telemetry is off by default. [oslo_middleware_tracing] is an alias that accepts the options of oslo.middleware’s tracing middleware and drives traces only; where an option is set in both sections [otel] wins and a warning is logged at startup. protocol is http/protobuf (default, endpoint http://localhost:4318) or grpc (http://localhost:4317). headers and resource_attributes are comma separated name=value lists; headers may carry credentials and is never logged. Request spans are named after the route template (for example GET /v3/users/{user_id}). include_user_ids is false by default because spans leave the host: it gates the authenticated user, project and domain ids and the raw request path, which contains resource ids. include_client_address is separate and also false by default: it adds the caller’s IP as client.address. Anything these options add can be removed again in the collector. response_traceparent echoes the trace context in responses, and legacy_http_attributes adds the http.method family of names that oslo.middleware emits. insecure applies to grpc only, as in oslo.middleware. span_level (default info) is the most verbose span exported. A malformed value fails startup even while telemetry is disabled.

Export needs a build with the otel cargo feature (otlp-grpc adds the gRPC transport). Without it the options are validated but nothing is exported, and startup logs a warning. Traces and metrics are exported in the background and never block requests; if the pipeline cannot be built, Keystone logs it and starts without telemetry.

Authentication, Tokens, and Security

SectionOptions
[auth]methods
[token]provider, expiration
[fernet_tokens]key_repository, max_active_keys, insecure_allow_null_key
[jws_tokens]key_repository, insecure_allow_null_key
[credential]driver, key_repository, insecure_allow_null_key
[application_credential]driver, reject_unenforced_access_rules
[ec2]auth_ttl
[trust]driver, allow_redelegation, max_redelegation_count
[security_compliance]inactivity, first-use password change, password-hash reporting, lockout, password age/expiry/regex, and password-history options
[rate_limit_global_ip]enabled, burst_size, replenish_rate_per_second
[rate_limit_user_auth]enabled, burst_size, replenish_rate_per_second
[rate_limit_trusted_proxies]trusted_proxies, trusted_header

insecure_allow_null_key defaults to false. Keep it disabled in production. See Fernet tokens and Security.

Policy and Domain Providers

SectionOptions
[api_policy]enable, opa_base_url, opa_policies_path
[identity]driver, caching, default_domain_id, max_password_length, password_hashing_algorithm, password_hash_rounds, user_options_id_name_mapping
[assignment]driver
[catalog]driver
[resource]driver
[role]driver
[limit]driver, list_limit, enforcement_model
[revoke]driver, expiration_buffer
[idmapping]driver
[token_restriction]driver

OPA policy is enabled by default. See API policy enforcement.

Feature Providers

SectionOptions
[webauthn]enabled, driver, relying_party_id, relying_party_name, relying_party_origin, fake_credential_hmac_key
[federation]driver, default_authorization_ttl
[mapping]driver, cluster_salt
[k8s_auth]driver
[oauth2]enable switch and domain allowlist, signing algorithm and rotation, Argon2 cost, access/ID/refresh/code/device lifetimes, polling interval, session janitor, token rate-limit, and login page template/static asset options. Full option table: OAuth2 administrator guide
[api_key]driver, Argon2 cost, janitor retention, trusted proxy/header, and rate-limit options
[scim_realm]driver
[scim_resource]driver, janitor_deprovisioned_retention_days
[ldap]connection/TLS/pool/query options and user/group attribute mappings documented in the LDAP guide

See the corresponding pages under Administrator Guides for validation rules and complete operational examples. [mapping].cluster_salt is required before mapping-backed authentication (SPIFFE, Kubernetes, federation, API client) works at all; see Identity Mapping Administration for that requirement and for rule-matching precision (narrow claim matches, is_system scoping, least-privilege roles) as a security concern distinct from it.

Dynamic Plugins and Audit

SectionOptions
[auth_plugins]plugins, trusted_proxies, trusted_header
[auth_plugin.<name>]path, sha256, mode, capabilities, headers, outbound hosts, provisioning/role bounds, route targets, resource limits, rate limits, concurrency, valid_since
[auth_plugin_identity]driver
[audit]enabled (true), spool_dir (/var/lib/keystone/audit), hmac_kek_file (unset: <spool_dir>/hmac-key.bin), node_id ($HOSTNAME, else unknown-node; set it, unique per node), spool_max_segment_bytes (256 MiB), spool_max_segment_age_secs (86400), spool_max_segments (unset: keep all), spool_max_bytes (unset), spool_retention_secs (unset), perimeter_all_requests (false), perimeter_channel_capacity (4096), critical_channel_capacity (256), shipper_batch_size (500), shipper_poll_interval_secs (5), shipper_initial_backoff_secs (1), shipper_max_backoff_secs (60), spool_drain_timeout_secs (10), sink (none)

[audit] enabled defaults to true. Setting it to false skips creating the spool directory, spool lock, HMAC key and writer, and discards audit events; use it only for development or deployments that do not need an audit trail.

Audit signing key and node identity:

  • node_id identifies the node in every event, names its spool files and keys its signing key, so it must be unique per node. It defaults to the HOSTNAME environment variable, then the system hostname; Keystone refuses to start with auditing enabled if it is empty, unset (the unknown-node fallback) or contains characters outside A-Z a-z 0-9 . _ -.
  • hmac_kek_file is the keyring holding the audit key-encryption-keys, one per key version, created with mode 0600 if missing. It must not be inside spool_dir (startup fails if it is): whoever can write the spool must not be able to read the key that signs it. When unset, the legacy <spool_dir>/hmac-key.bin is used so existing deployments keep working, and a warning is logged. A legacy 32-byte key file is read as key version 1.
  • Rotate the signing key with keystone-manage audit rotate-hmac-key. It adds a new version and makes it current; old versions are kept so events already signed stay verifiable. Running servers pick the new version up within about 30 seconds. Back up the keyring and give the SIEM verifier the updated copy.

Spool and queue limits:

OptionDefaultDescription
spool_max_segmentsunsetKeep at most this many sealed segments; the oldest are deleted beyond it.
spool_max_bytesunsetKeep sealed segments plus room for one full live segment (spool_max_segment_bytes) within this many bytes, deleting the oldest sealed segments first.
spool_retention_secsunsetDelete sealed segments older than this many seconds.
perimeter_all_requestsfalseAlso record a perimeter completion event for every request, including ordinary token-authenticated API calls. High volume: size perimeter_channel_capacity accordingly and watch the dropped-events counter.
perimeter_channel_capacity4096Capacity of the best-effort perimeter channel; events are dropped and counted when it is full.
critical_channel_capacity256Capacity of the fail-closed critical channel; senders wait when it is full.
shipper_batch_size500Events handed to the sink per call.
shipper_poll_interval_secs5Idle delay between checks for newly sealed segments.
shipper_initial_backoff_secs1First retry delay after a sink failure; doubles up to the maximum.
shipper_max_backoff_secs60Upper bound for the sink retry delay.

The three spool limits are unset by default, so no audit record is deleted unless an operator opts in. They are evaluated at startup and whenever the live spool rotates. A segment deleted by a limit was never acknowledged by a sink: each deletion is logged at ERROR and counted in keystone_audit_spool_retention_deleted_total.

Audit sink ([audit] sink): sealed spool segments are shipped to the sink and deleted only after the sink has accepted every event.

typeDescription
none (default)No sink; segments stay in spool_dir for an external shipper.
stdoutOne JSON line per event on standard output.
syslogRFC 5424 messages over TCP, optionally TLS. Requires building Keystone with the audit-syslog cargo feature.

The syslog sink takes endpoint (host:port, required), tls (default false), ca_file (PEM bundle; defaults to the system trust store), app_name (default keystone), connect_timeout_secs (default 10) and write_timeout_secs (default 30):

[audit]
sink = { type = "syslog", endpoint = "siem.example.com:6514", tls = true, ca_file = "/etc/keystone/siem-ca.pem" }

Each message carries the signed CADF JSON as its MSG and is framed with octet counting (RFC 6587), so the receiver can verify the HMAC as it would from the spool. Delivery is at-least-once: a failed or timed-out batch is retried with backoff and the segment is kept until delivery succeeds, so receivers should deduplicate on the event id. TCP has no application acknowledgement; a batch counts as delivered once it is written and flushed to the peer. If Keystone was built without audit-syslog, configuring this sink makes startup fail.

See Audit trail.

See Dynamic authentication plugin operations.

Distributed Storage and Emergency Operations

SectionOptions
[distributed_storage]dev_mode, kek_provider, node addresses/ID/path, join retry nodes, PKCS#11/TPM provider fields, and TLS or SPIFFE transport fields
[local_emergency]enabled, leaderless_grace_period_seconds, gossip_interval_seconds

Distributed storage is optional. When enabled in production, use the complete distributed-storage runbook; the option list alone is not a safe deployment procedure.

OpenAPI and Swagger UI

Swagger UI

Contributor Documentation

This section is for developers changing Keystone-NG itself.

Read CONTRIBUTING.md before submitting a change.

Local Development

Running the complete service locally requires a database and Open Policy Agent (OPA). Docker Compose and Skaffold provide those dependencies.

Prerequisites

  • Stable Rust toolchain
  • pre-commit
  • cargo-nextest for integration profiles
  • SPIRE for live API tests
  • Optional Skaffold and a Kubernetes cluster for the full deployment
  • Optional k3s, a local registry at localhost:5000, CloudNativePG, the SPIRE controller manager and a SPIRE trust domain of example.org for the single-voter Raft KEK overlay smoke tests

Install repository hooks after cloning:

pre-commit install

Local Build

Always name the crate for crate-specific Cargo commands:

cargo build -p openstack-keystone
cargo check -p openstack-keystone --message-format=short
cargo test -p openstack-keystone

Skaffold

The Skaffold configuration can deploy Keystone-NG, Python Keystone, OPA, the database, and supporting identity providers to a local Kubernetes cluster:

skaffold dev --default-repo localhost:5000 -p local

Use --cleanup=false when resources must remain after Skaffold exits. The repository configuration exposes mixed, Rust-only, and Python-only endpoints; consult skaffold.yaml for the current modules and profiles.

The Skaffold deployment (tools/k8s/keystone/overlays/skaffold) is a development setup: it runs the Raft storage in dev mode with an environment KEK and keeps nothing across pod restarts. Do not use it as a deployment template; the production reference is tools/k8s/keystone/overlays/production, described in the distributed storage Kubernetes reference deployment.

Host inotify limit

Every opa sidecar watches the certificate directory with inotify, and all pods of a k3s node share the host’s per-user fs.inotify.max_user_instances budget (128 by default). When it is exhausted OPA exits right after start with Unable to open watch: too many open files, the /ready probe of the Keystone pod keeps returning 503 and the container restarts in a loop. Raise the limit on the k3s host:

sudo sysctl -w fs.inotify.max_user_instances=1024
echo 'fs.inotify.max_user_instances = 1024' | sudo tee /etc/sysctl.d/99-inotify.conf

Rolling updates of the dev Raft cluster

The development StatefulSet keeps the Raft storage on a persistent volume claim (raft-storage, 2Gi), so a recreated pod keeps its state and only replays what it missed. The claims outlive skaffold delete; remove them for a clean Raft state:

kubectl delete pvc -l app.kubernetes.io/name=keystone-rs

A node that comes back with empty storage (a lost volume) has to catch up from the leader; the pod log reports Syncing with the cluster every 10 seconds until it is done, and /ready returns 503 meanwhile. Node 0 looks for an existing cluster among its retry_join_nodes before it bootstraps a new one, and a voter that asks to join again is demoted to learner by the leader. Once it caught up, promote it again with keystone-manage storage promote <id>; the Skaffold post-deploy hook does this for the dev cluster.

Changing the claim template of an existing StatefulSet is not possible. When upgrading an older dev deployment that used an emptyDir, delete the StatefulSet first: kubectl delete sts keystone-rs.

Raft KEK overlays on k3s

tools/k8s/keystone/overlays/k3s-test contains throwaway, single-voter overlays for smoke-testing the pkcs11 and tpm Raft KEK providers on a local k3s node. They are not quorum deployments. The full reference, including the host TPM limitation, is the Single-voter k3s smoke test section of the distributed storage documentation.

The skaffold-k3s-test.yaml file, with the pkcs11 or tpm profile, builds and pushes the required images to localhost:5000, then deploys the matching overlay. Use the same --tag raft-test value that the overlays expect. CloudNativePG and the SPIRE controller manager must already be installed (the main keystone module installs both).

PKCS#11 overlay

The PKCS#11 overlay uses SoftHSM2. It initialises the token and generates the AES-256 KEK automatically on the StatefulSet volume. The Skaffold module creates the keystone-pkcs11-pin Secret before deploying:

skaffold run -f skaffold-k3s-test.yaml \
  -p pkcs11 --default-repo localhost:5000 --tag raft-test --cleanup=false
kubectl -n keystone-raft-test get pods -w

TPM overlay

The TPM overlay runs a swtpm sidecar in the pod. First provision the sidecar’s state and the KEK context files on the k3s host:

sudo mkdir -p /var/lib/keystone-raft-test/swtpm
sudo chown 65532:65532 /var/lib/keystone-raft-test/swtpm

swtpm socket \
  --tpmstate dir=/var/lib/keystone-raft-test/swtpm \
  --ctrl type=tcp,port=2352 --server type=tcp,port=2351 \
  --tpm2 --flags not-need-init &
SWTPM_PID=$!

export TPM2TOOLS_TCTI="swtpm:host=127.0.0.1,port=2351"
tpm2_startup -c
TPM_KEK_TCTI="$TPM2TOOLS_TCTI" \
  TPM_KEK_CONTEXT_FILE=/tmp/keystone-tpm-kek.ctx \
  cargo run -p openstack-keystone-storage-crypto-tpm --example tpm_kek_demo

kill "$SWTPM_PID"
sudo rm -f /var/lib/keystone-raft-test/swtpm/.lock

If the TPM reports DA lockout, delete /var/lib/keystone-raft-test/swtpm/* and repeat the provisioning steps.

Then deploy the overlay. The Skaffold module creates the keystone-tpm-kek Secret from /tmp/keystone-tpm-kek.ctx and /tmp/keystone-tpm-kek.ctx.hmac:

skaffold run -f skaffold-k3s-test.yaml \
  -p tpm --default-repo localhost:5000 --tag raft-test --cleanup=false
kubectl -n keystone-raft-test get pods -w

When testing API flows, port-forward the service and authenticate as the bootstrapped admin user against project admin:

kubectl -n keystone-raft-test port-forward svc/keystone-rs 18080:8080

The k3s-raft-pkcs11 job in .github/workflows/functional.yml runs the PKCS#11 overlay in CI when the storage crates or the Kubernetes overlays change. tools/k3s-raft-smoke.sh is the check it runs and can be used locally against a deployed overlay: it authenticates as admin, restarts the pod and verifies that the earlier token is still valid.

Tear down either variant with:

skaffold delete -f skaffold-k3s-test.yaml \
  -p pkcs11 --default-repo localhost:5000
skaffold delete -f skaffold-k3s-test.yaml \
  -p tpm --default-repo localhost:5000

OpenStackClient

Point an OpenStackClient cloud entry at the deployment’s Rust or mixed endpoint and provide the configured domain, project, username, and password. Use the generated OpenAPI reference when testing Keystone-NG-only v4 routes that OSC does not expose.

Submission

Run the relevant tests, pre-commit run --all-files, and git diff --check. Commits use Conventional Commits and must include the DCO sign-off with git commit -s.

Observability stack

The development stack can ship Keystone’s traces and metrics to local backends, so a change to the telemetry (ADR 0040) can be looked at end to end. For the production configuration see the OpenTelemetry guide.

ComponentRoleLocal port
Jaeger v2 (all-in-one, in-memory)Receives OTLP traces, UI and query API16686 (UI), 4318 (OTLP/HTTP)
PrometheusScrapes /metrics of every keystone-rs pod9090
GrafanaQueries both, anonymous admin3000

Traces are pushed over OTLP. Metrics are only scraped: the stack sets metrics_enabled = false, so no series is ingested twice. Everything is stored in memory and lost on restart. Never expose the stack, Grafana allows anonymous admin access.

Running it on k3s

The manifests are in tools/k8s/observability and the skaffold module is observability. Deploy the backends first, then Keystone with the otel profile, which selects tools/k8s/keystone/overlays/skaffold-otel and configures [otel] through OS_OTEL__* environment variables:

skaffold run -m observability
skaffold run -m infra,keystone -p local,otel --default-repo localhost:5000

The keystone image is built with the otel cargo feature in every case; it only compiles the exporters in, and they stay off without the profile. To look at the UIs, use the ingress hosts jaeger.local, prometheus.local and grafana.local (same scheme as keystone.local; add them to /etc/hosts next to the Keystone ones, pointing at the ingress address):

192.168.2.121 jaeger.local prometheus.local grafana.local

Running it without Kubernetes

Start Jaeger, then the live API test server with the export switched on:

docker run --rm -p 4318:4318 -p 16686:16686 jaegertracing/jaeger:2.9.0
KEYSTONE_TEST_OTLP_ENDPOINT=http://127.0.0.1:4318 \
JAEGER_QUERY_URL=http://127.0.0.1:16686 \
  cargo nextest run --profile api -p test_api --test integration_api_v4 observability

tools/start-api.sh builds keystone with the otel feature and enables [otel] whenever KEYSTONE_TEST_OTLP_ENDPOINT is set. The test sends a request with a sampled traceparent and expects the span in Jaeger and the series on /metrics. It is skipped without JAEGER_QUERY_URL. In CI the Test job runs Jaeger as a service container and sets both variables for the REST API tests.

Queries

Prometheus (http://prometheus.local, or Grafana’s Explore):

# 95th percentile request latency per route
histogram_quantile(0.95,
  sum by (le, route) (rate(keystone_http_request_duration_seconds_bucket[5m])))

# Requests per second by status class
sum by (status) (rate(keystone_http_requests_total[5m]))

# Failed logins per authentication method
sum by (method) (rate(keystone_auth_attempts_total{outcome="failure"}[5m]))

# Rate limiter rejections
sum by (scope) (rate(keystone_rate_limit_rejections_total[5m]))

Jaeger (http://jaeger.local):

  • Service keystone, Operation POST /v3/auth/tokens lists the token issuing requests. Operations are named {method} {route template}.
  • Min Duration 100ms narrows the list to slow requests, and the Tags field takes http.response.status_code=500 for failures.
  • A request with a traceparent header is found by its trace id, which also works through the API: curl localhost:16686/api/traces/<trace-id>.

Troubleshooting

  • No service keystone in Jaeger: check that the pod runs the image built with the otel feature and that the startup log has no telemetry warning. A build without the feature logs one and exports nothing.
  • Spans are exported in batches, so they show up a few seconds late.
  • Jaeger and Prometheus keep their data in memory. Redeploying the observability module restarts them and clears it, and a service only shows up in Jaeger once it has sent a trace, so send a request to Keystone afterwards.
  • Grafana Drilldown > Traces does not list the Jaeger data source, the app only supports Tempo. Use Explore with the Jaeger data source or the Jaeger UI instead.
  • Grafana Explore with the Jaeger data source answers 400 Bad Request for an empty search, because Jaeger requires a service. Pick the service keystone in the Search tab, or paste a trace id in the TraceID tab.
  • Prometheus target keystone is empty: the targets come from the DNS name of the headless service keystone-rs-internal, which has to resolve.
  • Only the root GET /v3/... spans and a few others show up: span_level defaults to info, which drops the debug provider and driver spans. The stack sets OS_OTEL__SPAN_LEVEL=debug in the otel overlay, so a deployment without it, or with another overlay, only exports the coarse spans.
  • The default sampler follows the caller’s traceparent. sampling_rate is 1.0 in this stack so that every root span is kept.

Testing

Choose the narrowest command that covers the change and always specify the crate for crate-level commands.

PurposeCommand
Workspace type checkcargo check --message-format=short
Crate unit testscargo test -p <crate_name>
Integration testscargo test -p test_integration
Raft integration testscargo nextest run -p test_integration --profile raft
Raft storage crate suitecargo test -p openstack-keystone-distributed-storage
Live API testscargo nextest run --profile api -p test_api
Formatting checkcargo fmt --all -- --check
Crate lintcargo clippy -p <crate_name>

Raft Storage Tests

cargo test -p openstack-keystone-distributed-storage is the authoritative suite for the storage crate. Its tests/test_cluster/ binary (one module per area, shared helpers in harness.rs) starts real multi-node clusters in one process and covers membership changes, snapshot catch-up, DEK rotation, backup and restore, leader transfer, leader failover and node restart, and quarantine. It sets the development KEK itself, so it does not need tools/raft-env.sh. The tests are #[serial] and use fixed port ranges, so run them with a single test process per machine.

--profile raft of test_integration is a different suite: it runs the keystone integration tests against Raft-backed drivers. The SoftHSM2 based test_pkcs11_cluster only runs with --features pkcs11.

Building the storage crate needs protoc (for example the protobuf-compiler package).

Live API Tests

The API profile starts SPIRE, OPA, and a real Keystone server through tools/start-api.sh. Run tools/teardown-api.sh before a fresh run if a prior run was interrupted. Do not pipe nextest through tail; redirect it to a file because long-running child processes inherit the output descriptor.

The bootstrap-created default domain does not receive OAuth2 signing keys. Tests that need JWKS or discovery must create a new domain through the API and poll for asynchronous key provisioning.

Password authentication returns IdentityInfo::User. Mocks for the real password-auth path must use the same identity variant.

Documentation Tests

Regenerate the API specification and build the book:

cargo run -p openstack-keystone --bin keystone -- --dump-openapi yaml \
  > doc/src/openapi.yaml
mdbook build doc

doc/src/openapi.yaml is generated input for the documentation build and is intentionally ignored by Git. The mdBook workflow regenerates it before every published build; do not add it to a commit.

API Development

Keystone-NG follows domain-driven crate boundaries:

  • crates/keystone owns the service binary and HTTP handlers.
  • crates/core owns domain providers and backend traits.
  • crates/core-types owns shared domain structures.
  • crates/api-types owns request and response structures.
  • crates/*-sql and crates/*-raft own persistence drivers.

Handlers and Types

Place v3 and v4 handlers below the owning API module, keep one handler per module, and register it with the domain’s openapi_router(). API request and response types belong in crates/api-types so the OpenAPI document follows the implementation.

CRUD handlers require tests for valid authentication with positive and negative policy decisions and for invalid authentication.

Backend Traits

Use the established CRUD names: create_<resource>, get_<resource>, list_<resources>, update_<resource>, and delete_<resource>. Keep each driver in the crate belonging to its domain and storage technology.

Policies

Policy files use policy/<domain>/<resource>/<action>.rego. List handlers must run collection policy and then re-check each returned item with its read policy. Never send decrypted secrets to OPA. Read the security model before changing authentication, scope, delegation, credentials, tokens, or policy input.

Python API compatibility

Tracks where this implementation’s HTTP surface differs from python Keystone’s. Started as the documentation deliverable requested by #938; it is seeded from that issue’s body table and grows as rows are closed. #938’s table is itself stale in places, so rows here are checked against the code and the Identity API v3 reference rather than copied verbatim.

Verify with the tempest identity suite:

tools/run-tempest-local.sh                 # full suite
TEMPEST_REGEX='tempest\.api\.identity\.admin\.v3\.test_policies' \
  tools/run-tempest-local.sh               # one module

Claims in this document are backed by a measured before/after pair on the same revision and host, not by a remembered score. The most recent run, against 8fc78201:

BaselineWith the policy API
Ran133133
Passed6163
Failed6866
Skipped44

Diffing the two failing-test lists shows exactly two fixed — test_create_update_delete_policy and test_list_policies — and zero regressions.

Endpoint coverage

FeatureEndpointsStatus
Service catalog/v3/services (CRUD)Done
Endpoints/v3/endpoints (CRUD)Done — legacy region attribute auto-vivifies a Region and is mirrored back
Regions/v3/regions (CRUD, plus PUT /v3/regions/{id})Done (#1078)
Policies (legacy blob store)/v3/policies (CRUD)Done (#1035) — tempest verified, see below
Unified limits/v3/registered_limits (CRUD, batch create), /v3/limits (CRUD, batch create), /v3/limits/modelDone (#1091)
Project tags/v3/projects/{project_id}/tagsMissing
Application credentials (v3.10)/v3/users/{user_id}/application_credentialsMissing
Access rules (v3.13)/v3/users/{user_id}/access_rulesMissing
System role assignments/v3/system/users/{user_id}/roles, .../roles/{role_id}Partial — user grants (GET/HEAD/PUT/DELETE) implemented; the /v3/system/groups/{group_id}/roles* variants are missing
Project hierarchy/v3/projects/{project_id}/parents, .../subtreeMissing
OS-INHERIT role inference list/v3/role_inferencesDone
OS-TRUST/v3/OS-TRUST/trustsPartial — create/get/list/delete; no PATCH (python has none either). Missing /{id}/roles sub-resource and response links.self (#1082)
OAuth2/v3/OS-OAUTH2/*Missing
OS-ENDPOINT-POLICY/v3/policies/{id}/OS-ENDPOINT-POLICY/*, /v3/endpoints/{id}/OS-ENDPOINT-POLICY/policyMissing — needs the policy_association table; deliberately out of scope for #1035, follow-up issue to be filed

Cross-cutting behaviours

  • Malformed JSON bodies answer 400 with an {"error": ...} document. Axum’s Json<T> extractor natively answers a type-mismatch body with 422 text/plain; crates/keystone/src/api/json_rejection.rs normalizes it.
  • Collection pagination follows ADR 0029: ?limit=/?marker= with links. Python Keystone instead truncates to [DEFAULT] list_limit and sets a truncated flag on the collection. This is a deliberate, uniform deviation across every collection endpoint here, not a per-resource gap.
  • Per-member links.self is not emitted on any resource yet (#1082).

Policies (/v3/policies)

Storage for opaque policy documents that remote services fetch and interpret. Deprecated upstream (“Keystone is not a policy management service”) and unrelated to this service’s own OPA-based authorization — see openstack_keystone_core::policy_store.

Implemented verbs, matching python Keystone’s PolicyResource exactly:

VerbPathSuccess
POST/v3/policies201
GET/v3/policies (?type= exact match)200
GET/v3/policies/{policy_id}200
PATCH/v3/policies/{policy_id}200
DELETE/v3/policies/{policy_id}204

There is deliberately no PUT /v3/policies/{policy_id} — python Keystone does not define one, so it answers 405. (Contrast /v3/regions/{id}, which does have an upstream PUT.)

Request/response details carried over from upstream:

  • blob is accepted as a string only on POST/PATCH, per keystone/policy/schema.py. An object-valued blob is a 400. Responses carry whatever JSON value is stored, so rows written by older python releases (object blobs) still read back correctly.
  • The stored column is JSON-encoded exactly as python’s JsonBlob type decorator does. Round-tripping is JSON-semantic, not byte-identical: whitespace, string escaping, and object-key ordering may differ between json.dumps and serde_json.
  • id is always server-assigned on create; a caller-supplied id is discarded (_assign_unique_id). On PATCH an id is accepted only when it equals the path segment, else 400 (“Cannot change policy ID”), and is never persisted.
  • An empty {"policy": {}} patch document is a 400 (minProperties: 1).
  • Unknown properties round-trip through the extra column. On create their names are normalized (: and - become _), matching upstream’s _normalize_dict; PATCH does not normalize, also matching upstream.
  • PATCH merges extra into the stored properties (supplied keys win, omitted stored keys survive), matching upstream’s update_policy. Note this differs from e.g. RegionUpdate.extra, which this codebase overwrites wholesale.

Authorization mirrors keystone/common/policies/policy.py: list/show are admin-or-system-reader, create/update/delete are admin-only.

Follow-ups

Known gaps deliberately left out of #1035. These issues still need to be filed — no issue numbers exist yet, so nothing here links to one:

  • OS-ENDPOINT-POLICY — /v3/policies/{id}/OS-ENDPOINT-POLICY/* and /v3/endpoints/{id}/OS-ENDPOINT-POLICY/policy, backed by the policy_association table. Tempest’s test_policies does not exercise it.
  • input.credentials.system_scope audit — several policies compare against a key Credentials never emits. The affected rules are not uniform and region’s python default is broader than the rego, so this needs an endpoint-by-endpoint audit rather than a rename. See security model §9 for the per-rule breakdown.

Authentication Plugin Development

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("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 Administrator Plugin Operations):

# 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

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.

Additional trust boundaries (security review, issue #989)

The diagram above draws the request → Credentials → OPA path. Three more boundaries carry the same weight and are named here explicitly:

  • The OPA policy bundle itself. Whatever a running opa instance loads from ghcr.io/<repo>/opa-bundle is the authorization logic — a tampered or rolled-back bundle is exactly as dangerous as a tampered Keystone binary, without touching Keystone’s own code or the policy/ tree at all. policy-container.yml’s publish job signs the bundle by digest with cosign keyless signing and verifies that signature before reporting success (Gate H, security review V4); a deployment that wants load-time verification (OPA’s own bundle downloader has no native cosign/Sigstore hook) must run the equivalent cosign verify itself before serving traffic, or pin resource to a verified digest instead of latest — see tools/opa_config.yaml.
  • The WASM plugin invocation path (ADR 0025). A mode = full_auth or route plugin is invoked pre-authentication, by definition — a remote, unauthenticated party triggers plugin execution and its http_fetch calls at will (security review V7). AuthenticationContext::WasmPlugin and the plugin_claims projection already exist in the tree ahead of the full mechanism; ADR 0025 is still Proposed, so the SSRF/claims-injection/ identity-binding/resource-exhaustion controls it specifies are deferred until implementation starts, at which point each control needs a test that exercises the failure, not just the intended path — tracked as part of that feature’s definition of done, not as a standalone gate now.
  • The OAuth2/OIDC provider surface (ADR 0026). Acting as an OAuth2/OIDC provider is the classic web-authz surface Keystone did not previously have — open-redirect via redirect_uri, authorization-code interception without PKCE, refresh-token replay (security review V8). ADR 0026 commits to exact-match redirect_uris, mandatory PKCE for public clients, and refresh-token rotation; V8a additionally verified (and where needed, fixed — the device-flow endpoints now share the /authorize//token per-IP rate-limit posture) that the client-id/ grant-type oracle and username-enumeration classes don’t apply here the way they did in the public research that prompted the review.

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.

A stronger variant exists for /v3/policies, whose stored document (blob) and open-ended extra map are both arbitrary caller-supplied data: the to_policy_input() methods in crates/api-types/src/v3/policy.rs construct the policy input from an allowlist (id, type) instead of removing known-bad keys. Prefer that shape for any resource with an open key space — a denylist cannot cover extra.

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. A policy failure that is not Forbidden (OPA unreachable, malformed decision) must be propagated, not treated as “drop this item” — otherwise an infrastructure fault silently returns a truncated collection. 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, crates/keystone/src/api/v3/policy/list.rs.

I8a — A per-item re-check does not license a permissive collection filter

What: the collection-level policy must still narrow the query to what the caller could plausibly read, using filter values the driver actually applies. An unprivileged caller must not be able to request the whole collection and have the per-item pass sort it out. Why: I8 makes a permissive collection filter safe (nothing leaks) but not cheap — every row still has to be fetched and OPA-evaluated before being discarded, so a single request becomes a full-table scan plus N policy round-trips. That is a resource-exhaustion amplifier available to any authenticated user, which is what issue #1117 found in identity/credential/list: it granted every member an unfiltered list. It now demands a user_id filter naming the caller unless the caller is a privileged lister (admin, or reader on the system scope). Where: policy/credential/list.rego, paired with the AUTHORIZED_PAGE_SCAN_FACTOR bound in collect_authorized_page (crates/keystone/src/api/common.rs), which caps the same cost for the privileged listers whose rows legitimately do get filtered.

When adding a collection endpoint, check that the value the policy is gating on is the same value the driver filters by. If the policy approves ?user_id=X but the driver ignores user_id, the check is decorative: the scan happens anyway. In credential/list both read a single parsed CredentialListParameters, so they cannot diverge.

I9 — Group role revocation relies on validation-time role recalculation

What: revoking a group role assignment (GroupDomain, GroupProject, GroupSystem) creates no revocation event, because an event for a group cannot be bound to its members and would revoke every token carrying the role on that scope (Python bug #1662514). Members lose the role because calculate_effective_roles re-resolves user, group-membership and assignments on every token validation, for domain, project and system scope. User assignment revocation still creates a user_id-bound event. Roles that are not assignment-derived (a token restriction carries its own role set, ADR 15) are not re-resolved from assignments by design; the restriction itself is re-read on every validation and is what must be changed or deleted to end such a token. Why: an unbound event is a denial of service against unrelated users; no event plus a cached role set would be a privilege that outlives its revocation. Both are avoided only while roles are never trusted from the token payload. Where: crates/core/src/assignment/service.rs (revoke_grant), crates/core/src/auth.rs (calculate_effective_roles), crates/assignment-driver-sql/src/lib.rs (list_assignments, user to groups expansion). There is intentionally no config switch to restore the broad event; see ADR 9.

I10 — Existence is not disclosed before authorization

What: a handler that looks up a path object (user, group, role) before enforcing policy must still enforce policy when the object is missing, using an id-only target, and only then report 404. Why: a 404/403 split lets an unauthorized caller enumerate ids. Where: crates/keystone/src/api/v3/role_assignment/system/mod.rs (policy_target), used by every system/{users,groups}/.../roles handler.

5. Delegated-auth specifics

Trust, application-credential, and EC2 are authentication methods, each with its own AuthenticationContext variant (service grants, which have no Fernet form, are described after them). 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).

Closed 2026-09-14 (ADR 0037): application-credential access_rules (per-endpoint restrictions) are now enforced at request time. enforce_access_rules() (crates/core/src/api/auth.rs) runs inside Auth::from_request_parts, before every return path, and denies a request from a rules-restricted app-cred whose (method, path) no rule permits against Keystone’s own service (identity) — a rule naming a different service is a restriction on that service, enforced by its own keystonemiddleware, not by Keystone. See ADR 0037 for the full design and why this is a check inside the existing auth extractor rather than a separate middleware layer. The interim mitigation from security review V5 (create_application_credential’s WARN log, and application_credential.reject_unenforced_access_rules) remains in place unchanged but no longer describes an unenforced control — see the updated doc-comment on that config field.

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.

Service grants (ADR 0036)

AuthenticationContext::ServiceGrant { grant, actor }: a delegatable service (actor: its client_id and SPIFFE ID) acting for the grantor under a stored grant. Unlike the methods above it never has a Fernet payload: FernetToken::from_security_context rejects it on every scope, and Keystone does not accept its own OAuth2 JWTs as bearer credentials. The context exists only inside grant redemption at the token endpoint (crates/core/src/service_grant/redeem.rs), where new_for_scope() applies the same checks as for the other delegated methods, and its result is signed into a JWT for downstream services.

  • is_delegated == true, delegated_project_id == grant.target.project_id, service_grant_id == grant.id, and the actor is projected into the policy input as credentials.actor. The JWT carries the same facts in delegation_context (auth_method: service_grant) and the RFC 8693 act claim.
  • Scope: only ScopeInfo::Project with the grant’s own project (validate_scope_boundaries); every other scope, the unscoped one included, is ScopeNotAllowed. The I3 tripwire is enforced again when the JWT is verified (enforce_delegation_invariants).
  • Roles (I4): grant.role_ids (narrowed by the redemption request) intersected with the grantor’s live effective roles on the project. There is no admin bypass: the grant, not the grantor’s privileges, is the ceiling.
  • Issuer: the grant lives in, and is redeemed at, its project’s domain, so only that domain’s key signs the token (ADR 0026 §5 domain key isolation). The grantor may be of any domain; never sign with the grantor’s domain key.
  • Validation re-checks that the grant is enabled and unexpired and that its grantor is the principal. Redemption additionally re-checks the grantee client, its SPIFFE match, and the grantor, project and domain.
  • Creating a grant is refused under any delegated chain except an unrestricted application credential (ensure_chain_may_delegate, keyed on authentication_context()), so a grant cannot be used to mint further grants.
  • Token exchange refuses a service grant subject (NotDelegated); the phase 2 on-behalf-of exchange will add its own variant.

Service clients (ADR 0036)

AuthenticationContext::ServiceClient { actor }: a delegatable service authenticated by its X.509-SVID on the internal interface, acting as itself to read or revoke grants naming it (GET/DELETE /v4/service-grants/{id}). It is built by service_grant::service_client_context only after the SVID matched one of the client’s spiffe_ids, and only for the grant being accessed.

  • Not delegated: no delegated_project_id, no roles anywhere. Only ScopeInfo::Unscoped is allowed; every other scope is ScopeNotAllowed.
  • Never becomes a Fernet token.
  • Policy recognises it by auth_type == "service_client" together with credentials.actor.client_id == grant.grantee.client_id (policy/service_grant/common.rego, is_grantee_service). No other policy grants it anything.

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 pipeline — SecurityContext::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. This checklist is also the required “Security review checklist” section of the pull-request template (security review design gate, issue #988) – every PR touching these areas carries it, ticked by the reviewer, not just this prose reference.

  • 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)?
  • Same endpoint: can an unprivileged caller request the whole collection and leave the per-item pass to sort it out? Is the value the collection policy gates on the same one the driver actually filters by (I8a)?
  • Does the change let a narrow auth method be broadened by a request-supplied scope (I5)?
  • Does the change revoke or invalidate something without a revocation event (like group role revocation)? Is the effect guaranteed by recomputation on every validation, never by data cached in the token (I9)?
  • Handler looks up a path object before policy? Does a missing object still go through policy before the 404 (I10)?
  • New state-changing provider method? Is it wrapped in audited_op! / audited_if_ctx! (fail-closed attempt record, static failure reason), or listed with a reason in the audit_coverage allow-list?
  • Does a new audit record take its initiator from the authentication chain (build_initiator_from_principal / _from_vsc), never from the token scope or from request-supplied text? Does it carry identifiers only (no secrets, no free-form error or plugin text; reasons from the closed OutcomeReason vocabulary)?
  • New authentication surface? Is it covered by the perimeter completion allowlist (is_authentication_surface) or emitting its own perimeter record, with a test asserting the record for success, failure and 429?
  • 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

  • input.credentials.system_scope is a dead key in several policies. Credentials.system (crates/core/src/policy.rs) serializes the system scope under the field name system; nothing ever emits system_scope, so any rule comparing against it is unreachable. It fails closed (access is denied, never granted), so this is a functional gap rather than a vulnerability. policy/policy/*.rego uses the correct input.credentials.system.

    The affected rules are not uniform, and a global key rename would be wrong. Each needs checking against the python default it is meant to mirror:

    RegoPython defaultEffect of the dead key
    assignment/listRULE_ADMIN_OR_SYSTEM_READERReal: system reader is denied
    auth/token/showadmin or token owner, plus system readerReal: system reader is denied
    endpoint/{list,show}RULE_ADMIN_OR_SYSTEM_READERReal: system reader is denied
    service/{list,show}RULE_ADMIN_OR_SYSTEM_READERReal: system reader is denied
    auth/project/listany valid tokenNone — the rego is default allow := true, so nothing is denied
    region/{list,show}check_str='' (any valid token)Different bug: the rego is default allow := false, so it is stricter than python regardless of the key; renaming would not fix it

    Needs a follow-up issue for an endpoint-by-endpoint audit against the Identity API policy reference, not a blanket search-and-replace. This class of bug is exactly what the Gate B3 real-opa run handler tests catch and hand-written Rego unit tests cannot.

Cryptography inventory and FIPS 140-3 status

This page lists every cryptographic primitive the workspace uses, where it lives, which library provides it, and whether it can run on the FIPS 140-3 validated AWS-LC module. It is the reference for FIPS and crypto audits.

Each source file that implements or selects a primitive carries a one-line comment // Crypto inventory: <ID> that points at a row below. Callers that only use a seam (for example HTTPS clients built on rustls) are covered by the marker of the seam (tls-provider, NET-1) and carry none of their own. When you add, move or remove cryptography, update the row and the comment in the same change (see the checklist at the end).

Reading the status column

StatusMeaning
ValidatedRuns on the validated AWS-LC module when built with the fips feature of its crate (or of the service).
Not validatedUses a library without a FIPS 140-3 validation. Replacing it needs a design change or a different library.
Compat-fixedThe algorithm is dictated by an external format (Python Keystone, AWS, RFC 6238, WebAuthn). It cannot be swapped without breaking interoperability.
Not a security primitiveHashing or randomness for identifiers, never a secret or a trust decision.
DelegatedThe work happens outside this process (HSM, TPM, PostgreSQL). Its validation is the device’s.

“Validated” is a statement about which implementation is linked, based on the aws-lc-rs documentation and tests for the fips feature. It is not a CMVP certificate claim, and an operator still has to check the security policy of the module version that ships (see Caveats).

Build modes

CrateDefault buildfips feature
openstack-keystone-storage-cryptoring (AES-GCM, HKDF, HMAC, RNG)aws-lc-rs with its fips feature: AES-256-GCM, HKDF, HMAC, RNG. GCM IVs are generated inside the module, with a per-message key. See ADR 0016-v2 “FIPS builds”.
cadfring (HMAC-SHA256, HKDF, RNG)aws-lc-rs with its fips feature: HMAC, HKDF, RNG.
openstack-keystone-tls-providerringaws-lc selects aws-lc-rs; fips adds rustls’ fips feature (validated module). Used by rustls, tokio-rustls, tonic, reqwest, vaultrs, ldap3.
openstack-keystone-distributed-storageforwards to storage-cryptofips forwards to storage-crypto.
openstack-keystone (service)ring everywhererustls-aws-lc switches TLS to aws-lc-rs; fips also selects the validated module for TLS and for the storage cryptography.

Use --no-default-features --features fips on storage-crypto and cadf to keep ring out of those crates. A process that makes a FIPS claim must call assert_fips() (storage-crypto and cadf both export it) and openstack_keystone_tls_provider::is_fips() at start-up. They fail unless the validated module is linked and in FIPS mode.

Mixing builds in one Raft cluster is unsupported. By design a fips node can read records written by a default node (legacy base-key fallback in storage-crypto), but a default node cannot read records written by a fips node. Only the first direction has a unit test (the legacy state-record version binding); the cross-build read is not tested end to end.

Inventory

Distributed storage (storage-crypto, storage, KEK providers)

IDWherePrimitiveProviderStatus
STO-1storage-crypto/src/cipher.rs, backend.rsAES-256-GCM for Raft log, state machine and backup recordsring; aws-lc-rs RandomizedNonceKey in fipsValidated in fips (module-generated IV, per-message key). Default build uses deterministic nonces (SP 800-38D §8.2.1).
STO-2storage-crypto/src/dek.rs, backend.rsHKDF-SHA256 (Extract-then-Expand) for the Log, State, Backup and audit sub-keysring; aws-lc-rsValidated in fips.
STO-3storage-crypto/src/cipher.rsHKDF-SHA256 per-message key (fips only) and Expand-only nonce derivation (default only)sameValidated in fips. The default-build Expand-only nonce derivation is not an approved flow; it is skipped in fips.
STO-4storage-crypto/src/kek.rsEnvKek: AES-256-GCM wrap of the DEK. Wrap nonce is a truncated HMAC-SHA256 (default); module IV (fips)sameValidated in fips.
STO-5storage-crypto/src/dek.rsDEK generation from the system RNGring SystemRandom; aws-lc-rs DRBGValidated in fips.
STO-6storage-crypto-pkcs11/src/lib.rsCKM_AES_GCM wrap of the DEK inside the token; optional token-generated IVcryptokiDelegated to the PKCS#11 token. The token-generated-IV path uses the message API and is untested on hardware.
STO-7storage-crypto-tpm/src/lib.rsTPM 2.0 AES-CFB wrap of the DEK, HMAC tag computed by the TPM, 16-byte IV from randtss-esapiDelegated to the TPM. Single-node only. The IV comes from rand, not the TPM RNG, and AES-CFB is not the SP 800-38D mode used elsewhere.
STO-8storage/src/store/state_machine/snapshot.rsRandom 64-bit nonce salt of a backupstorage-crypto random_bytesValidated in fips.
STO-9storage/src/network.rs, keystone/src/server/listener/raft_grpc.rsmTLS between Raft nodes, tonic, x509-parser for peer certificatesrustls through openstack-keystone-tls-providerValidated with the service fips feature, except SPIFFE mTLS (STO-12).
STO-12spiffe-rustls, spiffe-rustls-tokioSPIFFE X.509-SVID mTLSring, hardwired by spiffe-rustlsNot validated. Always ring; see TLS.
STO-10storage/src/network.rs, raft_grpc.rs (tests, dev certs)rcgen certificate generationrcgen ringNot validated. Test and development only.
STO-11storage/src/store/state_machine/*.rs, raft_sm.rsrand for snapshot indexesrandNot a security primitive.

Audit framework (cadf)

IDWherePrimitiveProviderStatus
AUD-1cadf/src/crypto.rs, dispatcher.rsHMAC-SHA256 signature over the JCS-canonical event, constant-time verificationring; aws-lc-rsValidated in fips.
AUD-2cadf/src/kdf.rs, identity.rsPer-node signing key: HKDF(salt = "{service}-audit-hmac-salt-v1", IKM = KEK, info = "{service}-audit-hmac-v1:{node_id}")ring; aws-lc-rsValidated in fips.
AUD-3cadf/src/keyring.rs32-byte KEK generation for the audit keyringring SystemRandom; aws-lc-rs DRBGValidated in fips.
AUD-4cadf/src/syslog.rsRFC 5425 syslog over TLS (audit-syslog feature), webpki roots from rustls-native-certsrustls through openstack-keystone-tls-providerValidated with the service fips feature.
AUD-5cadf/src/runtime.rs, spool.rsuuid v4 event identifiersuuid (OS RNG)Not a security primitive.

Tokens, keys and credentials

IDWherePrimitiveProviderStatus
TOK-1key-repository, token-driver-fernet, credential-driver-sqlFernet (AES-128-CBC + HMAC-SHA256) for tokens and for credential blobs; keys from Fernet::generate_keyfernet 0.2 (RustCrypto aes, cbc, hmac)Compat-fixed (Python Keystone format) and Not validated.
TOK-2key-repository/src/asymmetric/keygen.rs, jwt_bridge.rs, token-driver-jwsES256 (P-256) and RS256 (RSA-2048) key generation and JWS signingp256, rsa, jsonwebtoken rust_cryptoNot validated. RSA-2048 is allowed through 2030 by SP 800-131A; ES256 is approved by algorithm.
TOK-3core/src/oauth2_key/*, spiffe_key/*JWKS publication, key ids, domain signing keys (ES256/RS256)sameNot validated.
TOK-4keystone/src/federation/api/oidc_utils.rs, core/src/oauth2_client/verify.rsVerification of upstream OIDC ID tokens and client assertions (asymmetric algorithms only)jsonwebtokenNot validated. The algorithm set is the peer’s.
TOK-5core/src/oauth2_client/pkce.rs, oauth2_session*.rs, auth_plugin_auth.rs, mapping/version.rs, identity/shadow_id.rsSHA-256 (PKCE S256, token and code hashes, ids)sha2SHA-256 is approved; the implementation is Not validated.
TOK-6core/src/auth_plugin.rs, mapping/hmac.rsHMAC-SHA256 for auth-plugin handles and mapping fingerprintshmac+sha2Not validated (approved algorithm).
TOK-7core/src/api_key/crypto.rs, oauth2_client/crypto.rsArgon2id for API key and OAuth2 client secrets; random secrets from randargon2, randNot validated. Argon2id is not an approved KDF under SP 800-132.
TOK-8core/src/api_key/token.rsSHA-256 and CRC32 of the API key checksum, random base62 entropysha2, crc32fast, randCRC32 is Not a security primitive; entropy source Not validated.
TOK-9keystone/src/api/v4/oauth2/html.rs, federation/api/oidc_utils.rsCSP nonce, OIDC state/nonce values from randrandNot validated (OS RNG).
TOK-10auth-plugin-runtime/src/lib.rsSHA-256 integrity check of plugin artifactssha2Not validated (approved algorithm).

Users and external protocol compatibility

IDWherePrimitiveProviderStatus
PWD-1password-hashing/src/bcrypt.rs, bcrypt_sha256.rsbcrypt and bcrypt-sha256 password hashesbcrypt, hmac, sha2Compat-fixed (existing Python Keystone hashes) and Not validated; bcrypt is not an approved password KDF.
PWD-2password-hashing/src/scrypt.rsscryptscryptCompat-fixed, Not validated.
PWD-3password-hashing/src/pbkdf2.rsPBKDF2-HMAC-SHA512hmac, sha2Compat-fixed. The algorithm is approved (SP 800-132); the implementation is Not validated.
PWD-4password-hashing/src/plaintext.rs, lib.rsConstant-time compare, random 16-byte saltsubtle, randNot validated (OS RNG).
EC2-1core/src/credential/ec2_signature.rsAWS Signature v2 (HMAC-SHA1, HMAC-SHA256) and v4 (HMAC-SHA256) verificationhmac, sha1, sha2Compat-fixed (AWS protocol). HMAC-SHA1 is allowed for MAC use, not for signatures. Not validated.
TOTP-1core/src/credential/totp.rsHMAC-SHA1 TOTP (RFC 6238)hmac, sha1Compat-fixed, Not validated.
WEB-1webauthn crate, api-types/src/webauthn/*WebAuthn registration and assertion verification (ES256, RS256, attestation chains)webauthn-rs (links system OpenSSL through openssl)Not validated by this build; validity follows the system OpenSSL.

Transport and databases

IDWherePrimitiveProviderStatus
NET-1keystone (reqwest), cli-manage, assignment-driver-openfga, oslo-config (Vault)HTTPS clientsrustls through openstack-keystone-tls-providerValidated with the service fips feature.
NET-2identity-driver-ldapLDAPS / StartTLSldap3; the rustls-aws-lc feature selects aws-lc-rsValidated with the service fips feature. The rand use there only shuffles server URLs.
NET-3sea-orm / sqlx in all *-sql drivers and cli-manageTLS to MySQL and PostgreSQLruntime-tokio-native-tls (system OpenSSL)Delegated to the system OpenSSL. FIPS validity follows the host’s OpenSSL provider.

Tests and tools (no production impact)

tests/* and tools/* use sha2, hmac, sha1, jsonwebtoken and rcgen to build fixtures and cross-check against Python Keystone (tools/cross_verify.py, tools/generate_kats.py, tools/audit_vectors.py). tools/verify_and_pin_opa_bundle.sh and the Dockerfiles use sha256sum to pin downloaded artifacts. None of it ships in a service binary.

TLS and transport gaps

TLS follows openstack-keystone-tls-provider. With the service fips feature, rustls runs on the validated module through its own fips feature. This is the design of the provider crate; this inventory did not re-verify it with a handshake test.

Remaining gaps:

  • SPIFFE mTLS (spiffe-rustls) hardwires ring and stays on it in every build, so Raft peers authenticated by SPIFFE identities are not on the validated module.
  • Database TLS (sea-orm/sqlx with native-tls) and WebAuthn use the system OpenSSL.
  • An aws-lc or fips TLS build links aws-lc-sys or aws-lc-fips-sys next to the fips build of storage-crypto and cadf; the libraries use distinct symbol prefixes and coexist in one binary.

Caveats

  • The validated module only treats AES-GCM encryption as an approved service when it generates the IV. The fips build therefore never supplies an IV for encryption; decryption with an external IV is approved.
  • HKDF is only used as Extract-then-Expand with a non-empty salt and info in the fips build, which is the flow the aws-lc-rs documentation lists as an approved service. Expand-only HKDF remains in default-build nonce derivation and is never executed in fips.
  • Both assert_fips() functions check that the validated module is the one linked and running in FIPS mode. They do not prove that every call a binary makes into any library is an approved service; TOK-, PWD-, EC2-, TOTP-, WEB-* and NET-* are outside the module.
  • Hashes and MACs called “approved algorithm” above are NIST-approved algorithms, not validated implementations.
  • The statements here come from the aws-lc-rs documentation and source and from tests of this repository, not from the CMVP security policy of the module. Check the policy for the exact module version before an audit.

Maintenance checklist

When a change adds or alters cryptography:

  1. Add or update the row in this file with provider and status.
  2. Add // Crypto inventory: <ID> below the license header of each file that calls the primitive.
  3. If the primitive can run on the validated module, route it through the crate’s backend seam rather than calling the library directly, and add a known-answer test that runs in both builds.
  4. Run the unit tests with and without the fips feature for storage-crypto and cadf.
  5. Re-run grep -rnE "\b(use|extern crate) (sha2|sha1|hmac|hkdf|argon2|bcrypt|scrypt|fernet|jsonwebtoken|rsa|p256|rand|getrandom|ring|aws_lc_rs|rustls|webauthn_rs|cryptoki|tss_esapi)\b" crates --include=*.rs and check that every file it lists is covered by a row. Fully qualified uses without a use line (sha2::Sha256::new()) need a second grep for \b(sha2|sha1|hmac|hkdf)::.

Security Architecture Review: Preemptive Gates, Testing, and Vulnerability Vectors

Status: advisory review (2026-07-09), re-evaluated against main on 2026-09-14, and followed same-day by an implementation pass closing five of the six re-prioritized items — see §0 for what landed since and what remains. Companion to Security model (the normative invariant reference) and Policy enforcement. Where the two disagree, security-model.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.

0. Status at a glance (re-evaluated 2026-09-14)

The review’s gates were largely adopted between 2026-07-09 and 2026-09-14. This section records what actually landed, verified by direct code read against main; the per-vector sections below carry the detail. Landed means the mechanism exists and runs in CI; partial means the mechanism exists but does not yet cover what it was proposed to cover.

GateStatusEvidence
ALandedci.yml runs opa test policy unconditionally in the main job, not only on paths: policy/**
B1Landedtools/check_policy_handler_coverage.py, run unconditionally in ci.yml; four checks (missing policy / missing test / orphan policy / unenforced handler)
B2Landed 2026-09-14 (§0a); coverage still partialcrates/core/src/api/policy_contract.rs + CapturingPolicy, now with a fifth check_policy_handler_coverage.py check making adoption non-optional (shrink-only allowlist). Applied by 19 of 155 enforce()-calling handler modules
B3Harness landed, coverage partialget_state_with_real_policy() (crates/keystone/src/api/mod.rs) runs a real opa run -s over the actual policy/ tree. Used by 17 of 155
CLandedtools/check_delegated_policy_scope_drift_tests.py
DLandedtest_new_for_scope_delegated_roles_never_exceed_delegation_matrix, test_delegation_scope_kind_matrix_roles_never_exceed_delegation (crates/core/src/auth/tests.rs)
ELandedtools/check_rego_undefined_argument_footgun.py
FLandedfuzz/ (5 targets) + .github/workflows/fuzz.yml; scope_pinning_property proptest
GLanded (scheduled).cargo/mutants.toml scoped to core-types/auth.rs, core/auth.rs, core/policy.rs + mutants.yml — deliberately not a required per-PR gate
HPartialPublish-side cosign keyless signing and a verify step landed (policy-container.yml). tools/verify_and_pin_opa_bundle.sh (2026-09-14) provides the load-side verify-and-pin step as an explicit release tool; nothing runs it automatically, and tools/opa_config.yaml still defaults to the mutable :latest
ILandedtools/check_event_payload_no_secret_fields.py + policy_contract::assert_no_secrets
JLandedtools/check_security_checklist_sast.py

Vector status (updated after §0a): V3, V5, V8a, V9, V10 closed; V1, V2 structurally backed by Gates D/J; V6 closed for the three endpoints it named (the design-level follow-up ADR remains open); V3a’s mechanism is now a non-optional CI check, coverage remains partial; V4’s publish side is signed+verified, a verify-and-pin tool now exists for the load side, but nothing runs it automatically. V7 and V8 are no longer hypothetical — the features shipped, so their “when implemented” framing is stale (§V7/§V8).

What the re-evaluation changes about priority: the original top recommendation (A + B1 + B2) is now two-thirds done. The remaining work is no longer building the input-contract mechanism — it exists and is good — but making it non-optional, which was the point of proposing it as a route sweep (§V3a).

0a. Implementation pass (2026-09-14, same day)

The re-evaluation above was followed same-day by an implementation pass against the §7 “Re-prioritized” list. Five of the six items landed:

#ItemOutcome
1Make Gate B2 non-optionalDone. Fifth check added to tools/check_policy_handler_coverage.py: any file calling .enforce( must reference policy_contract (directly or via a sibling #[path] tests.rs), with an explicit, shrink-only ALLOWLIST_NO_POLICY_CONTRACT for the 136 pre-existing handlers not yet backfilled. os_trust/trust/{create,show,list,delete} and ec2tokens/create — the two named uncovered delegation surfaces — got full B2 and B3 coverage and are off the allowlist; adoption moved 14→19 of 155.
2Ship access_rules enforcementDone. ADR 0037 + enforce_access_rules() (crates/core/src/api/auth.rs), called from every return path of Auth::from_request_parts. Denies a request-rules-restricted application credential’s call that no rule permits against Keystone’s own service, before OPA policy evaluation. See §V5.
3Verify the policy bundle at load, pin by digestPartial. tools/verify_and_pin_opa_bundle.sh does the cosign verify + digest-pin step as an explicit release-time tool (not wired into a running OPA’s load path, since OPA itself still has no native hook — an init-container/admission-check integration is still a deployment-specific follow-up). See §V4.
4Rate-limit the three remaining crypto endpointsDone. check_ip() added to v3/ec2tokens/create, v3/auth/token/show, and the federation jwt::login / oidc::callback handlers. See §V6.
5Correct ADR statusesDone. ADRs 0022, 0025, 0026 changed from Proposed to Accepted, each naming the crates/modules that shipped it. See §V7/§V8.
6Write the two open property testsDone. crates/core/src/auth/tests.rs’s delegation_monotonicity_property module: app_cred_roles_never_exceed_delegation, trust_roles_never_exceed_delegation, app_cred_revoked_role_unusable_after_removal, trust_revoked_role_unusable_after_removal, all driving the real calculate_effective_roles() over a synthetic 5-role universe. See §V10.

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-model.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-model.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-model.md §8 ties each hardening back to a real CVE/OSSA, which is exactly the discipline that prevents regression.

The gaps below are therefore not “the design is wrong.” They are “the design depends on humans remembering a checklist, and the checklist is not yet mechanically enforced,” plus a handful of named surfaces that are documented as open or are newly proposed.

3. Vulnerability vectors to control explicitly

Each vector lists the attack, the current state, and the control the project should commit to (gate / design / pentest). Priority is the review’s opinion, not a mandate.

V1 — Delegation boundary escape via rescope/reauth (P1, mitigated, keep proving it)

Attack. A delegated caller (trust, app-cred, or EC2 credential minted under one) rescopes or reauthenticates to influence the token scope and act outside the delegation’s fixed project or role set. This is the scope-bind escape class (OSSA-2026-015, OSSA-2026-005).

Current state. Defended by I1–I5. The residual risk is not the existing code — it is the next change. The defense is spread across validate_scope_boundaries(), calculate_effective_roles(), from_security_context(), build_authz_info_from_fernet_token(), and Credentials::try_from. A change that touches one and forgets another reopens the class, exactly as the from_security_context → ProjectScopePayload fall-through nearly did (I4 history).

Control.

  • Testing: make the delegation-bound property a matrix test that is generated, not hand-written — every (AuthenticationContext variant) × (ScopeInfo variant) × (restricted / unrestricted delegation) cell asserted end-to-end 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-model.md I2), or the handler never calls enforce() at all. Any of these is an authz bypass that no Rust type catches.

Current state.

Original finding (2026-07-09):

  • opa test policy ran 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, did not trigger the policy test suite. The two halves of the authz decision were tested in separate CI jobs that never both ran on a cross-cutting PR.
  • opa fmt --check runs in linters.yml, but formatting is not correctness.
  • There was no gate asserting every enforced policy_name has a matching .rego rule and a _test.rego, nor that every CRUD handler calls enforce().
  • Fail-closed on OPA error looks correct (PolicyError → forbidden()), but there was no explicit test that an OPA outage / malformed response / timeout yields deny, not allow.

Re-evaluated 2026-09-14: closed. Gate A runs opa test policy unconditionally in the main ci.yml job, so the Rego suite now gates Rust-only PRs. Gate B1 (tools/check_policy_handler_coverage.py) runs unconditionally and checks all four directions — a dangling enforce() name, a policy without a sibling _test.rego, an orphan decision policy no handler references, and a CRUD handler with no enforce() call (with a single reviewed allowlist entry, v3/auth/token/create.rs, which is pre-authn by construction). Gate E covers the I2 undefined-argument footgun. What remains from this vector is not existence but content, tracked as V3a.

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-model.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 (P1; harnesses landed, Gate B2 now a CI check — 12% adoption)

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 (2026-07-09). Three layers exist; only two are tested.

LayerTested today
Rego logic in isolation✅ opa test policy (synthetic input)
credentials (chain) projection✅ Credentials::try_from tests + Rust scope-drift tripwire
Handler-built target/existing/policy_name, and its composition with the real policy❌ ad-hoc mock captures only

The seam is exercised only two ways today, neither sufficient:

  • Ad-hoc mock capture. A handler test may inject 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.

Re-evaluated 2026-09-14: the mechanisms landed; the coverage did not. Both proposed harnesses now exist, and both are better than what was asked for:

  • Gate B2 — crates/core/src/api/policy_contract.rs provides assert_object_keys (the ADR 0002 mis-key check), assert_no_secrets (I7 generalized to a recursive denylist of 11 secret-shaped field names), and assert_existing_presence (the target/existing slotting check), applied to CapturingPolicy’s recorded calls.
  • Gate B3 — get_state_with_real_policy() (crates/keystone/src/api/mod.rs) spawns a real opa run -s over the repository’s actual policy/ tree on a private Unix socket and wires it to the production HttpPolicyEnforcer. This is the stronger of the two options the review listed (managed subprocess rather than opa build -t wasm), and it panics rather than degrading if opa is missing.

The gap is now adoption, not capability, and it is the review’s one materially unfinished recommendation:

CoverageHandler modules calling enforce()
Total155
Assert the B2 input contract14 (9%)
Exercise the real policy via B311 (7%)

Adoption was risk-prioritised, which is the right order — credential/* (the OSSA-2026-015 surface whose test module was empty when this review was written) now carries both B2 and B3, as do user/os_ec2/* and policy/*. But two core delegation surfaces are covered by neither:

  • v3/os_trust/trust/{create,show,list,delete} — trusts are one of the three delegation mechanisms I1–I5 exist to bound.
  • v3/ec2tokens/create — the redemption endpoint of the crown-jewel scenario in §6.1 (OSSA-2026-005 / CVE-2026-33551).

policy_contract.rs’s own doc comment calls itself “the uniform, non-opt-in assertion set.” At 9% adoption with no route sweep and no Gate B1 check requiring a contract test to exist, that is an aspiration rather than a description: a new handler added today is covered by B1 (it must call enforce()) but nothing requires it to prove what it passes. The remaining work is the sweep, not the harness — either enumerate the registered routes and drive each through CapturingPolicy, or (cheaper, and it composes with the existing checkers) extend check_policy_handler_coverage.py with a fifth check: every handler module containing .enforce( must also reference policy_contract in its test module, with an explicit reviewed allowlist for the exceptions.

The payoff is not hypothetical. security-model.md §9 now records a defect class found in exactly this way — several policies test input.credentials.system_scope, a key nothing ever emits (Credentials serializes it as system), making those rules unreachable. It fails closed, so it is a functional bug rather than a vulnerability, but it is precisely a handler↔policy contract mismatch that opa test cannot see and that the B3 real-opa run tests do. Broader B2/B3 adoption is how the rest of that class gets found.

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.

Implemented 2026-09-14 (§0a item 1): the route-sweep alternative this section proposed (check_policy_handler_coverage.py’s check 5) landed instead of an actual route sweep — cheaper, and it composes with the existing unconditional CI job rather than adding a new one. It requires any file calling .enforce( to reference policy_contract in its own or a sibling test module, carries a shrink-only ALLOWLIST_NO_POLICY_CONTRACT for the 136 pre-existing handlers not yet backfilled, and cannot regress: a new handler with .enforce( and no policy_contract reference fails CI, full stop. os_trust/trust/{create,show,list,delete} and ec2tokens/create — the two named uncovered core delegation surfaces — were backfilled with both B2 and B3 coverage as part of this change (adoption 14→19 of 155). The remaining 136 are the honest backlog this table always implied; the gate now guarantees that backlog only shrinks.

V4 — OPA policy-bundle supply chain (P2)

Attack. The authorization logic is shipped as an OCI artifact (opa build … --bundle → oras push ghcr.io/…/opa-bundle:latest, policy-container.yml). Whatever the running Keystone loads is the policy. If the bundle can be tampered with in the registry, or a stale/rolled-back latest is pulled, every authz decision is attacker-defined — without touching Keystone’s code or the policy/ tree.

Current state (2026-07-09). The bundle is pushed unsigned; there is no evidence of signature generation or of verification at load time. latest is mutable.

Re-evaluated 2026-09-14: half-closed. The publish side landed — policy-container.yml resolves the pushed digest, signs it with cosign keyless (reusing the job’s existing id-token: write), and then verifies its own signature before the workflow reports success, so a broken signing setup fails CI rather than silently shipping an unverifiable bundle. The design-side ask landed too: security-model.md §3 now names the bundle as a trust boundary.

The consumption side is still open, and it is the half that actually defends a running deployment: nothing verifies the signature at load. tools/opa_config.yaml still pulls …/opa-bundle:latest — a mutable tag — and its own comment concedes the point (“OPA’s bundle service has no native cosign/Sigstore verification hook, so that check cannot happen [here]”). A signature nobody checks stops a careless publisher, not an attacker with registry write access. The remaining work is a verify-then-serve step in the deployment path (sidecar or init container that runs cosign verify against a pinned digest and only then hands the bundle to OPA), plus pinning by digest in opa_config.yaml and the k8s manifests instead of :latest.

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-model.md (today it is implicit) — the running policy is as security-critical as the binary, and should have the same provenance bar.

Implemented 2026-09-14 (§0a item 3), partial: tools/verify_and_pin_opa_bundle.sh resolves a tag’s digest, runs the same cosign verify the publish job already runs on itself, and on success pins resource in tools/opa_config.yaml (or any given config) to the verified digest — or, with --config /dev/null, just emits the verified image@digest for a deployment’s own init-container check. It is deliberately a release-time tool, not an always-on load hook: the digest changes on every policy merge, and OPA itself still has no native Sigstore verification, so “pin by digest” is a promotion action a human or pipeline chooses to take, not something that runs unattended on every opa run. A deployment that wants continuous rollout from :latest still needs its own init-container/admission-hook cosign verify before serving traffic — this script is exactly that check, packaged for reuse.

V5 — Application-credential access_rules unenforced at request time (P1; closed 2026-09-14, ADR 0037)

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-model.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 (2026-07-09). Advisory only, by the project’s own admission.

Re-evaluated 2026-09-14: still open — and now the review’s single most important outstanding item. The interim control landed exactly as proposed: creation warns unconditionally on a non-empty access_rules list, and application_credential.reject_unenforced_access_rules (crates/config/src/application_credentials.rs, default false) makes it a hard rejection (ApplicationCredentialProviderError, crates/core-types/src/application_credential/error.rs). security-model.md §9 documents it.

But no request-time enforcement exists. Every AccessRule reference in the tree is still CRUD plumbing — provider_api.rs, backend.rs, service.rs, the SQL driver, the audit projection — and there is no middleware matching an incoming (service, method, path) against the stored rules before dispatch. With the flag defaulting to false (correctly, for compatibility), the default deployment still accepts a restriction it will not honour. The fail-loud flag buys honesty for operators who opt in; it does not close the gap. The ADR and middleware §5 calls for are unwritten.

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

Implemented 2026-09-14 (§0a item 2): closed. ADR 0037 + enforce_access_rules() (crates/core/src/api/auth.rs) landed as the requested “request-matching middleware,” though not as a separate tower/axum layer — it runs inside Auth::from_request_parts, the extractor every handler already calls, so it costs no extra authentication round trip and cannot be bypassed by a handler that forgets to call something. Scope is deliberately narrower than “any service”: Keystone can only self-enforce rules naming its own service (identity), since it has no visibility into requests made to other services — that half remains keystonemiddleware’s job elsewhere, as designed, not a residual gap. access_rules_permit() (crates/core-types/src/application_credential/access_rule.rs) is the pure matcher (method/service exact match, path via the documented {tag}/*/** wildcard syntax), unit-tested directly; the enforcement point is tested through the actual Auth::from_request_parts mock-injection path (matching/non-matching rule, empty/absent rules, wrong-service rule, non-app-cred auth unaffected). Runs before OPA policy evaluation, not after — a call outside the rules is rejected without paying for a policy round trip. Positive/negative/wrong-service coverage exists; the rescope test V5 asked for is implicit rather than explicit (enforce_access_rules reads the rules from AuthenticationContext::ApplicationCredential, which reauthentication carries unchanged per security-model.md §5’s table — there is no separate rescope code path to diverge from).

V6 — Denial of service on unrate-limited cryptographic endpoints (P2; the three named endpoints closed 2026-09-14)

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 (2026-07-09). 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).

Re-evaluated 2026-09-14: partially closed; the original three crypto endpoints are still open. check_ip() coverage has grown well beyond phase 1 — it now gates v3/auth/token/create, the full OAuth2 surface (authorize, token, device, device_authorization, jwks, jwks_revocation, well_known) and v4/spiffe. That is the V8a work plus more.

The endpoints this vector actually named remain unlimited, verified by direct read — none of them contains a check_ip/rate-limit call:

EndpointExpensive work reached unauthenticated
v3/ec2tokens/createHMAC signature verification
v3/auth/token/show (token validation)Fernet/JWS decrypt + verify
federation authenticate endpointsOIDC/JWT signature verification

ec2tokens is the notable one: it is both an unrate-limited crypto endpoint (V6) and the redemption path of the V1 crown-jewel scenario, and it currently carries neither a rate limit nor a B2/B3 policy-contract test (V3a).

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.

Implemented 2026-09-14 (§0a item 4): the three named endpoints are closed. check_ip() now gates v3/ec2tokens/create (before verify_signature), v3/auth/token/show (before the Fernet/JWS decrypt), and the federation jwt::login / oidc::callback handlers (before JWKS fetch / JWT verification) — same posture as /v3/auth/tokens: checked first, before any expensive work. ec2tokens/create also closed its V3a gap in the same pass (§V3a), so it no longer carries neither control. The design-level follow-up ADR extending check_ip coverage more broadly, and the load/abuse and X-Forwarded-For-spoofing tests, remain open — this closes the three concrete endpoints the vector named, not the general design/testing asks.

V7 — Dynamic auth plugins: pre-auth attack surface (P1 — now live, no longer hypothetical)

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 (2026-07-09). Design-stage; AuthenticationContext::WasmPlugin and plugin_claims projection already exist in the tree, so partial plumbing has landed ahead of the full mechanism.

Re-evaluated 2026-09-14: shipped. This is no longer a design-stage vector — auth-plugin-core, auth-plugin-runtime, and auth-plugin-identity-driver-raft are real crates, crates/core/src/auth_plugin{,_auth,_http,_identity}.rs carry the mechanism, and auth_plugin_startup.rs / auth_plugin_http_client.rs wire it into the server. The named controls exist: allowed_hosts with connect-time IP validation (auth_plugin_http.rs, host_functions.rs), a reserved-header denylist enforced at config load (crates/config/src/auth_plugins.rs), and three of the five fuzz targets cover the host↔guest JSON boundary (fuzz_auth_contract_response, fuzz_route_contract_response, fuzz_mapping_contract_response).

Two things follow. First, ADR 0025 still reads Status: Proposed while the feature is in main — as do ADR 0022 (rate limiting) and ADR 0026 (OAuth2 provider), both equally shipped. That drift matters for a security review that uses ADR status to judge what is attack surface today: a reader triaging by ADR status would under-weight three live, internet-facing surfaces. Update the statuses. Second, this vector’s priority is now unconditional — a pre-authentication WASM execution path reachable by any remote party deserves the dedicated pentest suite §6.6 describes, on the current code rather than “when those features ship.”

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 — now live, no longer hypothetical)

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 (2026-07-09). 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.

Re-evaluated 2026-09-14: shipped, and the implementation was audited. The provider is live (crates/keystone/src/api/v4/oauth2/: authorize, token, device, device_authorization, jwks, jwks_revocation, well_known), and V8a below is the record of that audit — the drift risk this vector warned about was checked, found in one place (device-flow rate limiting), and fixed. ADR 0026 still reads Status: Proposed; see V7 on ADR status drift.

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-107 — generate_dummy_hash() performs a real Argon2id hash (not a cheap early-return) with the same configured cost params as verify_secret()’s verify; hash and verify are comparable-cost Argon2id operations but not byte-identical code paths, and the DB lookup itself is faster for an unknown client_id than a known one. This residual gap is explicitly called out in-code (token.rs:387-395) and bounded by the pre-hash, raw-client_id-keyed rate limiter (token.rs:367-373, checked before the DB lookup)
client_credentials grant: existence+grant-type 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, /token (device_code grant): 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. The /token device_code arm (token/device_code.rs) applies check_ip and the per-client_id oauth2_token_rate_limiter before any storage lookup, plus a per-device_code quiet period ([oauth2] device_code_invalid_quiet_period_seconds, default 300s, 429 + Retry-After) after an invalid_grant/expired_token answer; slow_down polls never write last_polled_at. /device additionally applies an exponential backoff on failed user_code lookups, keyed on (user_code, client IP) and on the client IP alone (ADR 0026 §7.C, #1433), answered 429 + Retry-After before the storage lookup; node-local and in-memory. /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.

V8b — Federated sign-in on the OP login page (open redirect, state fixation, callback replay)

The OP login page can send the browser to an upstream IdP (oauth2/federated.rs). Threats and controls:

ThreatControl
Open redirect through idp_hint / idp_idBoth are only compared with the ids of enabled IdPs registered for the domain; an unknown idp_hint is ignored, an unknown idp_id is 400. The redirect URL is built from that IdP’s own discovery document.
State fixation / login CSRF (attacker feeds the victim a callback for the attacker’s account)The callback is honoured only when the session cookie’s pre-auth session is waiting on exactly that state and IdP (pending_upstream, compared in constant time, and compared again when the session is completed). The attacker’s state does not match the victim’s session, so nothing is signed in. The start of the flow is a POST carrying the session CSRF token.
Callback replayThe federation AuthState is deleted and pending_upstream is cleared on first use; a second callback finds neither. The upstream authorization code is single-use at the IdP.
Mix-up (answer from another IdP)The stored AuthState.idp_id must equal the pending IdP; the ID token iss/aud/nonce checks of the federation path apply.
Weak upstream claims a strong amrOnly a list of at most 8 short strings is copied; otherwise ["federated"]. Operators who do not trust an IdP’s amr should not register it for OP login.
Rate limitingBoth endpoints run the per-IP limiter before any lookup.

The device-code page (oauth2/device_federated.rs) applies the same controls with the device grant in place of the pre-auth session: the pending redirect is stored on the grant, compared in constant time, consumed atomically, and a grant of another domain is refused.

Residual: expiring group membership is not re-checked at refresh rotation.

A remembered consent lets /authorize and the device page skip the consent page (oauth2/consent.rs). Threats and controls:

ThreatControl
Scope escalation under an old consentThe page is skipped only when every requested scope is in the stored scope set; one new scope asks again, and an approval widens the stored set.
A client that was never approved gets a silent grantThe record is keyed on (domain, user, client); nothing is stored unless the user ticks “remember” on a form carrying the session CSRF token. Denying never stores.
Remembered consent defeats prompt=consentprompt=consent is kept on the pre-auth session and disables the skip on every path (password, second factor, federated, SSO). prompt=none returns consent_required unless a stored consent covers the request.
pre_authorized clientsThey skip consent by operator decision; nothing is stored for them and the checkbox is not offered.
Consent outliving its subjectRecords are deleted with the user, the domain and the client; withdrawing one also revokes that user’s refresh families of the client. Disabling a user keeps the record (the user cannot sign in anyway).
Management through a delegated credentialidentity/oauth2/consent/{list,delete} allow the user themselves only when credentials.is_delegated is false (decided on the authentication chain, not the token scope), or an administrator.
Lookup failure turns into a silent grantAny storage error counts as “not covered”: the user is asked.

Residual: the authorization target of openstack:api is stored as None because the browser flows do not allow openstack:api yet; when they do, the skip check must compare the target as well.

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 (2026-07-09). I7 strips the blob in credential_policy_input(); secrets are now wrapped with the secrecy crate (recent commit b35ca42). Good.

Re-evaluated 2026-09-14: closed, on both surfaces. Gate I landed twice over: policy_contract::assert_no_secrets recursively rejects 11 secret-shaped field names anywhere in target/existing (and is asserted against Credentials itself in crates/core/src/policy.rs), and tools/check_event_payload_no_secret_fields.py runs unconditionally in ci.yml so the ADR 0023 audit projection cannot grow a secret-shaped field. The one residual is scope of application: assert_no_secrets only protects the handlers that opted into a B2 contract test (see V3a), so widening B2 adoption widens this guarantee too.

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; property tests landed 2026-09-14)

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 (2026-07-09). 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.

Re-evaluated 2026-09-14: open. The design is unchanged and still sound, but the property tests this vector asked for were not written. The only proptest suite in the workspace is scope_pinning_property (crates/core/src/policy.rs) — the delegation monotonicity and revocation properties named in §5.2 remain the two open items on that list. “Authority removed at T is unusable after T” is still covered only by example-based tests.

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.

Implemented 2026-09-14 (§0a item 6). crates/core/src/auth/tests.rs’s delegation_monotonicity_property module adds both properties this section asked for, driven through the real calculate_effective_roles() (not a reimplementation) over a synthetic 5-role membership space:

  • app_cred_roles_never_exceed_delegation / trust_roles_never_exceed_delegation — the delegation-monotonicity property, for any live-assignment state. Trust and app-cred turned out to have genuinely different bounding semantics worth pinning down: app-cred resolution is an intersection (frozen roles ∩ current assignments), while trust resolution is all-or-nothing (resolve_trust_roles denies entirely unless the trustor currently holds every delegated role) — the property tests assert each function’s actual contract rather than a single shared shape.
  • app_cred_revoked_role_unusable_after_removal / trust_revoked_role_unusable_after_removal — the revocation property: resolve once against a wider “before” assignment set, then again against an “after” set with roles removed, asserting the second resolution never contains a removed role. This exercises the actual mechanism (“no caching across calls, always a live lookup”) rather than asserting the property in the abstract.

Trust deletion and app-cred expiry mid-token-lifetime (as opposed to role removal) remain covered only by example-based tests; the pentest asks are still open.

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 boundary — landed 2026-07-30 (fuzz/, .github/workflows/fuzz.yml): Fernet decode, the WASM boundary, and EC2 signature verification (fuzz_ec2_signature, arbitrary-derived); Credentials::try_from’s scope-pinning invariant is covered by a proptest suite in crates/core/src/policy.rs instead (structured typed-value invariant, not a byte parser)V1/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-model.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.

Status (2026-09-14, updated same day per §0a). See §0 for the verified per-gate state. A, B1, C, D, E, F, G, I and J are landed and running in CI. B2 is now also a non-optional CI check (check_policy_handler_coverage.py check 5) with a shrink-only backlog allowlist; B3 remains an opt-in harness. Applied by 19 and 17 of 155 enforce()-calling handler modules respectively — the mechanism no longer regresses, but most of the backlog is still unbackfilled. H is signed at publish and verified; a verify-and-pin tool (tools/verify_and_pin_opa_bundle.sh) now exists for the load side, but nothing runs it automatically on a live deployment.

Design-time gates (not CI)

  • Promote the security-model.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-model.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-model.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). Implemented 2026-07-30: crates/core/src/policy.rs’s scope_pinning_property proptest module, covering ApplicationCredential and Trust delegation across the project-id string space.
    • Delegation monotonicity and revocation (authority removed at T is unusable after T, V10) remain open — 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. fuzz/ (cargo-fuzz, .github/workflows/fuzz.yml, weekly + manual) covers the Fernet MessagePack decoder, the three WASM host↔guest JSON boundaries (full_auth/route/mapping), and EC2 signature verification (fuzz_ec2_signature, structured arbitrary-derived harness over Ec2SignatureRequest rather than a flat byte slice, since it’s a multi-field struct feeding multi-version string-to-sign canonicalization). Credentials::try_from’s scope-pinning invariant (property 2 above) is instead covered by a proptest suite (crates/core/src/policy.rs, scope_pinning_property module) sweeping the delegation-project/token-scope-project string space for both ApplicationCredential and Trust delegation — a typed-value invariant, not a byte parser, so proptest fit better than cargo-fuzz here.

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, enforcement landed 2026-09-14). Mint a restricted credential and confirm calls outside its rules are now denied (ADR 0037); confirm a rule naming a different service correctly does not authorize a call against Keystone’s own API.
  6. WASM plugins (V7) and OAuth2 provider (V8) — full dedicated suites; treat both as internet-facing pre-auth surfaces. Both features have since shipped (re-evaluated 2026-09-14), so these are engagements to schedule now, not contingencies. V8 has had a code-read audit (V8a); V7 has not.
  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, and /token device_code polling with many unknown/invalid codes — all should now hit 429 after burst exhaustion; re-verify this holds after any future change to device.rs/device_authorization.rs/token/device_code.rs.

7. Prioritized recommendation

Original list (2026-07-09), with outcomes

  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. — A and B1 done; B2’s harness done, its sweep 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. — fail-loud flag shipped (opt-in, default off); enforcement not.
  3. Gate D matrix + Gate G mutation testing (V1/V2) — convert the “remembered checklist” defense of the delegation boundary into a structural one. — done.
  4. Sign the policy bundle (V4) — the running policy deserves the same provenance bar as the binary. — signed at publish; not verified at load.

Re-prioritized (2026-09-14)

Ordered by what was outstanding as of the re-evaluation, highest value first. See §0a for the same-day implementation pass that closed five of six.

  1. Make Gate B2 non-optional (V3a). The mechanism is built and proven; 91% of enforce()-calling handlers just don’t use it. The cheapest closure is a fifth check in tools/check_policy_handler_coverage.py — a module with .enforce( must reference policy_contract in its tests, with a reviewed allowlist — which reuses a script already running unconditionally in CI. Backfill os_trust/trust/* and ec2tokens/create first: both are core delegation surfaces with neither B2 nor B3 coverage today. — done (§0a); the check is now unconditional in CI and the two named surfaces are backfilled. 136 of 155 remain on the allowlist — the check stops new erosion, it doesn’t retroactively backfill everything else.
  2. Ship access_rules enforcement (V5). Unchanged from the original list and now the oldest open item. The fail-loud flag made the gap honest for operators who set it; the default deployment still accepts a restriction it will not honour. This needs the ADR and the request-matching middleware. — done (§0a, ADR 0037).
  3. Verify the policy bundle at load, and pin by digest (V4). Half a supply chain control is a signature nobody checks. Add verify-then-serve to the deployment path and replace :latest in tools/opa_config.yaml and the k8s manifests with a pinned digest. — partial (§0a): the verify+pin tool exists; a deployment must still choose to run it (or the equivalent init-container check) since OPA itself has no load hook to automate this from.
  4. Rate-limit the three remaining pre-auth crypto endpoints (V6): ec2tokens/create, token validation, and the federation authenticate paths. The mechanism (check_ip) is already in place on eight other endpoints, so this is application, not design. — done (§0a).
  5. Correct ADR statuses for shipped features (V7/V8). ADRs 0022, 0025 and 0026 read Proposed while the features are in main. A security reader triaging by ADR status will under-weight three live surfaces — including a pre-authentication WASM execution path. — done (§0a).
  6. Write the two open property tests (V10/§5.2): delegation monotonicity and revocation-after-T. scope_pinning_property shows the pattern works here. — done (§0a).

The rest (plugin/OAuth2 pentest suites) should land alongside the features they protect — which, for V7 and V8, means now rather than later, since both shipped.

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 existing (fetch the record and pass it into the policy engine); target is null.

  3. Update operation MUST receive current and new state of the resource (first the current resource is fetched and passed as existing together with the new state as target to the policy engine).

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

  5. Delete operation MUST pass the current resource state of the resource into the policy engine as existing; target is null.

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": null, "existing": {"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 Value::Null as target, Option::from(stored_object).map(serde_json::to_value) as existing
  • Delete: Pass Value::Null as target, Option::from(stored_object).map(serde_json::to_value) 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 Value::Null for Show and Delete, since there is no new/desired state in those operations — the current record is passed as existing instead, never as target.

  • 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": null, "existing": { "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.

Role assignment revocation

Revoking a role assignment (revoke_grant) cascades into token revocation:

  • A user assignment (UserDomain, UserProject, UserSystem) inserts a revocation event with user_id, role_id and the project or domain (system assignments have neither), so only tokens of that user carrying that role are invalidated.
  • A group assignment (GroupDomain, GroupProject, GroupSystem) does not insert a revocation event. An event for a group cannot be bound to the group members (user_id stays empty), so it would invalidate every token carrying the role on that scope, including the tokens of users who hold the role through a direct assignment or another group. This is Python Keystone bug #1662514. Instead the effective roles of a token are recalculated on every validation (user, then the groups of the user, then the assignments of all of them, for domain, project and system scope), so group members lose the revoked role immediately without an event.

Python Keystone behaves the same way: it never persists such an event for a group. Its [token] revoke_by_id option only gates a non-destructive token cache invalidation and has no Rust counterpart. No option re-enabling the broad group event exists, because enabling it would reintroduce the bug.

The group path therefore depends on the validation-time recalculation of effective roles. This holds for every token whose roles are derived from assignments: unrestricted tokens, trusts and application credentials (bounded by, and filtered against, the current effective roles of the trustor or user) on domain, project and system scope.

Tokens whose roles are not derived from assignments are unaffected by assignment revocation, for users as well as groups. A token restriction (ADR 15) carries its own role set so that a service account needs no direct role assignment. The restriction is read again on every validation, so deleting or changing it, not revoking an assignment, ends such a token (the user_id-bound event of a user revocation also hits it as a side effect). Any change that lets a token carry roles resolved at issuance time without re-resolving them on validation must revisit this decision.

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

Superseded by ADR 0016-v2

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

Accepted

ADR-0016 (v1) is superseded by this version. Where the implementation differs from the text below, doc/src/admin/storage/distributed.md describes the behaviour of the code: in particular, F7 (NodeId uniqueness) fails closed only when a live membership conflict is detected; when no peer can be reached at startup the node warns and proceeds so a simultaneous full-cluster restart can recover.

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: Dependencies are locked to exact versions in Cargo.lock (openraft is additionally pinned with = in the workspace Cargo.toml). New releases of the security-relevant crates must pass a manual security review before upgrading. A contingency plan (fork, vendor, or replace) is maintained for each core dependency. The controls enforced in CI are cargo-deny (advisories, licenses and sources) and cargo-vet (supply-chain/, job cargo-vet in .github/workflows/audit.yml); both fail the build on new violations. cargo-vet imports the audit sets of the Bytecode Alliance, Google, Mozilla, Embark and ISRG and records the remaining crates as exemptions (a baseline, not an audit). Crates that handle key material have no first-party audit yet; they are exempted and tracked for audit: openraft, fjall, aes-gcm, hkdf, cryptoki, tss-esapi. Any new or updated dependency needs either an imported/first-party audit or an exemption justified in the pull request. cargo-deny cannot express a “stores key material without ZeroizeOnDrop” rule, so that property is verified by manual review. Reviewers apply the following checklist to every new or updated Cargo.toml entry that: (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. This covers openraft, fjall, the AES-GCM provider, the HKDF implementation, the mlock wrapper and, per the §2.5 PKCS#11/TPM addendum, the HSM/TPM client libraries cryptoki (storage-crypto-pkcs11) and tss-esapi (storage-crypto-tpm), which hold the unwrapped DEK transiently during wrap_dek/unwrap_dek. Versions are whatever Cargo.lock resolves; bumps are reviewed, not frozen to the versions named in earlier revisions of this ADR.

Pre-release consensus dependency (accepted risk): openraft is pinned to the 0.10.0-alpha line, whose API is still changing between alphas (for example SnapshotMeta.snapshot_id was removed). This is accepted explicitly: the pin is exact (=), upgrades between alphas are manual and reviewed, and the project moves to the stable 0.10 release when it ships. fjall is covered by the same pinning and review policy.


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(salt, DEK, info="keystone-raft-log-v1", L=32)
         ├── State DEK (SD)        ← HKDF(salt, DEK, info="keystone-fjall-state-v1", L=32)
         └── Backup DEK (BDEK)     ← HKDF(salt, 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 full HKDF (Extract-then-Expand with the fixed salt keystone-dek-hierarchy-v1 and a non-empty info). The DEK is already uniformly random, so Extract adds no entropy; it is run because Extract-then-Expand is the flow the FIPS module documents as an approved service. This ensures 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. The counter is scoped to the DEK epoch it encrypts under, so a DEK rotation starts a fresh counter space. On startup, the persisted counter is validated against a separately-stored high-water mark (kept in a dedicated Fjall metadata key _meta:nonce:<node_id>:hwm:<epoch>); if the recovered counter is strictly less than 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, which is also when the leader rotates the DEK (§6).

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

  • DEK wrapping (KEK): EnvKek derives the 12-byte wrap nonce as the truncated HMAC-SHA256(HKDF-Expand(KEK, "keystone-dek-wrap-nonce-v1"), DEK), so no RNG is involved and the nonce is unique per distinct DEK.

  • State nonce and replicated determinism: the HKDF-derived state nonce is deliberately a pure function of (StateDek, pk, version). Every node applies the same Raft entry independently and, as with the post-rotation re-encryption sweep, must arrive at byte-identical ciphertext without a further consensus round. A node-local fixed ++ counter nonce would break that property, and a Raft-log-index based one would not cover the local re-encryption sweep. Replacing it needs its own design (and a stored-format change), so it is intentionally left as is.

  • FIPS builds: the fips cargo feature of storage-crypto (forwarded by storage) routes AES-256-GCM, HKDF, HMAC and random generation through the FIPS 140-3 validated AWS-LC module and assert_fips() fails start-up unless that module is active. Building the feature requires cmake, a C compiler, clang and Go; use --no-default-features --features fips to keep ring out of this crate. The default backend is ring; both providers go through one ring-API implementation (backend/ringlike.rs), and backend.rs documents how to add another. The service’s fips feature also selects aws-lc-rs with rustls’ fips for TLS (openstack-keystone-tls-provider), except SPIFFE mTLS, which spiffe-rustls pins to ring.

    The module only reports AES-GCM encryption as an approved service when it generates the IV itself, so in a fips build the deterministic nonce schemes above are replaced, for every encrypt path (log, state, backup and the DEK wrap), by a random 96-bit IV from the module’s DRBG (SP 800-38D §8.2.2). The IV is still stored in front of the ciphertext, so the on-disk layout is unchanged.

    Random IVs are only safe for fewer than 2^32 encryptions under one key (SP 800-38D §8.3), and every member that applies the writes (voters and learners alike) encrypts under the same DEK, so a shared key would run out after 2^32 / members writes: days, with a fleet of learners. Log entries and state records are therefore encrypted under a key derived per message: HKDF(salt = "keystone-gcm-message-key-v1", ikm = Log/StateDek, info = context) (full Extract-then-Expand with a non-empty salt and info, the flow the validated module documents as approved), with context = term ++ index for a log entry and domain ++ pk ++ version for a state record. A key only ever encrypts one message (once per member applying it), so the limit is never approached, no usage counter or cluster-size accounting is needed, and the DEK rotation schedule stays independent of cluster size. Backup and DEK-wrap encryptions are rare and use their key directly.

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.

With pkcs11_token_generated_iv = true the wrap uses the PKCS#11 3.0 message-based API (C_MessageEncryptInit/C_EncryptMessage with CKG_GENERATE_RANDOM) so the token itself generates the IV from its approved DRBG, as FIPS 140-3 modules require. The wire format is identical and unwrap is unchanged, so blobs from either mode stay readable. The token must support the 3.0 message API (SoftHSM2 does not) and this mode has not been exercised against real hardware yet.

  • 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).

2.5.3 Cluster-Wide DEK Distribution

All cluster nodes MUST share identical KEK material. §2.1-§2.5 describe how a single node wraps/unwraps the DEK; nothing above guarantees that two nodes’ KEKs agree. Without cluster-wide coordination, bootstrap_dek generates an independent random DEK on every node’s first boot, which is silently wrong: log replication tolerates it (each node re-encrypts Raft log plaintext under its own DEK on apply), but a joining or lagging node that must catch up via InstallSnapshot receives ciphertext produced under the leader’s State DEK — if its own same-version epoch has different key bytes, every value fails GCM tag verification and the partition is quarantined (§10 invariant 5) instead of catching up. The same DEK-key-bytes mismatch makes restoring a backup onto a freshly bootstrapped cluster fail outright (§7).

Distribution protocol. A node joining the cluster for the first time calls FetchDek (§8) against the leader before calling AddLearner:

join_cluster(leader_addr):
  (dek_version, wrapped_dek, retired[]) = leader.FetchDek()
  install_fetched_dek(dek_version, wrapped_dek)          // local only, bypasses Raft
  for (version, wrapped) in retired:
    install_fetched_retired_dek(version, wrapped)        // local only, bypasses Raft
  leader.AddLearner(self)

FetchDek returns not just the current epoch but every retired-but-still- readable one too: a DEK rotation’s background re-encryption sweep (§6 step 5) is best-effort and asynchronous, so records under a retired epoch can still be live on the leader when a node joins. Without also fetching those epochs, a joining node could adopt the current DEK correctly yet still fail to decrypt any record a rotation hasn’t fully migrated off its predecessor — the same GCM-verification failure this section otherwise fixes, just for the boundary case around a rotation rather than the steady-state case.

install_fetched_dek/install_fetched_retired_dek unwrap their argument with the joining node’s own KEK and, only on success, write the corresponding _meta:dek:current / _meta:dek:retired:<version> record and update the in-memory epoch handle(s) — before the node is visible to the leader as a learner, so the leader cannot yet be replicating to it and there is no window where the wrong DEK could be used to (mis)encrypt real data. Ordering this before AddLearner is what makes the swap safe: the node’s own data keyspace is still empty at this point, so the discarded bootstrap-generated placeholder DEK never protected any ciphertext. A node that is already an initialized cluster member (a restart, not a first join) never calls join_cluster again and so never re-runs this swap — see ensure_raft_initialized’s is_initialized() guard.

FetchDek is authenticated the same way as AddLearner (peer trust-domain check, §4) — it is inter-node bootstrap traffic, not an operator action, and returns the wrapped bytes exactly as stored under the leader’s _meta:dek:current / _meta:dek:retired:*, unmodified — no unwrap/rewrap round-trip on the leader side, so the leader’s own DEK plaintext never leaves TPM/PKCS#11-backed protection to construct the response.

Consequence for TPM/PKCS#11: because FetchDek’s caller unwraps the response with its own KEK, this protocol only succeeds when every node’s KEK is symmetric and byte-identical — true for env (operators configure the same key material on every node) and for PKCS#11/TPM only if the operator provisions the same underlying AES key into every node’s token/TPM (e.g. importing one externally-generated AES-256 key rather than using each token’s own CKM_AES_KEY_GEN/TPM key-generation, or using a TPM’s duplication/import path where the platform supports it). A per-node, independently generated PKCS#11 or TPM key — which §2.5.1/§2.5.2’s “MAY auto-generate on first startup” describes — is cluster-incompatible: a joining node’s install_fetched_dek fails closed with a KEK-material-mismatch error and the join aborts rather than silently leaving the node with its own placeholder DEK. Provisioning identical TPM/PKCS#11 key material across nodes is an out-of-band operator step this ADR does not yet automate (no leader-side rewrap-for-joining-node’s-public-key mechanism exists); it is tracked as follow-up work under GitHub issue #1298.


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.

Implementation status: local_reads_mode defaults to disabled, in which case every read, whatever its tier, goes through the ensure_linearizable (ReadIndex) path. With local_for_public a consumer can ask for a stale local read per call (ReadConsistency::Local through get_by_key_with_consistency / prefix_with_consistency). The request is honored only for Tier 0/1 records; a Tier 2/3 record, or a prefix scan that matches one, falls back to ReadIndex. prefix_index is always linearizable. If the ReadIndex/forwarding path cannot confirm linearizability, the read fails (Unavailable) rather than falling back to a non-linearizable local read for every tier that is not served locally — see invariant 4 below.

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, per-epoch HMAC-SHA256 key derived from the active DEK via HKDF(salt, DEK, info="keystone-audit-dek-v1" ++ dek_version_u32_be ++ node_id_u64_be, L=32). The node_id makes each node’s key distinct; a compromised node cannot forge records attributed to other nodes. The key rotates on every DEK epoch swap (applied on every node from the Raft state machine), binding the HMAC key lifetime to the DEK epoch. The signature covers the exact serialised bytes of the record (timestamp, event type, actor, node_id, dek_version, details). Each spooled line carries key_version, the epoch whose key signed it, so a verifier selects the key by that field.

Implementation note (GitHub #1300): KEK-based derivation was rejected for the current implementation because it needs a KMS-side derive operation for PKCS#11/TPM providers and would not rotate with the DEK epoch. DEK-based derivation is the standard; export of epoch keys to the verifier is an operator step.

Transport: Records are appended to a local fsynced JSONL spool; an external shipper forwards them to the SIEM, which enforces append-only semantics downstream. Built-in TCP/TLS delivery and automatic epoch-key transmission to the SIEM are not implemented.

Availability: The spool is size-bounded (audit_max_spool_bytes). At 90% of the bound the node emits a CRITICAL alert; at 100% the oldest sealed segment is dropped. The spool is not encrypted at rest. Spool pressure or failure does NOT affect the Raft storage engine’s availability — writes to the identity data store proceed normally.

Ordering: An audit record for an operation is emitted after the operation has been attempted; failures emit <EVENT>_FAILED. Apply-side outcomes (for example DEK_INSTALLED) are emitted on every node.


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. (Implementation status as of #1301: the watchdog only logs, never shuts down, and only runs for a certificate given inline as tls_cert_content.)
  • 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 [distributed_storage] dek_rotation_days, default 90 days) or by volume (when the leader’s Log DEK nonce counter reaches 90% of 2^31).

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.

Implementation status (as of #1301):

  • Implemented: the time and volume triggers (the Raft leader checks every 5 minutes and proposes InstallDek, skipping while an emergency rotation awaits confirmation); rejection of an InstallDek or confirmed emergency rotation whose version is not above the current one (STALE_DEK_VERSION), so concurrent rotations cannot replace each other’s DEK; the per-node re-encryption checkpoint _meta:dek:rotation_progress:<node_id>:<version>, resumed after a restart; and a re-encryption sweep every 5 minutes over every retired epoch not yet fully migrated, which also retries skipped records.
  • Not implemented: _meta:dek:pending and the rotation_index boundary (steps 2-4). InstallDek swaps the DEK when it is applied, and every log entry carries the dek_version it was encrypted under, which serves as the boundary. The verification report, the 24-hour CRITICAL alert and blocked retirement (steps 5 and 8), and keystone-manage storage recover --record (step 6) do not exist; skipped records are only logged at WARN.

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. (Not implemented: the backup header carries a DEK manifest of KEK-wrapped DEKs, and the restoring cluster needs the same KEK; there is no KMS role.)
  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.

Two restore paths exist, chosen by whether the target node’s Raft is initialized:

  • Initialized cluster (live restore, OpenBao-style). The leader validates the backup, then commits it through the Raft log as chunked RestoreChunk entries and one RestoreApply entry. Every node applies RestoreApply at the same index: it replaces all replicated state and DEKs with the backup’s and keeps its own Raft bookkeeping (applied log, membership, snapshot history). Membership is therefore unchanged. Raft::install_full_snapshot is deliberately not used here: openraft only accepts it on a follower and panics when given a snapshot a leader cannot own.
  • Uninitialized node (disaster recovery). Follows OpenRaft’s documented restore-from-snapshot procedure: the vote is derived from the snapshot’s last log id, the snapshot (membership included) is installed with install_full_snapshot, and one node is told to elect. The same backup is replayed on every node; nodes are started with auto_bootstrap = false so they do not self-initialize first.

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.

Implementation status (as of #1301): the backup_dek and backup_dek_offline KMS roles are not implemented. Retired DEKs are kept in the cluster wrapped under the KEK and are carried in each backup’s DEK manifest.


8. gRPC Protocol & Code Definitions

Protocol Buffers: the messages below are the design sketch. In crates/storage/proto/raft.proto the log entry is Entry, the proposal payload is pb.api.CommandRequest and there is no RaftResponse message (the response is the in-process ZeroizingResponse).

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): the implementation (crates/storage/src/lib.rs) declares the type configuration as TypeConfig. The application payload is the protobuf pb::api::CommandRequest (the AES-GCM encryption of the log payload happens in the log store, there is no EncryptedBlob wrapper) and the node record is pb::raft::Node:

#![allow(unused)]
fn main() {
openraft::declare_raft_types!(
    pub TypeConfig:
        D = pb::api::CommandRequest,
        R = ZeroizingResponse,
        LeaderId = pb::raft::LeaderId,
        Vote = pb::raft::Vote,
        Entry = pb::raft::Entry,
        Node = pb::raft::Node,  // NodeId = u64, manually configured;
                                // collision detection via (node_id, rpc_addr)
                                // comparison against the membership at
                                // startup and on learner-add
);
}

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. Application-generated random nonces are prohibited. Any new encrypted context must define a deterministic, collision-resistant nonce strategy reviewed by the security team. The only random IV allowed is one generated inside an HSM that mandates it (PKCS#11, §2.5.1), which draws it from its approved DRBG. Deterministic IVs are compliant with NIST SP 800-38D §8.2.1 and therefore with FIPS 140-3; this invariant is stricter than FIPS requires, not in conflict with it.
  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.

ADR 0016-v2 Addendum: Peer Roles for the Raft/Storage gRPC Surface

Date: 2026-10-05

Status

Accepted

Amends: ADR 0016-v2 §4.1 (SPIFFE mode) and the gRPC authorization model.

Context

Issue #1303: any peer holding a valid certificate from the trusted CA or trust domain could call every RPC of the Raft and storage services. A compromised or merely misconfigured workload in the trust domain could then initialise or reconfigure the cluster, rotate the DEK, restore data or propose arbitrary state machine commands. Role information was also taken from loosely matched SVID paths (including a /ns/... fallback) and, in TLS mode, there was no role at all.

Decision

Introduce two peer roles and authorise every RPC against them. The role is derived only from the immutable peer certificate identity (first URI SAN), never from the CN.

Role resolution

  • SPIFFE mode: <spiffe_path_prefix>node is Node; <spiffe_path_prefix><operator_role> is Operator (defaults /keystone/storage/ and storage-operator); the role convention is resolved first, so a path under the prefix with any other role name is rejected even if listed; an exact allowed_peer_svids entry outside the prefix is Node; the /ns/ fallback is removed; any other path is rejected. allowed_peer_svids must be non-empty when dev_mode = false.
  • TLS mode: the role is read from the URI SAN using tls_role_san_prefix. Without it the cluster runs permissively only when dev_mode = true; dev_mode = false without it is a configuration error. There is no default prefix.

RPC matrix

RoleRPCs
NodeRaftService.*; StorageService.{Command, ForwardedGet, ForwardedPrefix, ForwardedPrefixIndex, ReportQuarantine}; ClusterAdmin.{FetchDek, GossipLocalEmergencyCandidate}
Node or OperatorClusterAdmin.{AddLearner, Metrics}
OperatorClusterAdmin.{Init, ChangeMembership} and all other management RPCs

StorageService.Command accepts only data mutations: a Transaction of Set, Remove, RemoveIndex, CreateIfAbsent and SetIndex. Admin and restore commands (RestoreChunk, RestoreApply, RestoreAbort, InstallDek, Quarantine, ClearQuarantine, pending rotation commands) are proposed in-process only and are denied on this RPC.

Follower-to-leader quarantine reporting moves to a dedicated ReportQuarantine RPC, validated against current membership and rate limited to 30 reports per hour per reporter identity.

Consequences

  • A workload that only holds a node identity can no longer initialise or reconfigure the cluster, restore backups, rotate or install DEKs, or clear quarantine.
  • Breaking change: SVIDs under the prefix with an unknown role name (for example <prefix>node-N) are rejected even when listed in allowed_peer_svids; only SVIDs outside the prefix (for example /ns/...) can be allow-listed. Deployments must re-register workloads (the Kubernetes manifests use a fixed .../keystone/storage/node template) before upgrading.
  • All nodes share the node role, so ReportQuarantine cannot bind the reported node_id to the caller’s certificate. A compromised node can report a quarantine for any member’s partition; this is bounded by the membership check, the rate limit (effectively per serving node, as the reporter identity is shared) and audit, and is recoverable with the operator-only ClearQuarantine. Per-node SVID binding is deferred as future work.
  • AddLearner is allowed for any Node-role peer, so a compromised node can enrol an attacker-controlled learner, which then receives replication and can call FetchDek; this is mitigated only by the shared-node-identity trust boundary, and per-node binding or operator-only join is future work.
  • Rate limiters (for example 2 rotations per hour) are per node, in memory, and reset on restart; they are not replicated through Raft.

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 Credentials — auth_type, is_delegated, unrestricted, trust, and delegated_project_id — are read from sc.authentication_context() (the immutable authentication chain), never inferred from the authorization scope. The token scope (project_id) is attacker-influenceable across a rescope, while the delegation object’s own project binding is fixed at delegation-creation time. Sourcing these facts from the chain is what lets a policy pin a delegated caller to the delegation’s own project rather than to whatever project the current token happens to be scoped to (OSSA-2026-015).

delegated_project_id: Option<String> carries this immutable binding:

  • AuthenticationContext::ApplicationCredential → the application credential’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.rs — include!() 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.
    • The first phase must still narrow the query: an unprivileged caller reaching the per-item pass with no usable filter turns one request into a full-collection scan plus an OPA round-trip per row. Matching Python Keystone’s identity:list_credentials, identity/credential/list therefore requires a user_id filter naming the caller unless the caller is admin or holds reader on the system scope. The accepted performance implication above applies only to those privileged listers.

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-compatible, 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 restriction — Authorization::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_claims → MappingAuthRequest → authenticate_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. MappingRuleSet — provider_id field removed

The MappingRuleSet struct does not carry a separate provider_id field. The ingress provider instance is identified through source: IdentitySource, which contains the relevant anchor (idp_id, cluster_id, or trust_domain) as its enum variant payload. The provider_id slug used in keyspace coordinates is derived from the source field at storage time.

D2. DomainResolutionMode — allowed_domains consolidated into enum variants

The allowed_domains whitelist was moved from a separate field on MappingRuleSet into the ClaimsOrMapping and ClaimsOnly enum variants of DomainResolutionMode. This encodes the constraint “must be non-empty for ClaimsOnly/ClaimsOrMapping, must be empty for Fixed” into Rust’s type system, eliminating cross-field runtime validation.

D3. ResolvedGroupBinding replaced with GroupRef

The custom ResolvedGroupBinding struct was replaced with GroupRef (defined in crate::identity::group), mirroring the existing RoleRef pattern. The strategy field from the original ResolvedGroupBinding was dropped — group resolution strategy (CreateOrGet/Get) is encoded in GroupAssignment within the live ruleset, which the engine fetches during verification. The persisted shadow record only needs the group anchor (id + domain_id + name).

D4. MappingRuleSetUpdate — mode variant is immutable

The MappingRuleSetUpdate type carries allowed_domains as a separate Option<Vec<String>> field rather than replacing the entire DomainResolutionMode. The service layer merges the new allowed_domains into the existing variant, preventing an operator from changing Fixed → ClaimsOrMapping (or vice versa) via update. The resolution mode variant itself is immutable after creation.

D5. is_system: bool — defaults to false

The is_system field on IdentityBinding is typed as bool (not Option<bool>) with a serde(default) attribute that resolves missing JSON to false. This removes ambiguity — an omitted field means the operator did not grant system privileges.

D6. GroupStrategy::CreateOrGet — default for GroupAssignment

The strategy field on GroupAssignment defaults to CreateOrGet rather than requiring explicit specification, as it is the more permissive operator-friendly default (fewer failures when groups are not pre-provisioned).

D7. MappingRule — provider_id not present

MappingRule does not carry provider_id. It is nested within MappingRuleSet, which identifies the provider through source: IdentitySource. All rule-level context is inherited from the parent ruleset.

D8. Virtual user lifecycle — deactivation preferred over deletion

The janitor task sets enabled: false instead of deleting records. A separate archive cleanup task permanently removes deactivated records after a configurable retention period (default: 365 days, configurable via [keystone] shadow_registry_archive_retention_days). This preserves forensic evidence (identity bindings, authorization snapshots, activity timestamps) for incident response and compliance auditing. The original spec specified immediate deletion. The provider interface is extended with explicit enable_virtual_user and disable_virtual_user methods. The mapping provider calls the revocation provider upon virtual user deactivation to trigger the token revocation pipeline.

D9. GroupAssignment.group_id — String changed to Option<String>

The group_id field on GroupAssignment is Option<String> rather than String. For Local identity mode, groups are resolved by name at runtime via group_name interpolation, and no pre-assigned group_id is required. Operations like find_or_create_federated_user and sync_user_groups resolve groups by GroupRef.name at authentication time. For Ephemeral identity mode, GroupAssignment.group_id should be specified to prevent group naming collisions.

D10. Legacy Mapping migration utility — mapping_to_ruleset_create

The migration utility (mapping_to_ruleset_create in crates/core/src/mapping/migration.rs) performs the field translation described in §11.1, with the following deviations:

  • bound_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

Accepted. Implemented in crates/core/src/rate_limit.rs, called from handler-level check_ip() checks across v3/auth/token/create, the OAuth2 surface (authorize, token, device, device_authorization, jwks, jwks_revocation, well_known), v4/spiffe, v3/ec2tokens/create, v3/auth/token/show, and the federation JWT/OIDC login endpoints (security review V6, V8a). Status was left at Proposed after the feature shipped; corrected 2026-09-14 (security review re-evaluation) so a reader triaging by ADR status does not under-weight this live surface. Follow-up ADR extending coverage to any remaining unauthenticated crypto endpoints is still open — see the security review V6 for current coverage.

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.
  • Python Keystone Compatibility: Configuration must be parsed from the existing keystone.conf INI file to ensure seamless co-existence with the 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.

Schema v1.1 (2026-07-30): Two fixes applied after real-world audit logs showed successful logins and mutations recording "unknown" identifiers and no client IP at all:

  1. sanitize_audit_id rejected every ID Keystone actually mints. It only accepted canonical hyphenated UUIDs (36 chars); every real ID uses Uuid::new_v4().simple() (32 hex chars, no hyphens). Now accepts both shapes.
  2. Initiator.host — a bare String carrying only the pre-auth signal — is restructured into a CADF-conformant nested Host object (DSP0262 §9.3.3) with id (the former string value) and a new address attribute for the client network address. Per spec, host on a Resource (which Initiator is) is a structured type; address is that type’s designated network-address attribute, not a sibling field on Initiator. This is a breaking wire-format change (host was a string, is now an object), so the payload version field bumps from "1.0" to "1.1". SIEMs parsing host as a string must be updated to read it as an object before ingesting v1.1 events.

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.

Wire format: DSP0262 names

The emitted JSON uses the DSP0262 (DMTF CADF) key names, with no format version change: records are told apart by their shape (eventTime present), not by a version. Fields the standard has no place for are carried in the standard extension points instead of new top-level keys:

Record contentDSP0262 location
event timeeventTime
record kindtypeURI (http://schemas.dmtf.org/cloud/audit/1.0/event) and eventType (activity)
target / initiator / observer typetypeURI (initiator service/security/account/user, observer service/security/keystone)
outcome_reasonreason: {"reasonType": "keystone", "reasonCode": "<variant>"}, omitted when there is none
outcome pre-commit record of a fail-closed provider operationpending
rejected requests such as 429failure, with a client-side reasonCode (TooManyRequests, ClientError)
correlation (request) idtags entry correlation_id:<id>
seq, boot_session_id, hmac_key_version, version, domain, observer nodeattachments entry named integrity (contentType application/json); domain is the initiator’s domain id, unknown when there is none
OAuth2 client_id and grant_type of a token-endpoint eventoptional attachments entry named oauth2 (contentType application/json, content {"client_id", "grant_type"}), present only on those events and covered by the signature; the integrity entry stays first. Consumers must look entries up by name, not by position
actionone shared style: lowercase [a-z0-9_-] words joined by / (e.g. create, oauth2/refresh_reuse_detected, wasm_plugin/<host_function>); applied to every record by CadfEventPayload::new

initiator.project_id and initiator.domain_id remain Keystone extension keys on the initiator (null when absent).

The signature of a DSP0262 record covers the new layout, exactly as written. seq is serialized as a JSON number; RFC 8785 numbers are IEEE-754 doubles, so a verifier that canonicalizes it MUST use a 64-bit integer type (values above 2^53 are not reachable in practice: the counter is per boot session). Cross-language vectors, including the v1.1 host cases (host.id, host.address, both, neither — omitted, never null), live in tests/audit/hmac_vectors.jsonl; tools/audit_vectors.py verify re-checks them with an independent Python JCS/HMAC implementation.

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 }
}

// `host` is a nested CADF `Host` object (DSP0262 §9.3.3), not a bare
// string — `Resource.host` (Initiator is a `Resource`) is spec-defined as a
// structured type with `id`, `address`, `agent`, `platform`. Only `id` and
// `address` are populated; `agent`/`platform` are valid but unused.
#[derive(Serialize, Clone)]
pub struct Host {
    /// 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 Host 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.
    id: Option<String>,
    /// Client network address. Parsed via `std::net::IpAddr` and re-rendered
    /// through `Display`; anything that doesn't parse as an IP is dropped
    /// rather than stored raw. Field is omitted (None) if never set.
    address: Option<String>,
}
impl Host {
    fn from_id(id: String) -> Self { Self { id: Some(id), address: None } }
    pub fn id(&self) -> Option<&str> { self.id.as_deref() }
    pub fn address(&self) -> Option<&str> { self.address.as_deref() }
}

#[derive(Serialize, Clone)]
pub struct Initiator {
    id: String, project_id: Option<String>, domain_id: Option<String>,
    host: Option<Host>,
}
impl Initiator {
    fn new(id: String, project_id: Option<String>, domain_id: Option<String>,
        host: Option<Host>) -> 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() }
    pub fn host(&self) -> Option<&Host> { self.host.as_ref() }
    /// Attach a client IP to `host.address`, sanitized. Preserves any
    /// pre-auth `host.id` signal already set.
    fn with_address(mut self, address: Option<String>) -> Self { /* ... */ self }
}
#[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 }
}

Principal-based initiator - There is no separate “verified token” type. After authentication succeeds, build_initiator_from_principal() derives the Initiator from the authentication result’s PrincipalInfo (immutable authentication chain, never from token scope). A later scope or authorization failure is therefore attributed to the known principal, while a failure before authentication completes yields an all-"unknown" initiator. Internal (non-request) actors use the system convention system:<component> as the initiator id (e.g. system:oauth2-key-hook).

Outcome reasons - reason is drawn from the closed OutcomeReason vocabulary (the sanitized error variant name or a static literal). Free-form error text, plugin-supplied deny messages and route details never enter a signed record; they are logged instead.

Request audit context - A request-scoped middleware (with_audit_request_context) places the resolved client IP and the correlation id in a task-local AuditRequestContext. Perimeter and provider emitters read it to stamp initiator.host.address and correlation_id; a value set explicitly by a handler (e.g. the EC2 access key as host.id) wins. Host functions invoked inside a WASM guest may not see the task-local and then use a fresh correlation id.

ID sanitization - Strips non-ASCII, caps at 64 chars. Accepts either UUID rendering Keystone actually produces: canonical hyphenated, or Uuid::simple() (no hyphens) — the latter is what every resource ID minted across the codebase uses (Uuid::new_v4().simple()), so a strict-canonical-only check (schema v1.0) coerced every real ID to "unknown":

#![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(); }
    // Canonical: len 36, 4 hyphens at canonical positions, 32 hex digits.
    let is_canonical = 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("-");
    // Simple: exactly 32 hex digits, no hyphens (Uuid::simple() format).
    let is_simple = cleaned.len() == 32 && cleaned.chars().all(|c| c.is_ascii_hexdigit());
    if is_canonical || is_simple { 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` (and, nested within it, `host.id`
    // / `host.address`) 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 the authenticated principal (build_initiator_from_principal) to extract the sanitized initiator. For endpoints with pre-auth identity signals (EC2 access key, federation idp_id), include non-PII identifiers as initiator.host.id — these don’t require a validated context and don’t risk PII leakage. The client network address is stamped separately as initiator.host.address once available, resolved via ADR 0022’s resolve_client_ip_from_headers under its own trusted-proxy configuration ([oslo_middleware]) — not public_ingress_peer_addr, which returns the raw, pre-resolution TCP peer (the reverse proxy’s own address whenever Keystone sits behind one) and exists so each trust boundary (rate limiting, auth-plugin dispatch, audit) can apply its own resolution independently, per the crate::net module’s trusted-proxy model. For authenticated requests, the Auth extractor resolves it once and sets it on every ValidatedSecurityContext it builds (via SecurityContext.peer_addr, mirroring how correlation_id is threaded); the login endpoint, which authenticates before any ValidatedSecurityContext exists, resolves and stamps it directly, independently of the raw peer address it separately feeds to rate limiting and auth-plugin dispatch.

  2. Completion (Middleware): with_audit_request_context wraps the request. Authentication layers (Auth, API-key auth) record the authenticated initiator in the request audit context; after the handler returns, the middleware emits one perimeter record with the HTTP status mapped to an outcome (2xx/3xx success, 401/403/5xx failure, other 4xx including 429 failure with a client reason) and a static reason. It applies only to an explicit allowlist of authentication surfaces (/v3|v4/auth/tokens, /v3/ec2tokens, /v4/auth/passkey, /v4/vendordata, /SCIM/v2, POST /v4/k8s_auth/{id}/auth), so ordinary validated-token traffic does not swamp the perimeter channel; and only when the handler did not already emit its own perimeter record, so each request leaves exactly one. An early rejection such as a rate limit therefore still leaves a record. Initiator is unknown when authentication never completed.

Error sanitization - Exhaustive match prevents silent PII leakage:

#![allow(unused)]
fn main() {
// Excerpts of `crates/keystone/src/audit.rs`. Both matches have no wildcard
// arm, so a new `KeystoneApiError` / `AuthenticationError` variant does not
// compile until it is classified.
pub 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::UnauthorizedNoContext => "Unauthorized".to_string(),
        KeystoneApiError::NotFound { .. } => "NotFound".to_string(),
        KeystoneApiError::Conflict(_) => "Conflict".to_string(),
        KeystoneApiError::BadRequest(_) => "BadRequest".to_string(),
        KeystoneApiError::InvalidToken => "InvalidToken".to_string(),
        KeystoneApiError::TooManyRequests { .. } => "TooManyRequests".to_string(),
        KeystoneApiError::ServiceUnavailable(_) => "ServiceUnavailable".to_string(),
        // ... every remaining variant (about two dozen) is listed explicitly.
    }
}

pub fn sanitize_authentication_error(e: &AuthenticationError) -> &'static str {
    match e {
        AuthenticationError::UserDisabled(_) => "UserDisabled",
        AuthenticationError::UserLocked(_) => "UserLocked",
        AuthenticationError::UserNameOrPasswordWrong => "UserNameOrPasswordWrong",
        AuthenticationError::Ec2SignatureInvalid => "Ec2SignatureInvalid",
        AuthenticationError::Provider { source, .. } => {
            extract_provider_name(source.as_ref()).unwrap_or("ProviderError")
        }
        // ... all ~40 variants map to stable literals.
    }
}

/// 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() {
// Shape of the macro (`crates/core/src/events.rs`); see its rustdoc for the
// full contract.
audited_op! {
    dispatcher: &self.events,
    ctx: vsc,                         // &ValidatedSecurityContext
    event: Event::new(Operation::Delete, EventPayload::User { id }),
    operation: self.backend.delete_user(state, id),
    on_audit_error: |e| ProviderError::AuditDispatchFailed { source: e },
    // optional: reason: |e| "StaticReason"
}
}

Behaviour: (1) emit the Attempt record on the critical channel and return on_audit_error(..) without running the operation if that fails (fail-closed); (2) run the operation; (3) emit Success, or Failure whose reason is a &'static str variant name from the error’s strum::IntoStaticStr derive (never formatted from error data). If the post-audit record cannot be queued, an ERROR log is written and keystone_audit_postaudit_dropped_total is incremented.

Coverage: every state-changing provider method is wrapped in audited_op! (through audited_if_ctx!, which runs the operation unaudited-but-emitted when the caller is internal, e.g. a janitor or a hook, and has no principal to attribute it to). That includes API keys, OAuth2 clients and signing keys, token revocation (DELETE /v3/auth/tokens), domain configuration, ID mappings, dynamic plugin identities, SCIM index entries and the credential bulk deletes. Payloads carry IDs only, never secrets or option values. The unit test audit_coverage::every_mutating_provider_method_is_audited_or_allow_listed walks every provider service.rs and fails for a mutating method that has neither call site nor an ALLOW_LIST entry with a reason, so a new provider cannot ship unaudited by accident.

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(salt="keystone-audit-hmac-salt-v1", IKM=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). Full HKDF (Extract-then-Expand with a fixed non-empty salt) is used, rather than the Expand-only form, because it is the flow the FIPS 140-3 validated module documents as an approved key derivation. The cadf crate has a fips feature that runs HMAC-SHA256, HKDF and key generation on that module. 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="pending" [10m]) - sum_over_time({app="keystone"} | cadf_outcome=~"success|failure" [10m]) > 0
  • Authenticated Principal Boundary: the initiator of a post-authentication failure comes only from the authentication result’s principal (build_initiator_from_principal). 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

The audit counters are exported as Prometheus counters (not gauges), next to gauges for the queue depth, spool size and signing key version, using the shared openstack-keystone-metrics primitives (ADR 0031):

MetricTypeMeaning
keystone_audit_events_totalcounterEvents accepted into a channel (drops excluded)
keystone_audit_dropped_totalcounterPerimeter events dropped because the channel was full
keystone_audit_postaudit_dropped_totalcounterPost-audit outcomes that could not be recorded
keystone_audit_channel_depth{channel}gaugeEvents queued for the spool writer (perimeter, critical)
keystone_audit_hmac_key_versiongaugeHMAC key version currently signing events
keystone_audit_spool_bytesgaugeLive spool plus sealed segments on disk
keystone_audit_spool_write_failures_totalcounterEvents the writer failed to append (lost)
keystone_audit_spool_quarantined_totalcounterSegments quarantined for tampered or unparsable lines
keystone_audit_spool_verified_total{result}counterLines checked at startup (verified, invalid)
keystone_audit_shipped_events_total{result}counterEvents handed to the sink (shipped, skipped)
keystone_audit_sink_errors_totalcounterFailed batch deliveries to the sink
keystone_audit_spool_retention_deleted_totalcounterSealed segments deleted unacknowledged by the size, age or count limits

Offered load on the perimeter channel is events + dropped, so the drop ratio is dropped / (events + dropped). The rules below live in deploy/prometheus/alert_rules.yaml, together with alerts for spool write failures, quarantined segments, a failing sink and a backed-up critical channel:

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]) + rate(keystone_audit_dropped_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."

Implementation status

AreaStatus
Framework: AuditDispatcher, HMAC signing, spool, replayImplemented
Spool segments, quarantine of tampered files, writer lockImplemented
Downstream sink and segment acknowledgementImplemented (stdout sink; a segment is deleted only after the sink accepted it; a network sink is tracked separately)
Shutdown drain (spool_drain_timeout_secs)Implemented
Metrics (keystone_audit_*) and alert rulesImplemented; counters, except the gauges named in the metric list
Perimeter events: login handlersImplemented (token, EC2, OAuth2 token, federation JWT/OIDC)
Perimeter completion record for the other authentication surfacesImplemented by the request middleware (path allowlist)
Ingress record for every requestOpt-in: [audit] perimeter_all_requests = true adds the completion record to every request (default false, to keep the perimeter channel bounded). Not a separate event from the Auth extractor
Provider auditing: audited_op! / audited_if_ctx!Implemented, enforced by a coverage test over the provider services
Initiator from the authenticated principal; request audit contextImplemented
Wire formatDSP0262 key names, integrity fields in an attachments entry (see above); schema version 1.1
Signing-key rotationKey versions are stamped and old versions verify; operator-driven rotation is not automated

Phase numbers in code comments (“Phase 4” metrics, “Phase 5.x” vectors and integration tests) refer to follow-up work after the three phases above; it is described in the Observability section and covered by the test suites.

  • 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: "failure" with reason TooManyRequests. 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.


Relationship to the Raft Storage Audit (#1378)

The distributed storage layer (ADR 0016-v2 §3.1) keeps its own audit pipeline in crates/storage: AuditForwarder signs AuditRecords with an HMAC key derived from the DEK epoch and appends them to a bounded, fsynced spool. That leaves two producers next to the CADF pipeline described here. The decision is to keep the two signing schemes and record shapes separate, and to share the delivery path.

Why not fold the forwarder into the cadf spool:

  • Different trust root. The storage key is derived from the DEK, rotates with every DEK epoch swap on every node and is bound to the HSM/KEK provider; the CADF key comes from the audit keyring ([audit] hmac_kek_file) and rotates on its own schedule. Merging them would either tie CADF signing to the storage key lifecycle or weaken the storage one.
  • Different record shape. Storage records are {"record", "key_version", "hmac"} lines keyed by dek_version, not CADF events; a SIEM already verifies them with the documented procedure. Re-shaping them is a wire-format break for no integrity gain.
  • Dependency direction. crates/storage is deliberately free of the keystone service crates; the audit crate depends on core-types.

What is shared instead. The segment shipper is generic over the producer: with [audit] sink configured, run_raw_segment_shipper ships the sealed raft-audit-<node_id>.jsonl.seg-* segments from the storage audit spool (audit_spool_dir, default <path>/audit-spool) through the same AuditSink, verbatim, and deletes each segment once the sink acknowledged it. The live file is never read. Sinks carry these lines through AuditSink::write_lines (stdout: the raw line; syslog: the line as MSG with MSGID = raft-audit). The storage spool writer drops segments the shipper removed from its size accounting, so acknowledged data no longer counts against audit_max_spool_bytes. One sink configuration, one delivery guarantee (at-least-once, deduplicate on the record), two signing schemes.

Open follow-up: converging the two key hierarchies and record formats (one derivation, one CADF shape for storage-side events) remains a possible future change; it needs a KMS-side derive for PKCS#11/TPM and a migration for existing storage audit consumers, and is out of scope here.


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/cadf/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

Accepted. Implemented in auth-plugin-core, auth-plugin-runtime, auth-plugin-identity-driver-raft, and crates/core/src/auth_plugin{,_auth,_http,_identity}.rs / auth_plugin_startup.rs / auth_plugin_http_client.rs. Status was left at Proposed after the feature shipped; corrected 2026-09-14 (security review re-evaluation, V7) so a reader triaging by ADR status does not under-weight this pre-authentication, internet-facing WASM execution path.

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’s 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 = 600
invocation_rate_limit_per_minute = 6000
max_concurrent_invocations = 16

# route-mode example: Terraform's OpenStack provider always sends
# identity.methods = [application_credential]. This plugin inspects the
# application_credential's 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: 600/min per source, 6000/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

Accepted.

Implementation status

Verified against the code on 2026-10-15.

SectionClaimStatusNotes
§3 Automatic signing-key rotationPrimary key rotated every signing_key_rotation_daysBuiltKey janitor rotates and reports rotated; tracked in #1282.
§4 Claims templatePer-client claims_template shapes id_tokenBuiltcrates/core/src/oauth2_client/id_token.rs.
§4 openstack:api on human grantsAccess token usable against OpenStack APIsPartialSupported for client_credentials and token exchange; rejected on authorization_code and device_code.
§6 Downstream middlewareKeystoneNativeJwtMiddleware for Nova/NeutronNot builtPython filter lives outside this repository; only the Rust reference verifier ships.
§7.C Device code rate limitingPoll interval, quiet period, user-code backoffBuiltdevice_code.rs, [oauth2] device_* options.
§8 max_ageForces re-authentication on /authorizeBuiltauthorize.rs compares max_age with the SSO auth_time.
§12 Token exchange (RFC 8693)Exchange for a Keystone-scoped tokenBuilttoken/token_exchange.rs.

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 previous 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 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", carrying openstack_context/roles) when the client explicitly requested and was granted the openstack:api scope (§4, “Scope Validation”; not yet supported on authorization_code/device_code, see the status table). 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 but capped by an absolute family lifetime fixed at root issuance (default 90 days, [oauth2] refresh_token_absolute_lifetime_days). The user (exists, enabled, same domain) and domain (enabled) are re-validated on every rotation; failure revokes the family.
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)Control-plane identifier aud: "openstack-apis", optionally narrowed to openstack-apis:<service type> values (ADR 0036 §7.1). Domain isolation is carried by iss, and the per-issuer JWKS, not by aud (see §5, “Domain Key Isolation and aud”).
Downstream AcceptanceExecuted via a lightweight, custom Python WSGI middleware injected into existing 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` 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"
    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: Audience,          // "openstack-apis" (whole control plane) or narrowed ["openstack-apis:<service type>", ...]; domain isolation is via `iss` + per-issuer JWKS (see §5)
    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.

  • POST /v4/oauth2/{domain_id}/revoke - RFC 7009 token revocation by the authenticated client. A refresh token revokes its whole family (reason rp_revoke); an access token’s jti is added to the jti revocation list (§3) until its exp. Always 200 for well-formed authenticated requests (unknown or foreign tokens are not distinguishable). OIDC access tokens carry a private sid claim (the refresh family_id), so revoking one also revokes its family; revoking a refresh token does not revoke access tokens already minted from its family (the jti list is not indexed by family).

Every create/update/delete/rotate-secret event triggers a CADF audit event (§9), same as mapping ruleset mutations.

Domain Key Isolation and aud

Each domain owns an independent signing keypair (§3), synchronized separately via Raft + FjallDB:

  • OpenStack-capable access tokens carry aud = "openstack-apis": the whole control plane of the deployment.
  • A token may be narrowed to specific service types at issuance via the RFC 8693 audience parameter (ADR 0036 §7.1): aud = ["openstack-apis:<service type>", ...]. Narrowing only ever removes reach.
  • A service accepts a token when aud contains openstack-apis or openstack-apis:<its own service type>. A service that does not know its own service type accepts only the un-narrowed form.

Because aud does not carry the domain, domain isolation rests on iss, the per-issuer JWKS. The issuer (/v4/oauth2/{domain_id}) names the signing domain, and a verifier:

  1. Trusts a Keystone, not a list of domains. It is configured with the base URL of the Keystone(s) it trusts (keystone_issuer_base_url) and accepts any iss of the form {base}/v4/oauth2/{domain_id}. A public cloud with many, dynamically created domains therefore needs no per-domain configuration on any service. An explicit issuer list (or pattern) remains available for a service that must trust only some domains.
  2. Derives the endpoints from iss. The JWKS ({iss}/jwks) and revocation list are fetched lazily per issuer into a bounded cache, only from under the configured base URL: an attacker-chosen iss must never make the service fetch an arbitrary host, and unknown issuers are negatively cached and rate limited so random iss values cannot become a fetch storm. A deleted domain serves no JWKS, so its tokens fail closed with no configuration change.
  3. Verifies a token only against the key set of the issuer it names, never a union of key sets, so one domain’s key cannot vouch for another domain’s iss.

iss names the signing domain and carries no scope authority. The token says who the caller is (vision, “a JWT that says who”); the scope it carries today is a transitional hint, and a user of one domain holding a role on a project of another, or a human holding a system-level grant, is legitimate whichever domain’s issuer signed the token. Verifiers therefore do not compare the scope to iss, and System scope is not refused. The authority for a scope is the mint side: the exchange validates the subject through the token provider, and client_credentials scope comes from a mapping whose authorizations are gated at write time. A domain manager may only create or change rulesets whose authorizations stay in the ruleset’s own domain; granting system scope or scope in another domain requires admin (policy/mapping/ruleset/{create,update}.rego, also for rule mutations). The policy sees the declared project_domain_id, so the mapping service also verifies at write time that it is the domain the project really belongs to (ProjectDomainMismatch); a project cannot move between domains, so this cannot go stale.

Enforcement is at write time only. The token path does not re-check the resolved scope against the ruleset’s provenance, and a global ruleset (no domain_id, admin-only) is not reachable from an OAuth2 client, because the client_credentials lookup is keyed on (client.domain_id, source). System scope for a workload through OAuth2 is therefore not available yet. The intended follow-up is a mint-time check keyed on the ruleset’s own domain: a domain-bound ruleset may only grant scope inside its domain (to principals of any domain), a global ruleset may grant any scope. It needs a global lookup path for clients and protection against a provider_id collision, since provider_id is chosen by the client’s creator and is unique only within its domain: a domain manager must not be able to adopt the provider_id an admin’s global ruleset targets. System scope for human administrators is a separate question, tied to per-request authorization (vision §4.3).

Residual risk: a leaked domain signing key can sign any scope claim, and every verifier that trusts that issuer accepts it. This closes only when the scope leaves the token and is decided per request by Keystone (vision §4.3). A domain audience in aud is deliberately not used: it would not help (the forger controls the claims) and would stop one token from working across services and domains.

Trade-off: the trusted base URL (or explicit issuer list) is the boundary: a service accepts every token from any issuer it trusts, for that issuer’s own domain. A service that should trust only some domains is configured per service, not through aud. The project stays in the project_id/delegated_project_id claims and is enforced as a target check (the request’s target project must equal the claim), as for Fernet project-scoped tokens; it is not put in aud.

Revocation at scale: the JTI revocation list is served per domain (/v4/oauth2/{domain_id}/jwks/revocation), so a verifier trusting many domains would poll once per active issuer every 60 s. Entries only exist after an emergency key rotation, client deletion or explicit revocation, so almost every list is empty. A cluster-wide revocation feed (one endpoint returning the union of all domains’ revoked jtis, optionally with a since cursor or ETag) is the recommended extension for large deployments: a jti is a UUID, so a flat set is safe to share across domains, and a verifier keeps one 60 s poll regardless of the number of domains. The per-domain endpoint is the baseline; a verifier that trusts many domains polls it once per active issuer.

Open point: whether keystonemiddleware and the Python service-side middleware can hold more than one trusted issuer today has not been verified.


6. Downstream Control Plane Enforcement Layer (Python WSGI Middleware)

To execute an incremental, zero-downtime parallel rollout alongside existing OpenStack installations, a thin custom Python WSGI middleware is dropped directly into the Paste Deploy pipelines of existing 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
        # Trust a Keystone, not a list of domains (see ADR §5, "Domain Key
        # Isolation and `aud`"): any `iss` of the form
        # `{base}/v4/oauth2/{domain_id}` is accepted, and its JWKS and JTI
        # revocation list are derived from `iss` and fetched lazily, only from
        # under this base. A token is verified only against the key set of the
        # issuer it names. jwt.decode's `require: ["iss"]` only checks claim
        # *presence*, not the value, so `iss` is matched explicitly below.
        self.issuer_base = config.get('keystone_issuer_base_url').rstrip('/')
        self.issuer_prefix = f'{self.issuer_base}/v4/oauth2/'
        # Optional: restrict to specific domains (domain ids). Empty = all.
        self.allowed_domains = set(config.get('keystone_allowed_domains', []))
        # Issuers whose JWKS fetch failed with 404 (unknown/deleted domain):
        # negatively cached so a forged `iss` cannot force a fetch per request.
        self.unknown_issuers = TTLCache(maxsize=1024, ttl=60)
        # `aud` is the flat control-plane value, or narrowed to this service's
        # own type (ADR 0036 §7.1). Without a service type only the flat value
        # is accepted.
        self.accepted_audiences = ['openstack-apis']
        if service_type := config.get('keystone_service_type'):
            self.accepted_audiences.append(f'openstack-apis:{service_type}')
        # 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=1024, ttl=300)
        # 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=1024, ttl=60)
        # 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. Pick the issuer from the (still unverified) token. It
                # must sit under the configured Keystone base (so no arbitrary
                # host is ever fetched) and name an allowed domain; then
                # fetch/update ONLY that issuer's asymmetric public
                # verification keys. Raises requests.RequestException on
                # failure past the cache TTL, caught below and treated as
                # fail-closed (§11). Trusting `iss` here only selects which key
                # set must verify the signature; a forged `iss` just fails
                # verification.
                issuer = jwt.decode(
                    token, options={"verify_signature": False}
                ).get('iss')
                issuer_domain = self._issuer_domain(issuer)
                if issuer_domain is None or issuer in self.unknown_issuers:
                    logger.warning('Unexpected issuer: %s', issuer)
                    return self._abort_unauthorized(start_response, 'Untrusted issuer')
                public_keys = self._get_cached_jwks(issuer)

                # 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.accepted_audiences,
                    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. The verified `iss` must be the issuer whose key set
                # verified the signature (claim presence alone, enforced by
                # `require` above, is not enough)
                if decoded_claims['iss'] != issuer:
                    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(issuer, 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 _issuer_domain(self, issuer):
        """Domain id of an acceptable issuer, else None."""
        if not isinstance(issuer, str) or not issuer.startswith(self.issuer_prefix):
            return None
        domain_id = issuer[len(self.issuer_prefix):]
        if not domain_id or '/' in domain_id:
            return None
        if self.allowed_domains and domain_id not in self.allowed_domains:
            return None
        return domain_id

    def _get_cached_jwks(self, issuer):
        """Fetch the issuer's 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(issuer)
        if cached is not None:
            return cached
        return self._fetch_jwks_from_network(issuer)

    def _fetch_jwks_from_network(self, issuer):
        """Raw JWKS fetch for one issuer. Raises on failure (fail closed)."""
        resp = requests.get(f'{issuer}/jwks', timeout=5)
        if resp.status_code == 404:
            self.unknown_issuers[issuer] = True
        resp.raise_for_status()
        keys = resp.json()
        self.jwks_cache[issuer] = keys
        return keys

    def _is_jti_revoked(self, issuer, 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(issuer)
        if revoked is None:
            resp = requests.get(f'{issuer}/jwks/revocation', timeout=2)
            resp.raise_for_status()
            revoked = set(resp.json().get('revoked_jtis', []))
            self.revocation_cache[issuer] = 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 polling at /token and user_code submission at /device are each subject to the per-IP limiter ([rate_limit_global_ip]), and /token additionally to the per-client_id limiter.
  • Failed user_code lookups at /device (unknown, expired, or wrong-domain code) feed an exponential backoff, answered 429 + Retry-After before any storage lookup. It is keyed twice: on (normalized user_code, client IP) ([oauth2] device_user_code_free_failures, default 3, then 1s doubling to a 300s cap) and on the client IP alone (device_ip_free_failures, default 10, same curve; 0 disables it for deployments where many users share one resolved IP). The user_code is trimmed and upper-cased, and keys are SHA-256 digests. The IP is part of the code key so a third party cannot lock a victim’s pending code out by hammering it; the IP-only key is what bounds spraying of many different codes, since each guess gets a fresh code key. The client IP is resolved as for the per-IP limiter (trusted-proxy settings apply even with [rate_limit_global_ip] disabled, which is the default), and the backoff works with that limiter disabled. Operators behind a reverse proxy must list it in [rate_limit_trusted_proxies]; otherwise all users share the proxy’s IP and one client can trip the IP-wide key for all (set device_ip_free_failures = 0 to disable it). State is node-local and in-memory (not reset by config reload) and capped: once full of recently active keys, new keys go untracked. The check and the failure record are not atomic, so concurrent guesses can slip a few extra attempts through. A peerless (internal) request is not throttled.
  • Invalid or expired device_code presented at /token triggers a quiet period ([oauth2] device_code_invalid_quiet_period_seconds, default 300) keyed on (client_id, device_code). Further polls with that pair are answered 429 + Retry-After before any storage lookup. The quiet period is node-local and in-memory. Issuance at /device_authorization is not blocked per IP; it is bounded only by the generic per-IP limiter ([rate_limit_global_ip], disabled by default), which operators exposing the device flow should enable.
  • 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.)
  • Cache-Control: no-store and Pragma: no-cache (login/consent forms carry CSRF tokens, redirects carry authorization codes; also set on every /token, /device_authorization and /revoke response, success and error, per RFC 6749 §5.1)
  • Referrer-Policy: no-referrer (the RP’s state and authorization codes must not leak to third-party resources via the Referer header)

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

Amendment: TOTP Second Factor on the OP Login

After a successful password check the OP looks up the user’s TOTP credentials. If any are enabled, user_id is not stamped on the pre-auth session; the session instead records pending_user_id, pending_factors and a mfa_attempts counter, so consent stays unreachable until the code verifies (POST /authorize/mfa, POST /device/mfa). Verification reuses authenticate_by_totp (and its per-user rate limit, the primary brute-force control) and is additionally capped per session by [oauth2] mfa_max_attempts (counted before the code is checked, not reset by re-entering the password; best-effort). A user with multi_factor_auth_enabled = false skips the step; only admins and domain managers can change user options. A lookup error fails closed. On success the session is stamped with amr = ["pwd", "otp", "mfa"], which is copied to the id_token and to refresh tokens so rotation preserves it. WebAuthn is not part of this amendment.

Amendment: Federated Sign-In on the Login Page

The browser login page can hand the user to an upstream federation IdP of the same domain (POST /authorize/federated, or idp_hint on GET /authorize) and receives the answer at GET /authorize/federated/callback. The upstream state is the primary key of the stored federation AuthState and is also written to the pre-auth session (pending_upstream); the callback only completes when the session cookie’s session waits on exactly that IdP and state, and both the pending value and the AuthState are consumed on use. (The issue text proposed an HMAC over the session id; a server-side comparison against the stored value gives the same binding without a second secret.) The redirect target comes only from a registered, enabled IdP of the domain, never from a request parameter. Code exchange, ID-token verification and the mapping engine are shared with /v4/federation/oidc/callback. The session records UpstreamLogin { idp_id, sid }, copied to the authorization code and to every refresh token of the family. The endpoints also require the session to belong to the path domain. A federated login replaces the local second factor: the IdP is the only authenticator, and the amr values it reports are attested by the IdP, not by this OP. Replay protection comes from consuming pending_upstream in complete_upstream_login, not from the non-atomic AuthState lookup/delete. Not yet implemented: the device-code page, and group-membership expiry re-checks at rotation.

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}(\text{salt}=\text{“keystone-audit-hmac-salt-v1”}, \text{KEK}, \text{info}=\text{“keystone-audit-hmac-v1:”} \parallel \text{node_id}, 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.
  5. Revoked families are kept as tombstones (for forensics and correlation with the critical audit event), not deleted. Expiry is otherwise enforced lazily on read, so a leader-only session janitor (oauth2_session::janitor, every [oauth2] session_janitor_interval_seconds, default 300) walks the expiry index and purges abandoned pre-auth sessions, unredeemed authorization codes, unpolled device grants and never-revoked refresh tokens (live or spent) shortly after they expire. Revoking a family moves its members to a separate refresh_tombstone expiry kind, which is purged [oauth2] revoked_family_retention_days (default 30) after the token’s own expires_at. Each pass emits one CADF maintenance event carrying the per-kind purge counts. Spent refresh tokens keep their expiry index entry so rotated parents are reclaimed too.

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 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 specified in ADR 0036 §7.1 and implemented for the application-credential exchange.

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 existing 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 existing 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, see the admin guide’s “Introspection for high-criticality services” for the RFC 7662 /introspect endpoint). 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

Accepted. Implemented in crates/identity-driver-ldap, selectable via [identity] driver = "ldap" plus a [ldap] config section (crates/config/src/ldap.rs). A few implementation notes refine this ADR’s original text:

  • Backend selection is not inventory-based; it is a hand-written HashMap<String, Arc<dyn IdentityBackend>> registry in crates/keystone/src/plugin_manager.rs, conditionally populated only when driver = "ldap" is configured (mirroring the JWS token provider’s fail-fast-on-select precedent). The linker-anchor mechanism in §1 is unrelated to this selection; it only keeps the crate linked.
  • There is no generic Hints/comparator abstraction in this codebase. §5’s filter translation is a direct function of UserListParameters/ GroupListParameters’ concrete fields, not a general comparator engine.
  • is_domain_aware()/generates_uuids() capability-flag methods were not added to IdentityBackend; the backend simply always reports config.identity.default_domain_id and uses the directory’s user_id_attribute value as id.
  • Write operations return a dedicated IdentityProviderError::Readonly (mapped to HTTP 403); operations with no LDAP equivalent (expiring group membership, service accounts, federated users) return IdentityProviderError::NotImplemented instead, since these are a different condition than a read-only rejection.

A subsequent review against upstream Python Keystone (keystone/identity/backends/ldap/{core,common}.py, keystone/conf/ldap.py) found and fixed several compatibility bugs:

  • query_scope defaulted to sub; Python defaults to one.
  • user_enabled_mask used (value & mask) != 0; Python’s actual semantics are (value & mask) != mask (enabled unless all masked bits are set — the Active Directory userAccountControl ACCOUNTDISABLE convention), and user_enabled_mask ignores user_enabled_invert entirely. The previous formula was exactly inverted for the common mask = 2 case.
  • user_enabled_default was typed as a Rust bool, which cannot represent Python’s dual usage as a boolean literal ("True") in the plain case and an integer literal (e.g. "512") in the user_enabled_mask case; it is now a String, parsed contextually, matching Python’s StrOpt.
  • get/get_by_name/get_all (single lookups and listing) previously resolved a DN via ID construction/search first, then did a second BASE-scoped fetch. Python’s read path never constructs a DN this way (_id_to_dn is write-only, used only by create/update/delete, which don’t exist on this permanently-read-only backend) — every read is a single filtered search under tree_dn, scoped by query_scope (one/sub), reading the resulting entry’s real DN off the result. The driver was changed to do the same, which also means query_scope = "one" now actually restricts listing/lookup to one level below the tree DN, as in Python, rather than always searching the whole subtree.
  • group_members_are_ids (members of group_member_attribute are user IDs, not DNs — e.g. posixGroup’s memberUid) was missing entirely; group membership resolution always assumed DNs.
  • Several missing [ldap] options were added for config-file parity: user_description_attribute, user_pass_attribute, user_default_project_id_attribute, group_members_are_ids, group_attribute_ignore, and an alias_dereferencing = default choice.
  • [ldap] url now supports Python’s comma/whitespace-separated multi-URL HA syntax, trying each candidate in turn (optionally shuffled via randomize_urls) until one connects and binds.

Two Python options are parsed for config-file compatibility but not functionally applied, since the ldap3 crate (v0.11) has no equivalent hook: alias_dereferencing (no per-search deref control) and tls_cacertfile/tls_cacertdir (TLS verification uses the platform/ rustls default trust store). Both are documented inline and log a startup warning when set. Custom CA loading is a reasonable follow-up if needed; operators can install the CA into the system trust store or use tls_req_cert = never for self-signed test directories in the meantime.

Live-directory functional tests against a real OpenLDAP slapd instance now exist (crates/identity-driver-ldap/tests/), run as a cargo nextest setup-script pattern (installing/seeding a local slapd, no Docker daemon required) analogous to tools/start-api.sh. A side-by-side Python/Rust smoke comparison remains follow-up work, not implemented in this change.

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_id → domain_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 compatibility – group_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 dependency – ldap3 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/cadf/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).

29. Generalized Marker Pagination for v3/v4 List Endpoints

Date: 2026-07-24

Status

Accepted

Context

Issue #308 asked for consistent, python-keystone-compatible pagination across all list endpoints. Two domains (federation identity_provider, ADR 0007) already had a first-cut implementation before this ADR, but it had real gaps:

  • False-positive next link. The original build_pagination_links() inferred “more pages exist” purely from data.len() >= limit. If the table had exactly limit rows left, it wrongly emitted a next link to an empty page.
  • No config-driven limit resolution. Nothing mirrored python-keystone’s Hints.get_limit_with_default precedence chain (user limit → per-resource default → global list_limit → absolute max_db_limit).
  • Duplicated fields per domain. The existing code hand-rolled limit/marker fields directly on each domain’s list-parameters struct instead of a single shared, reusable pagination type.

Real python-keystone (keystone/common/driver_hints.py, the @truncated decorator, keystone/server/flask/common.py wrap_collection) avoids the false-positive by always fetching limit + 1 rows and trimming the extra one off before returning — that is the mechanism this design replicates. It’s also worth noting real upstream wrap_collection hardcodes links: {next, self, previous: null} — previous is never computed, and there’s no working page_reverse anywhere in current upstream. So byte-for-byte v3 compatibility (OSC, keystoneclient) only requires forward-marker paging with previous always absent/null. v4 is our own API, not constrained by OSC compatibility, so it gets real bidirectional paging.

serde_urlencoded (what axum’s Query<T> extractor uses) does not support #[serde(flatten)] on typed numeric fields — limit: Option<u64> flattened into an outer struct fails to deserialize "limit=10" with a type-coercion error, because flatten forces buffering through serde::private::de::Content, which loses string-to-number coercion. A single shared pagination struct is still achievable without flatten: axum allows a handler to take two independent Query<T> extractors against the same request — each runs its own serde_urlencoded::from_str over the full query string and silently ignores fields it doesn’t declare. This is the mechanism used throughout.

Decision

Shared types

  • PaginationQuery (crates/api-types/src/lib.rs): { limit: Option<u64>, marker: Option<String>, page_reverse: bool }. Taken as a second, independent Query<PaginationQuery> extractor in every v3 and v4 list handler, alongside each domain’s existing (unchanged) filter-only params struct. v3 handlers accept page_reverse in the query string (harmless, ignored) but never read or forward it — only v4 handlers wire it through.
  • ListPagination (crates/core-types/src/lib.rs): { limit: Option<u64>, marker: Option<String>, page_reverse: bool }. Embedded as a single named field, pub pagination: ListPagination, on every domain’s *ListParameters struct — replacing what would otherwise be three duplicated fields per domain.
  • ResourceIdentifier trait (crates/keystone/src/api/common.rs): fn get_id(&self) -> String. Implemented once per domain, normally on the API-facing response type (the type actually returned to the handler after provider conversion). See “RoleAssignment” below for the one domain that deviates from this.

Over-fetch-and-trim helpers (crates/keystone/src/api/common.rs)

Two functions, both generic over T: ResourceIdentifier:

  • paginate_forward (v3): assumes the backend already returned up to limit + 1 rows in ascending order. If it got the extra row, trims it and emits a next link built from the last remaining item’s id; previous is never emitted (matches upstream wrap_collection).
  • paginate_bidirectional (v4): same over-fetch/trim logic, but also emits a real previous link whenever the request carried a marker at all (i.e., this isn’t the first page). Handles page_reverse by trimming from the appropriate end.

Both return (trimmed_items, Option<Vec<Link>>); call sites replace the naive Json(List { data, links: None }) construction with the trimmed vec plus the returned links.

Config: per-provider limits (crates/config/src/pagination.rs)

#![allow(unused)]
fn main() {
#[derive(Deserialize, Debug, Clone, Default)]
pub struct ListLimitConfig {
    pub list_limit: Option<u64>,
    pub max_list_limit: Option<u64>,
}
}

Embedded as pub list_limit: ListLimitConfig on each per-domain provider config struct (e.g. CatalogProvider, AssignmentProvider, ScimRealmProvider, MappingProvider). Resolution is Config::resolve_list_limit(&self, provider_limit: &ListLimitConfig, requested: Option<u64>) -> Option<u64>, called by the handler before invoking the provider: user-supplied limit → provider’s list_limit → clamped to max_list_limit if set.

Backend responsibility: over-fetch by one

Each backend is responsible for actually fetching limit + 1 rows in marker/direction order — the API layer’s paginate_forward/ paginate_bidirectional only trims what it’s given, it never re-queries. Two shapes of backend exist in this codebase:

  • SQL-backed, single-cursor domains (User, Project, Domain, Group, Role, Service, Region, Endpoint, Credential, K8sAuthInstance, TokenRestriction, federation identity_provider): the marker/limit filter applies directly to the driving SQL query (Column::Id.gt(marker), .order_by_asc(...), .limit(effective_limit + 1); reversed comparison and ordering when page_reverse).
  • Raft-backed, in-memory domains (ScimRealmResource, OAuth2ClientResource, ApiClientResource, MappingRuleSet): there is no SQL cursor at all. The backend fetches the full filtered candidate set, sorts it by a chosen unique key, retain()s items strictly greater/less than the decoded marker depending on page_reverse, then truncates (or split_offs, for the reverse direction) to limit + 1.

RoleAssignment: the one deliberate deviation

RoleAssignment’s result set is a union of two SQL queries plus in-memory role-implication expansion (resolve_implied_roles); it cannot be paginated via a SQL cursor at all. It reuses the in-memory sort/retain/truncate pattern from the Raft-backed domains, but inside the SQL driver’s outer list_assignments function, applied after the union and expansion are fully materialized.

Assignment rows have no single id column, so pagination uses a synthetic composite marker:

format!("{type}:{actor_id}:{target_id}:{role_id}:{inherited}:{implied_via_or_empty}")

This deliberately matches the exact tuple resolve_implied_roles()’s HashSet<Assignment> already uses for full-struct-equality deduplication — chosen specifically because two different prior roles can each imply the same role_id for the same actor/target, producing rows that would otherwise collide without implied_via in the key.

Because the API-facing Assignment response type lacks inherited and implied_via, ResourceIdentifier is implemented on the core-types Assignment instead (legal per Rust’s orphan rule, since ResourceIdentifier is a local trait), and paginate_forward is called on the raw provider result before API conversion, rather than after as in every other domain.

Consequences

  • All 16 domains identified in the original rollout now share one pagination mechanism: User, Project, Domain, Group, Role, federation identity_provider, Service, Region, Endpoint, Credential, K8sAuthInstance, ScimRealmResource, OAuth2ClientResource, ApiClientResource, TokenRestriction, RoleAssignment, and mapping MappingRuleSet.
  • ServiceAccount and RevocationEvent are out of scope: neither has a list capability exposed over REST (ServiceAccount) or a public REST endpoint at all (RevocationEvent, internal-only).
  • v3 responses never gain a previous link, matching upstream python-keystone byte-for-byte; only v4 endpoints support page_reverse/previous.
  • Every domain’s list handler follows the same mechanical recipe, making it straightforward for new domains to adopt: add pagination: ListPagination to the core-types list-params struct, apply over-fetch-by-one in the driver, implement ResourceIdentifier, add the second PaginationQuery extractor plus paginate_forward/paginate_bidirectional in the handler, add list_limit: ListLimitConfig to the provider’s config struct.

30. Per-Request Cache via tokio::task_local!

Date: 2026-07-28

Status

Proposed

Context

Several sessions of query-log analysis found the same shape of bug repeatedly: a single incoming request re-fetches the same row more than once because two independent code paths, invoked within the same request, each do their own lookup with no way to know the other already ran. Two concrete instances fixed ad hoc so far:

  • Token validation fetched the token’s domain twice — once to validate scope, once to build the response — because the two call sites had no shared state between them.
  • identity-driver-sql fetched a user’s entire password history on every display/auth path (GET /users/:id, GET /users, password auth), even though every consumer only ever reads the newest row (merge_passwords_data always calls .next() on the iterator it’s given). That one was fixed by pushing ORDER BY ... DESC LIMIT 1 / DISTINCT ON down into SQL rather than by caching, because the DB can answer “give me only the newest row” directly — no caching involved, just asking for less data. This ADR does not change that fix.

Both were fixed individually because there was no general mechanism. This ADR is about the next one: a request-scoped cache so redundant lookups within a single request (e.g., two provider calls in the same handler both resolving the same domain_id, or a nested provider call re-fetching a project its caller already loaded) stop needing a bespoke fix each time.

Why not extend the existing process-lifetime cache pattern

crates/core/src/identity/service.rs already has a cache: user_id_domain_id_cache: RwLock<HashMap<String, String>>, config-gated by identity.caching, manually invalidated on user delete and domain reassignment. That pattern works because the cached fact (user_id → domain_id) is close to immutable for the life of the process. Most candidate lookups for a per-request cache (a project row, a domain row, resolved role assignments) are not immutable — they can be mutated by a concurrent request a moment later. A process-lifetime cache for those would need real invalidation plumbing (bus/event-driven cache busting) to avoid serving stale data to a different request after a write. A cache whose scope is exactly one request sidesteps that entirely: it is populated from reads made within the request, discarded when the request ends, and never observed by any other request, so there is no invalidation problem to solve at all — correctness is automatic, not maintained by hand.

Why not thread an explicit cache parameter through every call

ExecutionContext<'a> (crates/core/src/auth.rs:548) is the object passed by reference into every provider trait method (crates/core/src/identity/provider_api.rs and the equivalent provider_api.rs in every other domain crate). It looks like the natural place to hang a cache field. It isn’t, in practice: handlers routinely construct a fresh ExecutionContext::from_auth(&state, &user_auth) inline at each call site rather than building one and reusing it — e.g. crates/keystone/src/federation/api/identity_provider/update.rs constructs two separate ExecutionContexts in the same handler function. Adding a cache field to the struct would only dedupe lookups made through the same ExecutionContext instance, missing exactly the multi-call-site case this ADR exists to fix, unless every handler in the codebase were first refactored to build one ExecutionContext per request and thread &ctx through every call — a much larger, invasive change than the caching mechanism itself warrants.

Why tokio::task_local! is the right shape here

A per-request cache needs to be reachable from deep inside provider/backend code (crates/core/src/identity/service.rs, crates/identity-driver-sql/src/lib.rs, …) without adding a parameter to every trait method across every domain. tokio::task_local! gives exactly that: state that travels implicitly with the currently-executing async task, readable from any function running on that task without being passed explicitly, and automatically gone once the task’s scope ends. This is not a new idea in this codebase — crates/core/src/events.rs:117-125 already uses tokio::task_local! for EMIT_CRITICAL_RECURSION, a reentrancy guard for EventDispatcher::emit_critical, with exactly the defensive access pattern (try_with(...).unwrap_or(default)) this ADR reuses: code running outside any established scope (tests, CLI tooling, background jobs) just sees “no cache,” not a panic or an error.

Axum’s per-request execution unit is a single tokio task (the handler’s future, spawned once per accepted connection/request), so “task-scoped” and “request-scoped” coincide exactly here — there is no separate request abstraction to scope against.

No new dependency

tokio is already a workspace dependency (Cargo.toml:185, tokio = { version = "1.52", default-features = false }) and task_local!/LocalKey::scope/LocalKey::try_with are part of tokio’s core API surface with no additional feature flag required — proven by the existing crates/core/src/events.rs usage compiling and running today under the same default-features = false configuration. moka, lru, and once_cell are not present in the workspace and are not needed. dashmap and arc-swap are already workspace dependencies but are unnecessary here: the cache is exclusive to a single task by construction (never accessed concurrently), so a plain RefCell needs no interior synchronization — using dashmap would only be paying for concurrency control this design never needs.

Decision

New module: crates/core/src/request_cache.rs

#![allow(unused)]
fn main() {
tokio::task_local! {
    /// Per-request cache. Established once per incoming request by Axum
    /// middleware; absent outside a request scope (tests, CLI tooling,
    /// background jobs), in which case reads/writes are silent no-ops.
    static REQUEST_CACHE: RequestCache;
}

#[derive(Default)]
pub struct RequestCache {
    entries: RefCell<HashMap<(&'static str, String), Box<dyn Any + Send>>>,
}
}
  • Keyed by (namespace, id) — namespace is a &'static str literal owned by the calling module (e.g. "resource.domain", "resource.project"), id is the lookup key (e.g. a domain_id). Namespacing avoids collisions between domains caching under the same raw id string.
  • Values are Box<dyn Any + Send>, downcast by the typed accessor below. Send (not Send + Sync) is required and sufficient: nothing needs Sync because access is never concurrent — the cache belongs to exactly one task — but the task itself must remain Send for Axum’s multi-thread runtime to move it between worker threads at .await points, which requires every value the task owns, including task-local storage, to be Send.
  • RefCell, not RwLock/Mutex: no async code holds a borrow across an .await point (get and insert are both synchronous, bracketing any async fetch — see below), and there is never concurrent access to fight over, so an async-aware lock would be pure overhead.

Accessor API

#![allow(unused)]
fn main() {
impl RequestCache {
    pub fn get<T: Clone + 'static>(&self, namespace: &'static str, id: &str) -> Option<T> { ... }
    pub fn set<T: Send + 'static>(&self, namespace: &'static str, id: &str, value: T) { ... }
}

/// Reads from the current request's cache, if any. Outside a request scope
/// (no `task_local` established), always returns `None`.
pub fn cache_get<T: Clone + 'static>(namespace: &'static str, id: &str) -> Option<T> {
    REQUEST_CACHE.try_with(|c| c.get(namespace, id)).ok().flatten()
}

/// Writes into the current request's cache, if any. Outside a request scope,
/// silently does nothing.
pub fn cache_set<T: Send + 'static>(namespace: &'static str, id: &str, value: T) {
    let _ = REQUEST_CACHE.try_with(|c| c.set(namespace, id, value));
}
}

Call-site pattern (e.g. resolving a domain inside a provider method):

#![allow(unused)]
fn main() {
if let Some(domain) = cache_get::<DomainResponse>("resource.domain", domain_id) {
    return Ok(Some(domain));
}
let domain = self.backend_driver.get_domain(ctx.state(), domain_id).await?;
if let Some(d) = &domain {
    cache_set("resource.domain", domain_id, d.clone());
}
Ok(domain)
}

The cache check and the cache write are both synchronous and bracket the .await — no borrow is ever held across an await point, satisfying RefCell’s requirements without needing an async lock.

Known, accepted race: two concurrent lookups for the same key that both miss will both hit the backend and both write — the second write just overwrites the first with an equal value. This is intentionally not guarded with an in-flight-request de-dup (e.g. a HashMap<Key, oneshot::Receiver> pattern) — that adds real complexity for a benefit that only matters if the same request independently kicks off two concurrent fetches of the same key, which existing call sites don’t do (the duplicate-fetch bugs this ADR targets are sequential, not concurrent, within a request).

Middleware: establish the scope once per request

New axum::middleware::from_fn layer, added next to the existing proxy_headers layer (crates/keystone/src/server/proxy_headers.rs:116-120) in the ServiceBuilder chain built by build_router (crates/keystone/src/bin/keystone.rs:781):

#![allow(unused)]
fn main() {
async fn with_request_cache(req: Request, next: Next) -> Response {
    REQUEST_CACHE.scope(RequestCache::default(), next.run(req)).await
}
}

Added to both entry points that build an Axum Router independently: the main router in build_router and the SCIM router (crates/keystone/src/scim.rs:70, nested at /SCIM/v2), since the SCIM router is constructed separately and would otherwise never establish the scope.

Provider-layer usage

cache_get/cache_set are called from the domain provider layer (crates/core/src/identity/service.rs and equivalent service.rs files in resource, assignment, etc.) — the same layer that already hosts the process-lifetime user_id_domain_id_cache — not from the *-driver-sql backend crates, which stay backend-agnostic and untouched. This is opt-in per call site: only lookups that are (a) read multiple times within a plausible single request and (b) not already the cheapest possible query (recall the password fix: sometimes the right answer is “ask the DB for less,” not “cache more”) get wired up. There is no blanket “cache every provider read” policy — that would risk masking future N+1 patterns instead of surfacing them.

Consequences

  • Redundant same-request lookups (repeated domain/project/similar reads across independent call sites within one request) can be eliminated by adding a cache_get/cache_set pair at the provider layer, with no changes to trait signatures, no threading a new parameter through ExecutionContext or any provider_api.rs, and no per-crate dependency changes.
  • No invalidation logic is needed anywhere: the cache is torn down automatically when the request’s task completes (REQUEST_CACHE.scope ends), so a write made by request A can never be observed by request B. This is strictly simpler than the existing user_id_domain_id_cache, which requires manual invalidation on every mutating call that touches its cached facts.
  • Code outside a request scope (unit tests calling provider methods directly, CLI tooling, background jobs, ExecutionContext::internal paths not reached via Axum) sees cache_get always return None and cache_set as a no-op — correct by construction, not by an explicit feature flag, so no test setup changes are required for the bulk of the existing test suite. Tests that specifically want to exercise cache behavior must wrap the call under test in REQUEST_CACHE.scope(...).
  • The mechanism only helps deduplicate reads within one request; it does nothing for the “fetch too much data” class of problem (the password-list fix). Both remain valid, complementary tools — engineers should default to “can the query ask for less” first, and reach for the per-request cache only when the same data is genuinely re-derived from scratch more than once inside one request.
  • Adds one new module (crates/core/src/request_cache.rs) and one new middleware layer registered in two places (build_router, crates/keystone/src/scim.rs). No new workspace dependency.

31. Prometheus Metrics Catalog

Status

Proposed. The exposition mechanism (“Exposition mechanism” below) is superseded by ADR 0040; the catalog, naming and cardinality rules still apply.

Context

Keystone already exposes a /metrics endpoint (crates/keystone/src/bin/keystone.rs, spawn_metrics_listener / metrics_handler) on the dedicated interface_metrics listener (cfg.interface_metrics.tcp_address, default 0.0.0.0:8099), unauthenticated, intended to be firewalled to internal scrapers. Today it serves exactly four metrics, each produced by hand-rolled Prometheus text-exposition (v0.0.4) formatting functions, deliberately avoiding a dependency on the full prometheus client crate (crates/cadf/src/metrics.rs doc comment):

  • keystone_audit_dropped_total (counter, ADR 0023)
  • keystone_audit_postaudit_dropped_total (counter, ADR 0023)
  • keystone_audit_events_total (counter, ADR 0023)
  • keystone_auth_plugin_load_failure{plugin_name} (counter, ADR 0025 §5)

That is coverage for exactly two subsystems (audit spool health, dynamic auth-plugin load failures). Everything else — HTTP request volume/latency, authentication outcomes, token issuance/validation/revocation, OPA policy decisions, rate-limit rejections (ADR 0022), Raft cluster health (ADR 0016-v2), federation/mapping outcomes (ADR 0007/0020), SCIM/API-key/OAuth2/ WebAuthn activity, and per-request cache effectiveness (ADR 0030) — has no metric today. Operators have no Prometheus-native way to alert on auth failure spikes, token backend latency, OPA decision latency, or Raft leader/replication health; they must fall back to log scraping.

This ADR defines the full metrics catalog Keystone should expose, the naming and label conventions all future metrics must follow, and the exposition mechanism for adding them without repeating the ad hoc string-formatting pattern in every crate.

Decision

Naming and label conventions

All metric names use the keystone_<subsystem>_<noun>[_<unit>] shape, snake_case, base units (seconds, bytes), and _total for counters, matching upstream Prometheus naming conventions:

  • Counters end in _total (existing exception: keystone_auth_plugin_load_failure predates this ADR and is not renamed — renaming a scraped metric name is a breaking change for every dashboard/alert already querying it. New counters follow the suffix.)
  • Latency is always a histogram in seconds (_duration_seconds), never a gauge or summary — histograms allow aggregation across instances, which matters for a multi-node deployment (ADR 0016-v2).
  • Point-in-time values (queue depth, connection counts, leader state) are gauges.

Cardinality / PII guardrail (extends ADR 0023’s data-minimization rule to metrics): label values MUST be drawn from a bounded, operator-or-code-controlled set. Never a label:

  • Raw resource IDs (user_id, project_id, token_id, domain_id) — unbounded and, per ADR 0023, treated as sensitive identifiers.
  • Free-text error messages — use the same sanitized variant-name approach as error_variant_name/sanitize_authentication_error (crates/keystone/src/api/* audit integration) so a label value is always one of a fixed Rust enum’s variant names.
  • Raw HTTP path — use Axum’s MatchedPath (route template, e.g. /v3/users/{user_id}), never request.uri().path().

Allowed label values: HTTP method, route template, sanitized outcome/reason enum variant, auth method name, token driver name (fernet/jws), plugin name (already operator-configured, per format_load_failure_metrics), grant/ceremony/resource-type names — all finite sets fixed at compile time or by configuration, not by request content.

Exposition mechanism

Continue the existing hand-rolled text-exposition approach rather than pulling in the prometheus or metrics crate — the four existing metrics were built this way specifically to avoid that dependency, and the catalog below is a fixed, known-at-compile-time set of series (no dynamic metric registration is needed). To avoid re-deriving atomic counters, label-escaping, and bucket math in every crate as this catalog grows, factor the shared primitives that crates/cadf/src/metrics.rs and auth_plugin_startup::format_load_failure_metrics each partially reimplement into one internal module (crates/keystone/src/metrics.rs or a new small openstack-keystone-metrics crate if reused outside keystone/core):

#![allow(unused)]
fn main() {
/// Monotonic counter, optionally labeled. Label sets are bounded and
/// known at construction time (see cardinality guardrail above).
pub struct Counter(AtomicU64);
pub struct LabeledCounter<const N: usize>([(&'static str, AtomicU64); N]);

/// Fixed-bucket histogram (standard Prometheus default boundaries),
/// implemented as one AtomicU64 per bucket plus sum/count — no locks,
/// no external crate.
pub struct Histogram<const B: usize> {
    bounds: [f64; B],
    buckets: [AtomicU64; B],
    sum_micros: AtomicU64,
    count: AtomicU64,
}
pub const DEFAULT_LATENCY_BUCKETS: [f64; 11] =
    [0.001, 0.005, 0.01, 0.025, 0.05, 0.1, 0.25, 0.5, 1.0, 2.5, 5.0];

/// Every subsystem contributes one function of this shape; `metrics_handler`
/// concatenates all of them into the scrape response, same as today.
pub trait PrometheusText {
    fn format_prometheus_text(&self) -> String;
}
}

Each subsystem keeps owning its counters/histograms (same pattern as AuditDispatcher owning dropped_count/events_total today) and implements PrometheusText; metrics_handler is extended to concatenate each subsystem’s output, same as it already concatenates audit + auth-plugin output.

Metrics catalog

Metrics are grouped by subsystem. Each entry: name, type, labels, purpose. This is the target catalog — implementation lands incrementally per subsystem in follow-up PRs (mirroring ADR 0023’s phasing), not all at once.

HTTP / API layer

MetricTypeLabelsPurpose
keystone_http_requests_totalcountermethod, route, statusRequest volume by route/outcome
keystone_http_request_duration_secondshistogrammethod, routeRequest latency
keystone_http_requests_in_flightgaugeinterface (public/internal/admin/metrics)Concurrency per listener

route is the Axum MatchedPath template, never the raw path.

Authentication

MetricTypeLabelsPurpose
keystone_auth_attempts_totalcountermethod, outcome (success/failure)Auth volume by method
keystone_auth_duration_secondshistogrammethodAuth latency
keystone_auth_failures_totalcountermethod, reasonFailure breakdown, reusing sanitize_authentication_error’s variant names
keystone_auth_lockouts_totalcounter—PCI-DSS account lockouts (ADR 0010)
keystone_auth_plugin_load_failurecounterplugin_name(existing, ADR 0025 §5)
keystone_auth_plugin_invocations_totalcounterplugin_name, outcomeWASM plugin call volume
keystone_auth_plugin_duration_secondshistogramplugin_nameWASM plugin call latency

method values: password, token, application_credential, ec2, federation, oauth2, passkey, k8s, api_key.

Tokens

MetricTypeLabelsPurpose
keystone_token_issued_totalcounterdriver (fernet/jws), methodIssuance volume
keystone_token_validated_totalcounterdriver, outcomeValidation volume/outcome
keystone_token_validation_duration_secondshistogramdriverValidation latency
keystone_token_revoked_totalcounterreason (user_request/admin/cascade/expired_trust)Revocation volume

reason="cascade" counts revocation events created as a side effect of revoking a role assignment. Only user assignments cascade; revoking a group assignment creates no revocation event (effective roles are recalculated on every token validation, see ADR 9) and therefore does not increment the counter. | keystone_token_revocation_list_size | gauge | — | In-memory/DB revocation-event backlog |

OAuth2 provider

MetricTypeLabelsPurpose
keystone_oauth2_token_requests_totalcounterdomain_id, grant_type, outcome (success/invalid_client/invalid_grant/invalid_scope/rate_limited/error)/token volume and outcome; domain_id is unknown unless the request succeeded, since the path segment is unauthenticated
keystone_oauth2_token_issue_duration_secondshistogramgrant_type/token latency
keystone_oauth2_refresh_reuse_detected_totalcounterdomain_id, client_idRefresh token reuse (breach signal)
keystone_oauth2_device_polls_totalcounteroutcome (pending/slow_down/authorized/denied/expired/invalid)Device code polls
keystone_oauth2_device_grants_started_totalcounter—Device grants started
keystone_oauth2_signing_key_rotations_totalcounterdomain_id, kind (normal/emergency/local_emergency/automatic)Key rotations
keystone_oauth2_signing_key_age_secondsgaugedomain_idAge of the primary signing key
keystone_oauth2_janitor_purged_totalcounterkindRecords purged or keys retired
keystone_oauth2_janitor_errors_totalcounter—Janitor errors
keystone_oauth2_janitor_last_run_timestamp_secondsgaugejanitorLast janitor sweep (key, session)
keystone_oauth2_revocations_totalcounterreasonRefresh token families revoked by operators (operator) and client revocation (client_revoked)

keystone_oauth2_authorize_requests_total and keystone_oauth2_login_attempts_total are registered but not yet incremented. user_id is never a label.

Policy (OPA)

MetricTypeLabelsPurpose
keystone_policy_decisions_totalcounteroutcome (allow/deny/error)Decision volume
keystone_policy_decision_duration_secondshistogramtransport (http/wasm)OPA round-trip latency
keystone_policy_errors_totalcountertransportOPA unreachable/malformed-response errors

No policy_name/action label — the resource/action pairs come from request content passed through PolicyEvaluationResult, and while the API surface is finite, matching ADR 0023’s minimization posture, decision volume/latency is tracked in aggregate rather than per-rule.

Rate limiting (ADR 0022)

MetricTypeLabelsPurpose
keystone_rate_limit_evaluations_totalcounterscope, outcome (allowed/rejected)Evaluation volume
keystone_rate_limit_rejections_totalcounterscope429s issued

scope values: per_ip, per_user, global, auth_endpoint — the fixed set of limiter scopes defined by ADR 0022’s config.

Audit (ADR 0023) — existing

keystone_audit_dropped_total, keystone_audit_postaudit_dropped_total, keystone_audit_events_total — unchanged, documented here for completeness of the catalog.

Domain providers (identity, resource, role, assignment, catalog,

credential, appcred, trust, federation, idmapping, k8s-auth, token-restriction)

MetricTypeLabelsPurpose
keystone_provider_operations_totalcounterdomain, operation, outcomeCRUD volume per backend trait call
keystone_provider_operation_duration_secondshistogramdomain, operationBackend latency (DB or Raft)

operation values follow the backend trait convention in crates/core/src/backend.rs (create/get/list/update/delete, plus enable/disable/authenticate/revoke per map_event_to_action in ADR 0023); domain is the crate name (identity, catalog, role, assignment, credential, appcred, trust, federation, idmapping, k8s_auth, token_restriction).

Raft / distributed storage (ADR 0016-v2)

MetricTypeLabelsPurpose
keystone_raft_is_leadergauge (0/1)—Leadership state per node
keystone_raft_termgauge—Current Raft term
keystone_raft_last_log_indexgauge—Log tail position
keystone_raft_last_applied_indexgauge—State-machine apply position
keystone_raft_replication_laggaugepeer_idlast_log_index - peer's match_index; peer_id is a small, config-fixed cluster member set
keystone_raft_apply_duration_secondshistogram—State-machine apply latency

Federation / mapping (ADR 0007, 0013, 0020)

MetricTypeLabelsPurpose
keystone_federation_authentications_totalcounteridp_id, outcomeFederated auth volume — idp_id is operator-configured, bounded by deployment
keystone_mapping_evaluations_totalcounteroutcomeMapping-engine evaluation volume
keystone_mapping_evaluation_duration_secondshistogram—Mapping-engine latency

SCIM / API keys (ADR 0021, 0024)

MetricTypeLabelsPurpose
keystone_scim_operations_totalcounterresource_type, operation, outcomeSCIM provisioning volume
keystone_api_key_authentications_totalcounteroutcomeAPI-key auth volume

OAuth2 / OIDC (ADR 0026, ADR 0028)

MetricTypeLabelsPurpose
keystone_oauth2_grants_totalcountergrant_type, outcomeGrant issuance volume
keystone_oauth2_key_rotations_totalcountertrigger (scheduled/emergency)Signing-key rotations, including ADR 0028 quorum-bypass emergency rotations
keystone_oauth2_jwks_requests_totalcounter—JWKS endpoint hits

WebAuthn / Passkey (ADR 0005)

MetricTypeLabelsPurpose
keystone_webauthn_ceremonies_totalcounterceremony (registration/authentication), outcomePasskey ceremony volume

Per-request cache (ADR 0030)

MetricTypeLabelsPurpose
keystone_cache_hits_totalcountercacheCache effectiveness
keystone_cache_misses_totalcountercacheCache effectiveness

cache is the fixed, code-defined set of tokio::task_local! caches ADR 0030 introduces (e.g. role_assignments, catalog_endpoints), not per-request data.

Process / runtime metrics — out of scope

Standard process metrics (process_cpu_seconds_total, process_resident_memory_bytes, open file descriptors, Tokio runtime task counts) are deliberately not part of this catalog. They are either already provided by the container/node exporter (node_exporter, cgroup metrics from the orchestrator) or, for Tokio-internal metrics, require tokio-console/tokio-metrics’ unstable runtime introspection APIs, which is a separate decision outside this ADR’s scope.

Rollout

Implementation proceeds subsystem-by-subsystem, each its own PR, in this order (highest operational value first): HTTP layer → authentication/token → policy → rate limiting → Raft → domain providers → the remaining subsystems. Each PR adds its subsystem’s counters/histograms, wires them into metrics_handler, and ships matching alert rules in deploy/prometheus/alert_rules.yaml alongside the existing keystone_audit group, following that file’s existing format.

Consequences

  • Operability: Operators gain Prometheus-native visibility into auth failure rates, token backend health, OPA latency, rate-limit pressure, and Raft cluster health, closing the gap where only audit-spool health and auth-plugin load failures were observable.
  • Consistency: A shared Counter/LabeledCounter/Histogram module replaces three near-duplicate hand-rolled formatters (audit, auth-plugin, and future ones) with one, keeping the “no external metrics crate” property while removing per-crate reimplementation.
  • Security: The cardinality/PII guardrail is a hard requirement for every future metric — it prevents both unbounded memory growth (an attacker-controlled label value is a cardinality-explosion DoS vector against the metrics registry) and the same class of identifier leakage ADR 0023 already forbids in audit events.
  • Compatibility: The four existing metric names are unchanged; nothing currently scraping /metrics breaks.
  • Cost: Incremental instrumentation work across most crates in the workspace; explicitly phased rather than a single large change.
  • 0022: Rate limiting — keystone_rate_limit_* scopes match its limiter configuration.
  • 0023: Audit — existing metrics, PII-minimization precedent the cardinality guardrail extends, and the /metrics endpoint this ADR builds on.
  • 0025: Dynamic auth plugins — existing keystone_auth_plugin_load_failure metric, keystone_auth_plugin_* additions.
  • 0016-v2: Raft storage — source of the keystone_raft_* metrics.
  • 0030: Per-request cache — source of the keystone_cache_* metrics.

Alternatives

  1. Adopt the metrics + metrics-exporter-prometheus crates: Rejected for now — the existing hand-rolled approach was a deliberate choice (per crates/cadf/src/metrics.rs) to avoid the dependency, the full catalog here is a fixed, compile-time-known set of series (no dynamic registration need that would justify a registry crate), and a shared internal primitives module gets the same de-duplication benefit without the new dependency. Revisit if a future subsystem needs genuinely dynamic label sets a const-sized LabeledCounter can’t express.
  2. Per-request-ID or per-project labels for finer-grained dashboards: Rejected — unbounded cardinality risk and conflicts with ADR 0023’s PII minimization; use structured logs/audit events (already correlated via correlation_id) for per-request/per-project drill-down instead of metrics.
  3. Expose /metrics only behind authentication: Rejected, consistent with ADR 0023’s existing posture — the metrics/health interface is already a separate listener (interface_metrics) intended to be firewalled to internal scrapers rather than authenticated per-request.

32. Vendor Data JWT Attestation (SPIRE Integration Plan, Phase 2)

Date: 2026-09-02

Status

Proposed

Reference

Phase 2 of doc/plans/spire-integration.md. Depends on Phase 1 (SPIRE DevStack plugin, merged) for the per-host nova-compute SPIFFE identity this endpoint authenticates its caller with, and on a minimal slice of Phase 3 (the internal SPIFFE mTLS interface, already wired to the REST router) for the interface this endpoint lives on.


1. Context

Nova injects a keystone-rs-signed JWT into a VM’s vendor_data.json at boot. A (future, Phase 5) custom SPIRE node attestor plugin running inside the VM reads that JWT and presents it to the SPIRE server, which verifies the signature and mints a project/instance-scoped SPIFFE SVID for the VM. This ADR covers the keystone-rs side of that hand-off: the endpoint that signs the JWT, and the key it is signed with.

2. Decision

  • POST /v4/vendordata (internal SPIFFE mTLS interface, port 8444) signs a short-lived (60-600s, default 300s) attestation JWT scoped to a project_id/instance_id pair, requested by Nova-compute on the vendor_data_url boot hook.
  • GET /v4/spiffe/{domain_id}/jwks publishes the public half of the signing key, unauthenticated, for the (Phase 5) SPIRE server plugin to fetch and cache.
  • Attestation key isolation: the signing key is a second, independent per-domain asymmetric key (openstack_keystone_core::spiffe_key), never the OAuth2 access-token signing key (openstack_keystone_core::oauth2_key, ADR 0026 §3) and never published on the OAuth2 JWKS endpoint. An earlier draft considered publishing both keys in one JWKS set, distinguished by a use metadata tag; that was rejected because it would make a JWK metadata field (per RFC 7517, meant to distinguish signature vs. encryption keys, not application purpose) the actual security boundary between “can forge API tokens” and “can mint VM identities” – a validator that mishandles use would silently accept the wrong key. Endpoint separation removes that failure mode: the SPIRE server plugin only ever fetches /v4/spiffe/{domain}/jwks and can never be tricked into trusting the OAuth2 key.
  • Ownership verification (Fix 1): before signing, and only when [vendordata] verify_placement is true (the default), the handler cross-checks the caller’s claimed project_id/instance_id against Nova’s own record for that instance, using keystone-rs’s own admin-scoped Nova credentials. A mismatch is 403; a Nova-side error or timeout is 503 (fail closed – never a signed JWT on an unverifiable claim). Without this check, any compute host’s SPIFFE SVID could request a signed attestation for an instance it does not run, since the SVID alone doesn’t bind the caller to a specific host/instance pairing.

3. Key design decisions

  • aud is fixed to spiffe-attestation, preventing reuse of an attestation JWT for any other purpose.
  • jti is a freshly generated random identifier per issued JWT, never instance_id – a legitimate re-attestation (VM rebuild) must produce a different jti from the original boot so the (Phase 5) SPIRE server plugin’s anti-replay cache can tell fresh re-attestation apart from true replay.
  • ttl_seconds is capped at [oauth2] access_token_lifetime_minutes as a safety bound, regardless of what the request asks for.
  • The endpoint lives on the internal SPIFFE mTLS interface because Nova-compute authenticates with its per-host SPIFFE SVID (spiffe://{trust_domain}/service/nova-compute/host/{hostname}, Phase
    1. – there is no other credential Nova-compute could plausibly present.

4. Known gaps and deviations from the plan doc (Phase 2 scope)

This phase intentionally trades some fidelity to the plan doc’s original design for a smaller, self-contained first implementation. Each item below is a deliberate, flagged simplification – not an oversight – and a natural next increment:

  • Attestation key storage is filesystem-backed, not Raft-backed. [oauth2]’s signing key (ADR 0026 §3) is Raft + FjallDB replicated for HA; the attestation key reuses the same generic AsymmetricKeyRepository/KeyMaterial shape from key-repository, but through FilesystemAsymmetricKeySource (the same source the Phase 0 JWS token provider already uses in single-node deployments) rather than a new Raft-backed backend. A single-node keystone-rs deployment works unchanged; multi-node HA needs the same Raft generalization [oauth2] already has, tracked as follow-up work, not part of this ADR.
  • No emergency rotation or JTI revocation for the attestation key. [oauth2]’s signing key has a two-phase emergency rotation and JTI revocation list (ADR 0026 §3, §11); the attestation key’s provider trait (SpiffeKeyApi) is deliberately smaller – ensure_domain_keys, jwks, active_signing_key only. Attestation JWTs are already short-lived (max 600s) and carry no long-lived session state to revoke, so the operational need is smaller than for OAuth2 access tokens; if it grows, the same rotation machinery [oauth2] uses can be generalized to a second purpose.
  • Nova ownership verification mints its own token per call (resolved: originally a static, operator-provisioned admin token). [vendordata] nova_auth_scope (project or system) and nova_auth_roles (role names) configure the scope and effective roles of a token keystone-rs mints for itself on every ownership check, using the same “virtual identity with pre-populated roles” mechanism the SPIFFE/mapping engine (ADR 0020) uses for externally authenticated identities – keystone-rs still has no real user account or role assignment for this identity, so the configured role names are set directly as the token’s effective roles rather than resolved from an assignment lookup (each name must resolve to an existing role, or the check fails closed with 503). This removes the long-lived static credential; nothing left to rotate manually.
  • The compute host is derived directly from the raw SpiffeId request extension, not through Phase 3’s generic SPIFFE claim-flattening (spiffe.host in crates/core/src/api/auth.rs) and the ADR 0020 mapping engine. This endpoint parses spiffe://{trust_domain}/service/nova-compute/host/{hostname} itself, with the same strict path-segment validation the plan doc specifies (reject trailing slashes, extra segments, and any URI that doesn’t match exactly) – but that claim is not yet available to OPA policy or any other handler. Generalizing it into the mapping engine’s claim flattening remains Phase 3 proper.

5. Security Considerations

See doc/plans/spire-integration.md’s “Security Considerations” table for the full threat model this phase participates in (“Compute-host impersonation / unauthorized attestation minting”, “Attestation/OAuth2 key blast-radius coupling”). The devstack join-token registration path from Phase 1 does not itself prove per-host identity the way x509pop would in production – this endpoint’s ownership check narrows, but does not eliminate, that gap in a devstack deployment.

6. Consequences

Positive

  • A key compromise no longer grants both API-token forgery and VM identity minting (separate keys, separate endpoints).
  • A compromised or malicious compute host cannot mint attestations for instances it does not run, when verify_placement is enabled.
  • Reuses the existing generic asymmetric key-repository machinery rather than inventing a new key storage format.

Negative / Risks

  • The filesystem-backed attestation key does not survive a single-node failure the way the Raft-backed OAuth2 key does; multi-node HA for this key is not yet implemented (see §4).
  • nova_auth_roles is configured with broad (cross-project) read access via os-extended-server-attributes in mind; misconfiguring it with an over-broad role name grants every self-minted Nova-calling token that same access, so treat the role list with the same operational care as a policy file, not as an inert config value.

33. OpenFGA Assignment Driver

Date: 2026-09-04

Status

Accepted. Implemented in crates/assignment-driver-openfga (openstack-keystone-assignment-driver-openfga), selectable via [assignment] driver = "openfga" plus an [openfga] config section (crates/config/src/assignment.rs, OpenFGAAssignmentDriver). Linked into the keystone binary only when the openfga cargo feature is enabled.

Context

Keystone’s role assignments (who has which role on which project / domain / system) are a natural relationship graph. OpenFGA is a Zanzibar-style relationship-based authorization store that models exactly this kind of graph and answers “does user U have relation R on object O?” with group expansion and userset rewrites built in.

Operators already running OpenFGA as their central authorization service want Keystone role assignments to live there too, so that a single authorization model governs both OpenStack and non-OpenStack resources, and so that group membership and role implication are resolved once, by the model, rather than separately in each consumer.

The existing SQL driver (crates/assignment-driver-sql) stores assignments in local tables and expands inheritance / group membership itself. The OpenFGA driver is an alternative AssignmentBackend that delegates that expansion to the relationship store.

Impedance mismatch

The AssignmentBackend trait (check_grant, create_grant, list_assignments, revoke_grant) is shaped by the SQL model. OpenFGA differs in ways this ADR has to reconcile:

  • OpenFGA has no notion of a Keystone entity id. A mapping from (kind, keystone_id) to an OpenFGA object string (type:id) is needed, and it must be reversible for listing.
  • OpenFGA’s read (raw tuple listing) matches on a tuple filter; it cannot enumerate tuples by user alone. Only the model-resolving APIs (check, batch-check, list-objects) answer “everything U can do”.
  • OpenFGA stores one flat relationship tuple per grant. There is no column for Keystone’s inherited flag.
  • Group membership and implied roles are the model’s job, not the driver’s.

Decision

1. Crate structure

Follows the ADR-0018 naming convention: openstack-keystone-assignment-driver-openfga, with the usual #[allow(dead_code)] pub fn anchor() {} linker anchor and an inventory::submit! { BackendRegistration::<dyn AssignmentBackend> { name: "openfga", .. } } registration.

crates/assignment-driver-openfga/
  Cargo.toml
  src/
    lib.rs      # OpenFGADriver, AssignmentBackend impl, HTTP + retry + fan-out
    types.rs    # ObjectMapper, Kind, request/response structs, error enum
    tests.rs    # httpmock-based unit tests

2. Entity ↔ object mapping is pure config

ObjectMapper (in types.rs) is stateless and built entirely from the [openfga] section — no per-entity lookup table and no reverse-mapping store (Keystone’s id_mapping table is a public↔local routing table for a different purpose and is deliberately not reused).

Each Keystone kind (user, group, project, domain, system) has a list of OpenFGA type names. The first is canonical (used for writes); every entry is consulted on reads / checks / deletes, so a store that already holds tuples under an alternate type name (account: instead of user:, say) still resolves. An optional id_transform (uuid-dashes) inserts/strips canonical UUID dashes, since Keystone stores dashless UUIDs and OpenFGA stores dashed.

The Keystone role id ↔ OpenFGA relation name mapping is an explicit role_to_relation table in config. An unmapped role id fails fast with RoleRelationNotConfigured.

3. effective selects the API family

list_assignments branches on RoleAssignmentListParameters::effective:

Query shapeeffective == Some(true)otherwise
actor + target + rolecheck (model-resolved)read of the exact tuple
actor + target, no rolebatch-check over every role relationread {user,object}, map relations back to roles
actor, no targetstreamed-list-objects fan-out501 — ListingActorWithoutScopeRequiresEffective
no actor, target (± role)read (see below)read
no actor, role only, no target501 — ListingAssignmentsByRoleNotSupported501 — ListingAssignmentsByRoleNotSupported
no actor, no target, no role501 — ListingAllAssignmentsNotSupported501 — ListingAllAssignmentsNotSupported

resolve_implied_roles has no independent effect; implication is a model rewrite and therefore only visible in effective mode. A debug! records this when the flag is set outside effective mode. Every assignment this driver returns — direct or effective — is reported with inherited: false and implied_via: None: the driver has no way to tell a directly-stored tuple from one the model resolved via inheritance or implication. See the Negative consequences.

A target-scoped listing always uses read even in effective mode: this driver does not call list-users, so group-derived grants on the target are not resolved. A debug! records the limitation when effective mode is asked for there. This read also never walks the Keystone project tree, in either mode: a project-scoped listing (domain_id/system_id have no tree to walk) only ever sees tuples stored directly on that project. Unlike the SQL driver — which adds each ancestor project as an inherited = true target via get_project_parents regardless of effective — a grant made on a parent project is invisible here even in non-effective mode. Project-tree inheritance on this driver only exists to the extent the OpenFGA model encodes it as a rewrite (see §4 and the Negative consequences below).

check_grant always calls OpenFGA’s model-resolving check API — it has no effective parameter, so it cannot restrict itself to a direct tuple the way assignment-driver-sql’s check does. See the Negative consequences.

4. inherited: true is rejected on create

Because a grant is one tuple with no inherited marker, an inherited grant would be indistinguishable from a direct one on read. create_grant returns InheritedGrantsNotSupported, which maps to AssignmentProviderError::Validation → HTTP 400, rather than silently storing it as a direct grant. Project-tree inheritance must be expressed as a relation rewrite in the OpenFGA authorization model.

5. Revoke mirrors the SQL driver’s idempotence

revoke_grant probes each representation with a direct read and issues a delete only for the ones that actually hold the tuple. Revoking a grant that is not present is a no-op returning Ok(()), matching assignment-driver-sql. This also avoids OpenFGA’s “tuple not found” delete error. A real error from either the read or the delete propagates.

6. Listing has no server-side cursor; pagination is post-fetch

A single list_assignments call is a fan-out union across representations, target kinds and role relations, so no opaque cursor can be pushed into OpenFGA. Results are fully materialised, de-duplicated, sorted by the assignment pagination marker, then sliced (marker / limit / page_reverse), mirroring the SQL driver’s post-fetch pagination.

To keep individual OpenFGA calls from truncating silently:

  • read follows continuation_token to completion (bounded by MAX_READ_PAGES = 1000, with a warn! if hit).
  • object enumeration uses streamed-list-objects, not list-objects. list-objects caps its result at OPENFGA_LIST_OBJECTS_MAX_RESULTS (default 1000) with no continuation token, so a full page silently drops assignments. The streaming form is bounded only by OPENFGA_LIST_OBJECTS_DEADLINE; the driver parses its newline-delimited {"result":{"object":...}} frames.

7. Resiliency: configurable retry

send() wraps every OpenFGA call. Transient failures — connection errors, timeouts, HTTP 429, HTTP 5xx — are retried up to max_retries times (default 0, i.e. off) with exponential backoff from retry_backoff_ms (default 100). A 4xx other than 429 is returned immediately.

8. Concurrency: bounded fan-out

Every fan-out (over actor/target representations, target kinds, role relations) issues its OpenFGA requests concurrently via buffer_unordered, capped at max_concurrency (default 10). read continuation and batch-check chunking stay sequential. max_concurrency = 1 forces fully serial calls without changing results.

9. No caching

check_grant / list_assignments results are not cached in the driver. OpenFGA is the source of truth and has its own consistency controls; a Keystone-side cache would add a staleness window on authorization decisions for no clear benefit at current call volumes. Revisit if profiling shows OpenFGA round-trips dominating auth latency.

10. Error mapping

OpenFGADriverError (in types.rs) converts to AssignmentProviderError: InheritedGrantsNotSupported → Validation (400); the three unsupported-listing variants (ListingActorWithoutScopeRequiresEffective, ListingAssignmentsByRoleNotSupported, ListingAllAssignmentsNotSupported) → AssignmentProviderError::NotImplemented → HTTP 501; everything else → Driver (500). The unsupported-listing shapes are a permanent property of the query (no target scope, only a role filter, or no filter at all), not a transient backend fault, so they are kept out of the 500 path: a 501 tells the caller “this driver can’t answer this”, where a 500 would read as a bug and page an operator. GET /v3/role_assignments reaches all three from public, policy-gated query parameters (role.id alone, or no parameters at all, or user.id/group.id without effective=true and without a target scope).

Consequences

Positive

  • A single authorization model spans OpenStack and non-OpenStack resources; group expansion and role implication are resolved once, by OpenFGA.
  • No new persistence: the driver is stateless, the mapping is config, and there is no schema to migrate.
  • Alternate type names and UUID-format differences are absorbed by config, so the driver can point at a pre-existing OpenFGA store.
  • Retry and concurrency are tunable per deployment; both default to conservative behaviour (retry off, modest parallelism).

Negative

  • Authorization-model coupling. The driver is only correct if the OpenFGA model encodes group membership (e.g. a member relation) and implied roles as rewrites. Nothing syncs Keystone identity group membership into OpenFGA — the deployment owns that. This is not limited to GET /v3/role_assignments: every scoped-token issue path (resolve_project_default_roles/resolve_domain_roles/resolve_system_roles in crates/core/src/auth.rs) calls list_assignments with effective = true, which on this driver is a batch-check against the model with no Keystone-side group expansion (the SQL driver expands list_groups_of_user itself before querying). A deployment that switches driver = "openfga" without first building that membership sync issues tokens missing every group-derived role, silently, from the first request onward.
  • Asymmetric listing. Actor-only listing needs effective = true; target-scoped listing never resolves group-derived grants; a non-effective project-scoped listing does not walk the project tree either (see the read-only target-scoped path in §3) — parent-project inherited = true grants that the SQL driver surfaces via get_project_parents are simply absent. Callers relying on the SQL driver’s fully-symmetric listing may see different results.
  • check_grant is always model-resolved. Unlike the SQL driver’s check (an exact match on actor/target/role and the inherited flag), this driver’s check_grant calls OpenFGA’s check API, so it also returns true for a grant reached via group membership, implication or inheritance, with no way to ask for “direct only” (the method takes no effective flag). Harmless today — check_grant has no production caller (the HEAD /v3/projects/{id}/users/{id}/roles/{id} handler goes through list_role_assignments instead) — but a future caller wiring check_grant directly will get a different answer per driver.
  • implied_via provenance is lost. Effective-mode results always report implied_via: None, even for a role the model only granted through implication. GET /v3/role_assignments?include_names therefore cannot show which grant implied which role on this driver, unlike the SQL driver.
  • No cursor. Large assignment sets are materialised in memory before pagination. Acceptable for realistic per-actor / per-target scopes; not for a hypothetical “all assignments” query (which is unsupported anyway).
  • Actor-only effective listing fans out per role. (actor, no target) in effective mode issues one streamed-list-objects graph walk per (representation, target kind, role relation) combination — roughly users × 3 × role count calls, each a full graph traversal, bounded only by max_concurrency in flight, not in total. GET /v3/auth/projects (an unscoped→scoped token flow step, e.g. Horizon’s project picker) takes this path. The SQL driver answers the equivalent query with two table scans. Acceptable at a handful of configured roles; worth profiling before pointing this driver at a large role_to_relation table.
  • inherited grants are unavailable through this driver. Deployments that depend on Keystone project-tree inheritance must model it in OpenFGA or stay on the SQL driver.
  • New dependency surface: reqwest to talk to OpenFGA, plus futures for the bounded fan-out.

Testing

crates/assignment-driver-openfga/src/tests.rs exercises every path against an httpmock OpenFGA: actor/target mapping and representation fan-out, the effective branch matrix, streamed object enumeration (multi-frame, no truncation), retry (retries a 503, does not retry a 400), idempotent revoke, inherited rejection, and post-fetch pagination. Config parsing is covered by openstack-keystone-config. A live-OpenFGA functional suite is reasonable follow-up work and is not part of this change. Also follow-up, not part of this change: profiling the actor-only effective fan-out call count (users × target kinds × role relations, see Negative consequences) against a realistic role_to_relation table size, and — if group-membership sync into OpenFGA gets built — an integration test asserting a scoped token actually carries a group-derived role end to end.

34. Per-Domain Assignment Drivers

Date: 2026-09-07

Status

Proposed.

Extends the domain configuration for the identity driver.

Context

A domain configuration already selects a domain’s identity backend. IdentityService holds every registered IdentityBackend by name plus a DomainConfigResolver, and driver_for resolves the domain’s stored identity/driver, dispatching to the named backend and falling back to the global [identity] driver when per-domain drivers are off, the domain is unknown, no driver is stored, or resolution fails.

The assignment provider has nothing equivalent. AssignmentService (crates/core/src/assignment/service.rs) holds exactly one backend_driver: Arc<dyn AssignmentBackend>, resolved once in AssignmentService::new from [assignment] driver. Since ADR 0033, a deployment must therefore choose sql or openfga for the entire cloud. There is no way to:

  • migrate one domain at a time onto OpenFGA and keep the rest on SQL;
  • let a single tenant whose authorization already lives in a central OpenFGA store keep it there while the rest of the cloud stays on local tables;
  • evaluate the OpenFGA driver against production traffic in one domain without betting every domain’s token issuance on it — a live risk, given ADR 0033’s warning that a deployment without a group-membership sync silently issues tokens missing every group-derived role.

Why the identity pattern does not transfer unchanged

An identity entity belongs to exactly one domain, so driver_for_user has one question to answer. A role assignment has two entities — an actor (user or group) and a target (project, domain or system) — and they need not share a domain: a user in domain A can hold a role on a project in domain B.

Dispatch must therefore pick one of them as the key, and the choice is not symmetric. That is the central decision of this ADR.

Two things a domain needs to move onto OpenFGA

Moving a domain off the global driver takes two distinct pieces of information:

  • a driver name — which registered backend serves the domain. Small, holds no secret, safe to change at runtime.
  • a driver configuration — for OpenFGA the store id, model id, API URL and bearer token; for SQL nothing beyond the global [database]. The endpoint and the bearer token are trust anchors for every authorization decision the domain makes.

This ADR routes the first through the domain configuration API and keeps the second in server configuration. §3 and §4 give the reasoning; the short version is that an API-writable driver configuration is a role-minting escalation (§6).

The relevant shape of the existing code

  • AssignmentApi (crates/core/src/assignment/provider_api.rs) has three methods: create_grant, list_role_assignments, revoke_grant. AssignmentBackend (crates/core/src/assignment/backend.rs) adds check_grant, which has no production caller.
  • AssignmentCreate and Assignment both carry target_id and an AssignmentType (UserProject, GroupDomain, UserSystem, …) that names the target kind. Both write paths thus always know their target.
  • RoleAssignmentListParameters carries user_id / group_id (actors) and domain_id / project_id / system_id (targets), all optional. A fully empty value is legal.
  • The DomainConfigResolver (crates/core/src/domain_config/resolver.rs) already exists on Provider, exposed as get_domain_config_resolver, and overlays the database source onto the file source (the database wins, option by option) into a raw DomainConfig. IdentityService builds its own instance, gated on [identity] domain_specific_drivers_enabled; DomainConfigResolver::new decides which of the two sources it holds by reading the [identity] switches directly. Per-domain assignment consults a resolver for a single value, assignment/driver.
  • Domain-configurable option names are whitelisted explicitly in crates/core-types/src/domain_config/option.rs — today only the identity and ldap groups.

Decision

1. Dispatch keys on the target, never on the actor

The driver serving an assignment is chosen from the target:

Target kindDomain used for dispatch
domainthe target domain itself
projectthe project’s domain_id, via get_project
systemnone — always the default driver

The decisive reason is not convenience but an invariant: the target-keyed partition is closed under Keystone’s assignment hierarchy. Every expansion a driver performs stays inside the target’s domain:

  • A project’s ancestors are always in the same domain as the project, so the SQL driver’s get_project_parents walk never leaves the domain.
  • A domain-level inherited: true grant applies only to that domain’s projects.
  • Implied-role expansion rewrites the role, not the target.

Actor-keyed dispatch would violate this. A cross-domain grant (actor in A, project in B) would be stored in A’s driver, but B’s project-tree walk and B’s target-scoped listings query B’s driver and would not find it. The assignment would be simultaneously present and absent depending on which side asked. Target-keyed dispatch has no such split: whoever asks about a target asks the one driver that owns it.

A second consequence follows for free: because the two write paths always carry target_id and AssignmentType, create_grant and revoke_grant dispatch unambiguously with no extra lookup beyond the project→domain resolution.

System-scoped assignments have no owning domain and stay on the default driver. This is deliberate rather than a fallback: system roles are the most privileged scope in the deployment, and keeping them out of any domain-selected backend bounds the blast radius of §6.

2. Per-provider dispatch; resolver sources gated in [domain_config]

Two orthogonal decisions, deliberately kept apart.

Per-provider dispatch. A new switch in crates/config/src/assignment.rs, domain_specific_drivers_enabled, defaulting to false, independent of [identity] domain_specific_drivers_enabled. An operator running per-domain LDAP identity must not silently acquire per-domain assignment routing — the two are unrelated concerns with very different security profiles, and an existing deployment that already sets the identity switch must not change behaviour on upgrade. When the switch is off, AssignmentService keeps a None resolver and every operation goes to the global driver, exactly as today.

Resolver sources. Which of the two sources a DomainConfigResolver holds — the fs source (per-domain files under [identity] domain_config_dir) and the sql source (the domain_config table) — is shared infrastructure, not a per-provider concern: both sources carry every group, and each consumer filters to its own (resolve_identity, resolve_assignment_driver_name). Today DomainConfigResolver::new reads the [identity] switches directly, which is why [assignment] domain_specific_drivers_enabled on its own would resolve every domain to the empty configuration unless an [identity] source were also enabled. This ADR moves that gate to its own section rather than leave the dependency implicit:

[domain_config]
from_files    = false  # hold the fs source
from_database = true   # hold the sql source

Config blocks in this ADR are rendered as TOML for readability. The primary keystone.conf is INI; there these are plain INI sections (the config crate splits dotted section headers, as [auth_plugin.<name>] already relies on), and a KEYSTONE_SITE_VARS_FILE overlay may equally be TOML. The same holds for every block below.

  • Both fields are Option<bool> internally. DomainConfigResolver::new reads the effective value from_files.unwrap_or([identity] domain_specific_drivers_enabled) and from_database.unwrap_or([identity] domain_configurations_from_database). A deployment that sets only the old [identity] keys is unchanged, defaults included.
  • When a [domain_config] key is set explicitly it wins; if a deprecated [identity] key is also set to a different value, construction logs one WARN naming the deprecated key and the winner.
  • [identity] domain_configurations_from_database becomes a deprecated alias for [domain_config] from_database. [identity] domain_specific_drivers_enabled is only partially superseded — it keeps its second job, turning on identity per-domain dispatch — so it is not deprecated, only its source-gating side effect moves. [identity] domain_config_dir (the fs driver’s path) stays in [identity]. Both are residual warts, smaller than the one they replace and now explicit.

Per-provider resolver instances. IdentityService keeps its own Option<Arc<DomainConfigResolver>>, built at startup and restart-only, gated on [identity] domain_specific_drivers_enabled. AssignmentService holds its own, inside the ArcSwap bundle of §9, gated on [assignment] domain_specific_drivers_enabled. Both instances read the same [domain_config] gate and wrap the same shared fs / sql backend Arcs — the split exists only so a §9 reload can swap assignment’s resolver without disturbing identity’s restart-only path. Provider::get_domain_config_resolver is retained, built from the same effective gate; removing it is not this ADR’s job.

The honest residual: source gating ([domain_config]) and dispatch ([assignment] domain_specific_drivers_enabled) are two knobs and both are required. That is strictly better than today’s silent dependence on an [identity] key, because the dependency now has its own name and its own documentation.

3. The domain configuration API carries a driver name, not a configuration

One new domain-config group, assignment, with exactly one option. In crates/core-types/src/domain_config/option.rs:

  • DomainConfigGroupName::Assignment, extending ALL, as_str and FromStr.
  • ASSIGNMENT_WHITELISTED_OPTIONS = &["driver"], ASSIGNMENT_SENSITIVE_OPTIONS = &[].

There is no openfga domain-config group. A domain’s stored configuration names the driver it wants — nothing else. list_limit is deliberately excluded: it is a per-provider tuning knob ([assignment] list_limit), not a per-domain concern, and a domain that raises it would shift load onto shared driver instances.

driver is validated at write time against the set of names the operator has made bindable (§4): sql, the driver of the global [assignment] section, and every driver named by an [assignment.backends.*] block. A write naming any other value — including openfga when the deployment configures no OpenFGA backend anywhere — is rejected by the config API, not stored and later ignored.

DomainConfig gains resolve_assignment_driver_name(&Config) -> Option<String> beside the existing resolve_identity / resolve_ldap. There is deliberately no resolve_openfga: the API layer never constructs a driver configuration.

Why the configuration stays out of the API. Per doc/src/contributor/security-model.md, a domain’s assignment driver configuration is a trust anchor for every token issued in that domain (§6). An API-writable configuration would:

  • carry the OpenFGA bearer token and any mTLS material through the config API and into the shared domain_config table, the Serialize / Debug redaction the only thing between it and a log line;
  • let whoever holds the config-API policy repoint a domain at an OpenFGA store they control and mint arbitrary roles there — the endpoint itself, not merely the choice among vetted endpoints, would be attacker-supplied;
  • add a per-option whitelist (fourteen [openfga] keys) that has to track the driver struct as it grows, each new key one review slip from being domain-writable.

Keeping the configuration in server config removes all three. The API caller chooses among backends the operator has provisioned; it cannot define one.

4. Per-domain driver parameters live in server configuration

crates/config/src/assignment.rs gains two maps: named backend blocks that each hold one driver configuration, and a domain → backend table.

[assignment]
driver = "sql"                       # global default; system + fallback
domain_specific_drivers_enabled = true

# A named driver configuration. Any number of domains may point at it.
[assignment.backends.central_fga]
driver   = "openfga"
api_url  = "https://openfga.internal:8080"
store_id = "01JQ..."
model_id = "01JQ..."
api_key  = "..."                     # secret; never leaves the host
# ... the full [openfga] option set, typed, no whitelist

# Map a domain to a backend block. No per-domain parameters.
[assignment.domains]
"1111...1111" = "central_fga"
"2222...2222" = "central_fga"

Rendered as TOML; in keystone.conf these are plain INI sections ([assignment.backends.central_fga], [assignment.domains]), as noted in §2.

  • Both maps are typed in crates/config like the rest of the file. There is no whitelist and no redaction plumbing because they never cross a process boundary.
  • Multiple domains share one backend by naming it. Two domains mapped to central_fga resolve to the same AssignmentBackend instance — one OpenFGA client, one connection pool — with no configuration repeated. Instances are keyed by backend name, not by domain.
  • A backend block’s driver must equal the mapped domains’ API-stored assignment/driver binding (§3). A sql backend block is allowed but rarely needed: an SQL-backed domain with no [assignment.domains] entry already shares the global [database] instance (§7).
  • The global [assignment] driver — and, for openfga, the global [openfga] section — still serves system targets, unconfigured domains, and every resolution fallback. A domain that wants the global OpenFGA store binds openfga with no [assignment.domains] entry and shares the default instance; nothing is repeated for that case either.

AssignmentService therefore keeps, in addition to the name→backend registry that plugin_manager provides:

  • a map of backend name → resolved Arc<dyn AssignmentBackend>, built from the [assignment.backends.<name>] block the first time a bound domain resolves to it and reused by every other domain that names it;
  • the global driver, used for system targets, for domains with no binding or no mapping, and as the fallback whenever resolution fails.

Joining the binding to the parameters. Resolution for a domain is:

  1. Resolve assignment/driver through the DomainConfigResolver (§3). Absent → global driver.
  2. Name present → look up the domain in [assignment.domains].
    • Mapped to a backend whose block exists and whose driver equals the bound name → build or reuse that backend instance, shared with every other domain mapped to the same name.
    • Not mapped, and the bound name equals the global [assignment] driver → the global instance.
  3. Mapped to a missing or driver-mismatched backend, or bound to a non-global name with no mapping → fall back to the global driver and log a WARN. This is the stale-binding case: an operator removed or renamed a backend block while a domain’s API-stored binding still names openfga. The write-time check of §3 stops the common case at the source; the resolve-time fallback keeps a bad edit from failing token issuance.

5. Untargeted requests fan out across every active driver and union

Four production paths reach the provider without a target:

  • crates/keystone/src/api/v3/auth/project/list.rs — GET /v3/auth/projects passes user_id and effective: true and discovers the projects; by definition it has no target.
  • crates/keystone/src/api/v4/auth_plugin/identity_link/mod.rs (target_holds_system_role) — user_id and effective: true, filtering the result for system assignments. This one is avoidable: it could set system_id and take the default driver directly, since system is a singleton target, and the implementation should do so.
  • crates/keystone/src/scim/group/delete.rs — lists a group’s assignments by group_id alone and revokes each one.
  • crates/keystone/src/api/v3/role_assignment/list.rs — GET /v3/role_assignments with entirely caller-supplied filters, which may carry neither actor nor target.

These query every active driver instance (the global driver plus each distinct per-domain backend instance) and union the results.

Serving them from the default driver alone — the obvious cheap answer — is rejected on two grounds. GET /v3/auth/projects would omit a user’s projects in any OpenFGA-backed domain, breaking the unscoped→scoped token flow that Horizon’s project picker depends on. Worse, SCIM group deletion would revoke only the grants held in the default driver and silently leave the rest standing: a group that looks deleted but still grants roles is exactly the escalation path that code path exists to close.

Three sub-decisions follow.

The fan-out set. The active set is the global driver plus one instance per distinct [assignment.backends.*] block that a bound domain maps to (§4). The bindings come from the domain-config sources — the fs source holds them all in memory from startup, the sql source needs a “list domains that set assignment/driver” query on DomainConfigBackend — while the backend blocks and the [assignment.domains] table are already in memory. Several domains mapped to one backend contribute a single instance and a single fan-out query. The set is warmed at startup, refreshed when a binding is written through the config API, and rebuilt on a configuration reload (§9).

Merging. Results are concatenated, de-duplicated, then re-sorted and sliced by Assignment::pagination_marker, so marker / limit / page_reverse apply to the union rather than per driver. This is the same post-fetch pagination both existing drivers already perform (ADR 0029, ADR 0033 §6), lifted one level up.

Partial failure fails the request. Any error from any driver in the fan-out

  • including the NotImplemented / HTTP 501 shapes OpenFGA returns for unsupported listing shapes (ADR 0033 §10) - fails the whole call. The alternative, skipping the failing driver with a warning, silently shrinks a listing that a caller may be using to revoke grants. A short, honest 501 is better than a long answer that is quietly missing rows. The cost is stated in the Negative consequences.

6. The assignment group is cloud-admin only

Per doc/src/contributor/security-model.md, this is the load-bearing policy requirement of the design.

Token issuance resolves a scope’s roles through the target’s driver: resolve_domain_roles, resolve_project_default_roles, resolve_project_roles and resolve_trust_roles in crates/core/src/auth.rs all call list_role_assignments with a target and effective: true. A principal who can write a domain’s assignment/driver moves that domain’s role resolution to a different backend.

§3 and §4 already bound what that backend can be: sql, the global [openfga] store, or a named [assignment.backends.*] block the operator mapped to the domain — never an endpoint the principal supplies. The residual privilege is still real: flipping the binding activates whatever backend the operator has staged for that domain, or the global OpenFGA store, and changes which backend resolves the domain’s roles. The domain configuration API policy must therefore restrict the assignment group to cloud administrators, even where a domain administrator may write that domain’s identity and ldap groups. The policy rules must key on the authentication chain, as every other authorization decision does, and must not be satisfiable by a domain-scoped token.

The system carve-out of §1 bounds the residual exposure: even a misconfiguration here cannot manufacture system-scoped roles, so it cannot reach the rest of the cloud.

7. No single-domain registration lock for assignment/sql

Identity needs one — DomainConfigService::claim_sql_registration lets a single domain claim identity/driver = sql, because the SQL identity tables cannot represent more than one domain’s users.

Assignment rows carry globally unique actor and target ids and are already partitioned by target, so any number of domains can share the SQL assignment driver without collision. The omission of a lock is therefore deliberate, and is recorded here so that it is not later read as an oversight in the identity parity. It also means an SQL-backed domain needs no [assignment.domains] entry at all (§4).

8. Caching and invalidation

The resolved domain_id → driver-name binding is cached in an RwLock<HashMap<..>>, mirroring IdentityService::resolved_driver_cache. The backend parameters are not cached at this layer — they are read from ConfigManager’s live configuration and refreshed on its reload path (§9) - and backend instances are keyed by backend name, so N domains mapped to one backend hold one entry. Whether the resolver has a source at all is likewise read from live configuration: the [domain_config] gate of §2 (with its [identity] fallback) is re-evaluated on the same reload path.

Unlike identity, the binding cache is not invalidation-free: the config API writer clears it in its own process, and a configuration reload rebuilds it (§9). What is not covered is a database-sourced binding write on another node, which that node observes only after its own reload signal or a restart. Because only the binding — a driver name — is database-sourced, that gap can move a domain between already-configured backends; it can never carry an endpoint or a secret across nodes.

The fan-out set of §5 must be refreshed on the same events, since it changes whenever a domain gains or loses an assignment/driver binding or the [assignment.domains] / [assignment.backends.*] configuration changes. The config API writer invalidates the binding cache and the fan-out set in its own process on every write; a configuration reload invalidates them too and additionally rebuilds the resolver and the per-backend driver instances (§9).

9. Configuration reload rebuilds the resolver, drivers and caches

Keystone reloads its configuration in place, and provider subsystems react through ConfigManager::notify_tx: the dummy-hash cache, the rate limiters and the database connection each have a reactor in crates/keystone/src/server/startup/background.rs. The per-domain assignment state must react the same way, because every input it derives from can change across a reload:

  • [assignment] domain_specific_drivers_enabled — dispatch may switch on or off — and the [domain_config] source keys of §2 (with their [identity] fallbacks) — the resolver may gain or lose a source.
  • the [assignment.backends.*] blocks and the [assignment.domains] table — a backend’s parameters, a whole backend, or a domain’s mapping may be added, edited or removed.
  • the fs domain-config directory — a domain’s stored assignment/driver binding file may be added, edited or removed on disk.
  • the global [assignment] section — the driver every unconfigured domain and every system target inherits.

A new reload_assignment_drivers_on_config_change reactor, registered beside the others in background.rs, subscribes to notify_tx and on each notification (or a lagged receiver) performs, against the current configuration:

  1. Resolver. Rebuild AssignmentService’s DomainConfigResolver from the current effective [domain_config] gate — None when the dispatch switch is now off, sourceless when both [domain_config] keys resolve off — so the file source re-reads the config directory. Identity’s own resolver instance is not touched (§2).
  2. Binding cache. Drop the resolved domain_id → driver-name map of §8 in full.
  3. Per-backend instances. Resolve the set of (bound name, mapped backend block) pairs the current configuration produces and diff it by backend identity against the live instance map: build and swap in an instance for a backend that is new or whose parameters changed, drop an instance no bound domain maps to any more, and leave an unchanged backend’s instance — and its connection pool — untouched. Editing one [assignment.backends.*] block rebuilds exactly that instance; every domain mapped to it picks up the new instance on its next resolve.
  4. Fan-out set. Recompute the §5 set from the rebuilt inputs.

This requires the resolver, the binding cache, the instance map and the fan-out set to sit behind interior mutability on AssignmentService — one ArcSwap over an immutable bundle is enough and keeps readers lock-free — where today backend_driver is a plain Arc.

Failure is last-known-good, matching the rate-limiter reactor: a reload whose new configuration does not resolve is logged and the previous bundle is retained, rather than tearing running drivers down on a bad edit.

This closes the in-process half of the §8 invalidation gap for file-sourced bindings and for every driver parameter, and goes one step beyond the identity path, which still needs a restart to pick up a changed identity/driver. Bringing the identity service onto the same reactor is reasonable follow-up work but is out of scope here.

Consequences

Positive

  • A domain can be migrated onto OpenFGA on its own, so ADR 0033’s driver can be exercised against real traffic without wagering every domain’s token issuance on the deployment having built a group-membership sync.
  • Driver endpoints and the OpenFGA bearer token never cross the API boundary or land in the shared domain_config table: the API caller selects among backends the operator has provisioned on the host, it cannot define one (§3).
  • Any number of domains share one OpenFGA backend — one client, one connection pool, one fan-out query — by mapping to a named [assignment.backends.*] block, with no driver configuration repeated per domain (§4).
  • The new domain-config surface is a single option, assignment/driver. There is no per-[openfga]-option whitelist to keep in sync with the driver struct.
  • Target-keyed dispatch is closed under project-tree inheritance and domain-level inherited grants, so no driver ever has to answer a question about a target it does not own.
  • Both write paths dispatch from data they already carry; no new field on AssignmentCreate or Assignment is needed.
  • System-scoped assignments are structurally excluded from domain-selected backends, so the most privileged scope keeps a single, operator-controlled source of truth.
  • The resolution layer, the config API, the whitelist mechanism and the sensitive-option redaction are reused unchanged; the new surface is one group variant, one resolve_* method, the two config maps and the dispatch.
  • No persistence change and no migration: an existing deployment with the switch off behaves exactly as it does today.
  • The resolver’s source gating is a named [domain_config] concern rather than a side effect of an [identity] switch (§2). Enabling per-domain assignment drivers takes [assignment] domain_specific_drivers_enabled plus a [domain_config] source; no [identity] key is involved, and existing deployments keep working through the [identity] fallback.
  • A configuration reload picks up a changed switch, a changed global [assignment] section, and an added, edited or removed backend block, domain mapping or domain binding file, without a restart (§9) — a capability the identity path does not yet have.

Negative

  • Onboarding a domain onto a named OpenFGA backend is a two-role operation. The operator adds or reuses an [assignment.backends.*] block, adds an [assignment.domains] mapping and reloads; a cloud administrator then writes the assignment/driver binding through the API. A binding with no matching mapping is inert — it falls back to the global driver with a WARN (§4) — so the two steps must agree. This is the deliberate cost of keeping the configuration off the API. (Binding a domain to the global OpenFGA store needs no config edit — just the API write.)
  • Divergence from the identity per-domain model. Issue #1202 puts a domain’s identity driver configuration through the config API; this ADR keeps the assignment driver configuration in server config. Until identity follows, an operator meets two different mental models for two adjacent features. The security asymmetry — an assignment backend mints roles cloud-wide through token issuance — is the justification, recorded in §3.
  • An untargeted listing is as weak as the weakest driver. With one OpenFGA-backed domain, an unfiltered GET /v3/role_assignments, a role-only listing, and any actor-only non-effective listing return 501 for the whole cloud, because the fan-out propagates the driver’s unsupported-shape error (§5). Notably the SCIM group-delete path (crates/keystone/src/scim/group/delete.rs) lists by group_id with effective unset, which is precisely that shape — SCIM group deletion breaks against an OpenFGA-backed domain until that call site sets effective, and this should be fixed as part of the implementation rather than left to be discovered in production.
  • Untargeted calls cost O(distinct active backends). GET /v3/auth/projects is on this path and is already the most expensive query on the OpenFGA driver (ADR 0033 lists it as a users × target kinds × role relations graph-walk fan-out). Fanning it out multiplies that — once per distinct backend, not once per domain, since domains sharing a backend share its query. Worth profiling before enabling per-domain drivers on a cloud with many distinct OpenFGA backends.
  • Driver instances stop being singletons. Named [assignment.backends.*] blocks mean an instance map keyed by backend name, lazily built, with its own lifecycle — a shared store is still one instance, but this is a departure from the one-instance-per-driver-name registry every other backend uses, and a new place for connection pools to accumulate.
  • A new privilege boundary to get right. §6 is a policy requirement, not something the type system enforces. A policy file that lets a domain admin write the assignment group binds that domain’s role resolution to another backend. This must be covered by an explicit policy test, not left to review.
  • Source gating and dispatch are separate knobs. [domain_config] decides whether the resolver has a source; [assignment] domain_specific_drivers_enabled decides whether assignment dispatches per domain. Both are required, and setting only the second still resolves every domain to the global driver (§2). This is deliberate — the sources are shared with identity — but it is one more thing an operator must get right.
  • Two config keys only partially migrate. [identity] domain_configurations_from_database becomes a deprecated alias; [identity] domain_specific_drivers_enabled keeps its identity-dispatch job and is not deprecated, only its source-gating side effect moves; [identity] domain_config_dir stays put. The [identity] section keeps a foot in domain-config gating until the identity path is reworked too (§2).
  • Stale resolution is now more visible. With identity, a stale cached driver affects one domain’s users. Here, a stale fan-out set can omit an entire domain from an untargeted listing until restart.
  • Cross-driver consistency is not transactional. A grant written to one domain’s driver and a revocation event written centrally are two operations; a driver failing mid-way leaves them disagreeing, as it does today, but with more drivers there are more ways for it to happen.
  • check_grant diverges further. It already behaves differently between SQL and OpenFGA (ADR 0033). Per-domain drivers mean a single deployment can now exhibit both behaviours. Harmless while it has no production caller; a future caller must not assume uniformity.
  • A configuration reload now churns assignment state. Where a reload today touches a cache and a connection pool, it now re-resolves every configured backend and may build or drop AssignmentBackend instances, each with its own OpenFGA client and pool. A reload that flips the domain_specific_drivers_enabled switch off tears down every per-backend instance at once. The reactor must diff rather than rebuild wholesale (§9) so that an unrelated reload does not drop healthy driver connections.

Testing

The implementation should carry, at minimum:

  • A per_domain_dispatch unit suite for AssignmentService, modelled on crates/core/src/identity/service/tests/per_domain_dispatch.rs: resolution from a stored assignment/driver, the empty-config and resolution-error fallbacks to the global driver, per-domain caching (the config source consulted exactly once for two calls), project→domain resolution for a project target, a domain target, and the system carve-out.
  • A cross-domain grant test proving the invariant of §1 directly: a grant by an actor in domain A on a project in domain B is written to and read from B’s driver, and is visible in B’s target-scoped listing.
  • Fan-out tests for §5: the union and de-duplication, marker pagination applied across the union rather than per driver, and a driver returning NotImplemented failing the whole call rather than being skipped.
  • A policy test asserting the assignment group is refused to a domain-scoped administrator (§6), since nothing else enforces it.
  • A binding-validation test: a config-API write of assignment/driver naming a driver that no [assignment.backends.*] block or the global [assignment] section defines (and not sql) is rejected, and a resolve against a binding whose mapped backend was later removed falls back to the global driver with a WARN (§4).
  • A shared-backend test: two domains mapped to one [assignment.backends.*] block resolve to the same instance (pointer equality), editing that block rebuilds it once and both domains observe the new instance, and the fan-out set counts it once.
  • A reload-reactor test (§9): a reload that adds, changes and removes [assignment.backends.*] blocks and [assignment.domains] mappings swaps the corresponding instances and leaves an unchanged backend’s instance identical (pointer equality); the binding cache and fan-out set are rebuilt; flipping the switch off drops every per-backend instance; a reload with an unresolvable new configuration retains the previous bundle.
  • An integration suite modelled on tests/integration/src/domain_config.rs, driving two domains on two drivers through the provider stack, including a scoped-token issue against each.
  • Config-parsing coverage for the new switch, the assignment domain-config group and the [assignment.backends.*] / [assignment.domains] maps in openstack-keystone-config and openstack-keystone-core-types.
  • [domain_config] gating coverage (§2): a DomainConfigResolver::new test that an explicit [domain_config] key overrides a conflicting [identity] one (and logs the WARN), that an unset key falls back to the [identity] value including its default, and that with every gate off the resolver holds no source and every domain resolves to the global driver; plus a config-parse test for Option<bool> round-tripping (unset vs explicit false).

Note that crates/core-types/src/domain_config/option.rs currently uses "assignment" as its example of an unknown group in unknown_group_is_rejected; that test needs a different example once this lands.

35. Relation Sync Provider

Date: 2026-09-08

Status

Proposed.

Adds an optional provider. Nothing changes for a deployment that does not configure it.

Context

ADR 0033 shipped the OpenFGA assignment driver with a documented hole in it:

Nothing syncs Keystone identity group membership into OpenFGA — the deployment owns that. […] A deployment that switches driver = "openfga" without first building that membership sync issues tokens missing every group-derived role, silently, from the first request onward.

That warning is load-bearing for more than the assignment driver. Where OpenFGA is the cloud’s authorization PDP, the services querying it are not only OpenStack: platform services outside Keystone’s API surface resolve their own decisions against the store, with Keystone nowhere in the call path. For those consumers a Keystone group is only useful if user:U member group:G tuples actually exist in the store, and provisioning from an external IdP — SCIM (ADR 0024) and federated login — is precisely the moment a tenant expects a new team to become usable everywhere.

So the store needs Keystone’s membership edges, and Keystone has to put them there.

Why not inside the write transaction

Writing to OpenFGA inline, in the same unit of work as the SQL membership mutation, makes OpenFGA a hard dependency of POST /v3/groups/{id}/users/{id} and of every SCIM provisioning call. A store outage becomes a Keystone outage, and a driver that the plugin architecture exists to keep optional becomes mandatory for an operation that has nothing to do with assignments. There is no distributed transaction across the two stores in any case: a commit in SQL followed by a failure against OpenFGA has to be reconciled afterwards no matter how tightly the call is wrapped.

Why not the existing event dispatcher on its own

EventPayload::GroupMembership events are already emitted at every membership mutation site in crates/core/src/identity/service.rs, so a ProviderHooks subscriber that wrote tuples is the obvious shape. It is not sufficient. EventDispatcher::emit (crates/core/src/events.rs:260-274) is documented fire-and-forget:

#![allow(unused)]
fn main() {
pub async fn emit(&self, event: Event) {
    let _ = self.tx.send(event.clone());          // result discarded
    for hook in hooks {
        tokio::spawn(async move {
            let _ = hook.on_event(&event).await;  // result discarded
        });
    }
}
}

The broadcast channel drops events when its buffer (256 in EventDispatcher::production) is full, hook errors are logged and swallowed, and a process restart between the SQL commit and the spawned task loses the notification with no record that it existed. That is at-most-once delivery with silent loss — not eventual consistency, but permanent divergence at a low but non-zero rate.

The direction of the loss is not symmetric. A lost membership addition is a user who cannot do something and files a ticket. A lost membership removal is retained privilege that nobody reports and nothing detects, and removal is exactly what SCIM deprovisioning does.

The project already met this problem in the audit subsystem and answered it with a second, fail-closed channel (AuditHook / emit_critical, ADR 0023) — plus postaudit_dropped_count as a gauge acknowledging that post-commit drops still happen. Membership sync cannot use the fail-closed channel without re-creating the hard dependency of the previous section.

A second writer exists

Two writers mutate user_group_membership in deployments this project targets, and neither emits an event this process can observe:

  • a co-deployed python keystone sharing the database, which the schema compatibility constraint exists to support;
  • an LDAP-backed domain (ADR 0027), where membership changes happen in the corporate directory and Keystone is a reader — identity-driver-ldap returns readonly for every membership write.

Any design in which the event stream is the source of truth is therefore structurally incomplete in the deployments that matter. A periodic diff is not a safety net behind the event stream; it is the correctness mechanism, and events only make convergence fast.

Not every membership change is a write

Three membership transitions produce no INSERT or DELETE that an outbox row could be attached to. All three are removals, the direction the previous section identified as the dangerous one.

Federated memberships expire by the clock. The expiring_user_group_membership table (user_id, group_id, idp_id, last_verified) is filtered at read time against FederationProvider::get_expiring_user_group_membership_cutof_datetime() (crates/config/src/federation.rs:46), which returns now - [federation] default_authorization_ttl. There is no cleanup job: the row stays and simply stops matching the predicate (user_group/list.rs:60,111,147). A membership sourced from an external IdP therefore stops being effective with no write, no event, and nothing to enqueue — and this is the dominant path for exactly the IdP-driven provisioning this ADR exists to serve.

Deleting a user or a group is a database cascade. user/delete.rs and group/delete.rs hold one delete function each and never reference user_group_membership; within crates/identity-driver-sql/src/ that table is touched only under user_group/. The membership rows disappear underneath the application, so a design that enqueues only at membership mutation sites sees neither deletion.

A deleted group is invisible to a per-group diff. A reconciler that walks Keystone’s groups cannot visit a group Keystone no longer has. Tuples for a deleted group are then revisited by nothing, in either mechanism, forever.

The relevant shape of the existing code

  • Membership writes in crates/identity-driver-sql/src/user_group/add.rs, remove.rs and set.rs take a &DatabaseConnection and issue a bare insert/delete with no transaction. user/create.rs:133 and user/update.rs:46 already use db.begin(), so the pattern is established in the crate.
  • user_group/set.rs exposes set_user_groups and set_user_groups_expiring: both replace one user’s membership set, so the edges they affect are only knowable by reading the prior set.
  • crates/identity-driver-sql has no src/migration/ directory: it maps onto a schema python owns. Crates that own their own tables ship one (token-restriction-driver-sql, k8s-auth-driver-sql, webauthn, federation-driver-sql).
  • When keystone-rs needed a richer model than a python-owned table allowed, it added a parallel rust-only table rather than altering the python one: federated_identity_provider alongside identity_provider (crates/federation-driver-sql/src/migration/m20250414_000001_idp.rs).
  • BackendRegistration<B> + declare_backend_registry! (crates/core/src/plugin_manager.rs:89-138) is how a provider gets pluggable drivers; register_backends builds every registration whose selected predicate accepts the config. ADR 0018 covers crate naming and the linker anchor.
  • crates/core/src/scim_resource/janitor.rs and api_key/janitor.rs give the leader-gated background sweep pattern: spawn on every node, act only when storage.current_leader().await == storage.node_id().await.
  • The OpenFGA driver already owns tuple I/O (openfga_write with a delete flag, openfga_read, retry/backoff and concurrency knobs) and the Keystone-id-to-object mapping (user_actor_types, group_actor_types, id_transform in crates/config/src/assignment.rs).
  • OpenFGA’s read cannot enumerate tuples by user alone (ADR 0033 “Impedance mismatch”), but it does filter by object, including by object type alone. A per-group diff and a whole-type sweep are both expressible; a per-user one is not. §2 and §7 both turn on this asymmetry.
  • ADR 0024 caps a SCIM group at MAX_GROUP_MEMBERS = 1000, which bounds the tuple set a per-group diff has to compare.

Decision

1. A new optional provider, relation_sync

Add crates/core/src/relation_sync/ holding a provider with two independently pluggable driver kinds, both registered through the existing BackendRegistration machinery and added to declare_backend_registry!:

Trait objectSelected byShips as
dyn RelationSyncOutbox[relation_sync] outbox_driversql
dyn RelationSyncTarget[relation_sync] targetsopenfga, log

Crate names follow ADR 0018: openstack-keystone-relation-sync-outbox-driver-sql and openstack-keystone-relation-sync-target-driver-openfga. Both carry driver in the name, so crates/keystone/build.rs anchors them automatically.

v1 ships only a sql outbox driver. A raft-backed deployment — this workspace already has a full raft driver family (scim-driver-raft, mapping-driver-raft, and others) but no identity-driver-raft — has no outbox to write to and therefore no relation_sync provider at all in that mode: outbox_driver cannot select sql without a SQL identity backend underneath it, and no raft outbox exists to select instead. Such a deployment gets §7 reconciliation-only convergence: no per-write trigger, no §4 scheduled expiry publishing, worst-case lag bounded only by reconcile_interval. This is stated here as a known limitation of v1, not a design goal — an identity-driver-raft would need a matching relation-sync-outbox-driver-raft before this provider serves that deployment shape, and neither is in scope here.

targets is a list. Each target keeps its own progress and its own failure state, so a slow or broken target never blocks another, and one relation stream can be published to several policy information points — a second ReBAC store, an entitlement service, a message bus — without Keystone learning anything about them beyond the trait.

This generality is not speculative: it is what makes a data-bundle-style push target — for example, pushing group membership into RadosGW’s own access metadata — a RelationSyncTarget implementation rather than a second event pipeline built beside this one. Such a target reuses the outbox, the relay, both reconciliation directions, and every metric in §10 unchanged; only the RelationSyncTarget impl and its [relation_sync.target.*] block are new. Two roadmap items collapse onto one mechanism, one lag metric, and one reconciler — worth keeping in mind when that target’s own ADR specs its write/read/delete semantics against the trait defined here.

The provider is absent unless configured. With no [relation_sync] section, Provider holds None, the hook is not subscribed, no outbox row is ever written, and no background task is spawned. This is what keeps OpenFGA optional: the pluggable architecture is preserved because the entire subsystem is, not because the OpenFGA driver is written defensively.

2. What is synced

v1 syncs group membership edges only, from both membership tables — user_group_membership and expiring_user_group_membership. An edge is synced if and only if Keystone would resolve it as effective, which for the expiring table means last_verified is still inside default_authorization_ttl (§4).

Role assignments are the assignment driver’s business (ADR 0033, ADR 0034) and are written to OpenFGA directly by that driver when a deployment selects it; duplicating them here would give two writers for one relation.

The trait is nonetheless phrased over a generic RelationChange so a later ADR can add relation kinds (project hierarchy, for instance) without reshaping the outbox.

#![allow(unused)]
fn main() {
/// A relation edge whose projection into a target store may be stale.
/// Deliberately a *key*, never a payload — see §5.
pub enum RelationKey {
    /// One membership edge, from either membership table.
    Membership { user_id: String, group_id: String },
    /// Every membership edge of one group. Enqueued when a group's member
    /// set is replaced wholesale, and when the group itself is deleted.
    Group { group_id: String },
}
}

There is deliberately no User variant, and the reason is the enumeration asymmetry from the Context. Resolving Group { group_id } after the group is gone still works: Keystone reports no members, the target is asked for every tuple on group:<id> — an object-filtered read, which OpenFGA supports — and all of them are deleted. The same trick is impossible for a user, because read cannot enumerate by user, so a User key would name a set the relay could not compute once the rows had cascaded away. User deletion is therefore expanded into per-group Membership rows before the cascade, inside the deleting transaction (§3). A User variant may become worthwhile later for targets that can enumerate by subject; OpenFGA is not one.

3. The outbox is written in the mutating transaction

Membership writes, and the two deletion paths, change from bare inserts and deletes to a db.begin() transaction that performs the mutation and inserts the outbox rows, then commits. Either both land or neither does.

Mutation siteEnqueued
user_group/add.rs (plain and expiring)Membership per edge; expiring edges additionally get a due-dated row (§4)
user_group/remove.rs (plain and expiring)Membership per edge
user_group/set.rs set_user_groups[_expiring]Membership for the union of the user’s prior and new group ids, the prior set read inside the transaction
user/delete.rsMembership for every group the user is currently in, read inside the transaction before the cascade removes the rows
group/delete.rsGroup

The outbox table is new and rust-only — python keystone neither reads nor writes it — which is the federated_identity_provider precedent from the Context and keeps the compatibility constraint intact: no python-owned table is altered, and a co-deployed python keystone is unaffected.

relation_sync_outbox
  seq          bigint  pk, autoincrement   -- drain order
  kind         string(16)                  -- "membership" | "group"
  user_id      string(64) null
  group_id     string(64)
  due_at       timestamp                   -- not claimable before this; §4
  enqueued_at  timestamp
  attempts     int  default 0
  dead         bool default false
  leased_until timestamp null

due_at equals enqueued_at for every row except the scheduled expiry rows of §4. It is indexed together with dead and leased_until, since the relay’s claim query filters on all three.

The enqueue helper lives in crates/core (already a dependency of identity-driver-sql) so the identity driver does not gain a dependency on the sync subsystem, and is a no-op returning immediately when the provider is not configured — an unconfigured deployment pays one Option check per membership write, and the extra reads in the set_* and user/delete.rs paths are skipped entirely.

identity-driver-ldap enqueues nothing: its membership writes already fail with readonly, and LDAP-sourced edges reach targets through §7 reconciliation, which is the only mechanism that can see them at all.

4. Expiry is a scheduled outbox row

A federated membership stops being effective at a time that is fully determined when it is written: last_verified + default_authorization_ttl. The transition is invisible to any write-triggered mechanism (Context), but it is perfectly predictable, so the outbox schedules it.

Writing or refreshing a row in expiring_user_group_membership enqueues, in the same transaction, a Membership row with due_at = last_verified + default_authorization_ttl. The relay ignores rows until due_at <= now. When one becomes claimable it is resolved by the ordinary §5 rule — ask Keystone whether the edge is effective now:

  • Not refreshed. Keystone no longer resolves the edge, the relay deletes the tuple. Expiry has been published at the moment it took effect, not at the next reconciliation.
  • Refreshed since (another login or SCIM sync bumped last_verified). Keystone still resolves the edge, the tuple stays, and the refresh has already enqueued its own later-dated row.

Re-verification therefore leaves superseded future rows in the table. They are harmless — each resolves to a no-op — and the relay coalesces rows for one edge within a batch. A deployment that re-verifies very frequently can bound the churn by deduplicating on (kind, user_id, group_id, due_at) at enqueue.

Two operational consequences follow, and both matter:

  • Not-yet-due rows are excluded from outbox_depth and lag_seconds (§10). A table holding a week of scheduled expiries must not read as a week of backlog.
  • default_authorization_ttl becomes a sync input. Changing it does not re-date rows already enqueued; the reconciler (§7) repairs the difference, and a large reduction should be followed by a forced reconciliation pass.

5. The outbox row is a trigger, not a payload

A row names an edge. It does not say whether the edge was added or removed, and the relay does not trust anything about the row except the key and due_at.

To drain a Membership row the relay asks the identity provider whether the edge is effective now and writes or deletes the tuple accordingly. Three properties follow, and they are the reason for the design:

  • Idempotent. Replaying a row is a no-op.
  • Order-insensitive. Event carries no sequence number and tokio::spawn-per-hook does not preserve ordering, so an add and a remove of the same edge can be observed out of order. Recomputing from the authoritative row makes that harmless; trusting an event payload would converge to the wrong value.
  • Coalescing. Duplicate rows for one edge collapse into one target write. This is what makes §4’s superseded expiry rows free.

The relay treats OpenFGA’s “tuple already exists” and “tuple not found” responses as success for exactly this reason.

6. The relay

A leader-gated background loop, following the janitor pattern (scim_resource/janitor.rs), one pass per target:

  1. Claim a bounded batch of rows with dead = false, due_at <= now and no live lease, oldest seq first, taking a lease (leased_until).
  2. Resolve each key against the identity provider and apply the resulting tuple writes/deletes through the target driver, honouring that driver’s own retry and concurrency configuration.
  3. Delete drained rows; on failure release the lease and increment attempts.
  4. After max_attempts, mark the row dead, emit a WARN and a counter, and move on. A poison row never blocks the queue — §7 repairs whatever it leaves behind.

Dead rows are not deleted at step 4. They are retained, unretried, for janitor_deadletter_retention_days (§9) so an operator can inspect what failed before it is gone, then purged by the same leader-gated sweep pattern as scim_resource/janitor.rs and api_key/janitor.rs — the two comparable subsystems in this codebase that dead-letter or tombstone rows both ship a retention janitor rather than an unbounded table. Purging a dead row does not lose the edge: §7’s Keystone-driven pass still repairs it on the next reconciliation from the live data, since the outbox row was only ever a trigger (§5), never the source of truth.

RelationSyncHook subscribes to EventPayload::GroupMembership and EventPayload::Group on the ordinary fire-and-forget dispatcher and does one thing: notify the relay to wake early. It performs no I/O against the target and no outbox write, so the dispatcher’s at-most-once semantics cost nothing — a dropped notification delays a drain to the next poll tick and cannot lose data. The event system is used for latency, never for correctness.

7. Reconciliation is bidirectional, and is the correctness mechanism

A second leader-gated loop periodically diffs Keystone against each target. It has two directions, and the second is not optional.

Keystone-driven. For each Keystone group: list_users_of_group against an OpenFGA read filtered on group:<id>, writing the missing tuples and deleting the extra ones. Cost is bounded by MAX_GROUP_MEMBERS (ADR 0024) per group.

Target-driven (orphan reap). A sweep of the target’s own membership tuples filtered on the group: object type, collecting the distinct group ids the store believes in. Any group id Keystone does not have is deleted wholesale.

This filters on the tuple’s object, and a role-assignment tuple’s object is always project:/domain:/system — assignment-driver-openfga maps every role grant onto one of those three target types via its ObjectMapper and never onto group: — regardless of whether the authorization model expresses group-derived roles by rewriting through the group’s member relation (a userset naming group:G as the user field of the role tuple) or some other mechanism. An object-type filter on group: cannot match a tuple whose object is a project, domain, or the system, so it cannot see or delete a role grant no matter how that grant reaches the group. The sweep’s blast radius is exactly the membership tuples this design writes, never the role tuples the assignment driver writes — asserted here explicitly because a wholesale delete on the wrong object type would be a production access incident, not a sync bug.

Without the second direction the design has no garbage collection at all. A group deleted in Keystone leaves the Keystone-driven pass nothing to iterate, so its tuples survive every future reconciliation — permanently, and unconditionally when the deletion came from a co-deployed python keystone, which enqueues nothing. In a store that other platform services trust, those orphans are retained group-derived privilege that no Keystone-side observation can find.

Together the two directions are the only mechanism covering:

  • memberships written by a co-deployed python keystone, which emit no event into this process;
  • LDAP-domain memberships, which change entirely outside Keystone;
  • expiries whose scheduled row (§4) was lost or dead-lettered;
  • deletions whose outbox row was lost or dead-lettered;
  • a store mutated out-of-band, or restored from a backup.

The sweep is paced and the interval is configurable: it sets the worst-case convergence time for every writer that is not this process, which makes it a security parameter and not a housekeeping one (§8). The orphan direction may run on a longer interval than the Keystone-driven one, since it is a whole-type scan.

8. The two staleness directions are not equivalent

Convergence lag is routinely discussed as one number. It is two, and they warrant different responses:

  • Stale addition — Keystone has the edge, the target does not. The user cannot do something they should be able to. Self-reporting, visible, and not a security event.
  • Stale removal — the target has the edge, Keystone does not. Privilege survives its revocation across every service reading the store. Nothing reports it.

The policy this ADR adopts:

  1. lag_seconds (§10) is alerted on, because it bounds stale removal for writers this process observes.
  2. Time since the last completed reconciliation pass is alerted on separately, because it — not lag_seconds — bounds stale removal for python, LDAP and any expiry whose scheduled row was lost.
  3. An operator-triggered forced reconciliation pass is a supported operation, for use after a store restore, a default_authorization_ttl reduction, or a dead-letter alarm.

Keystone deliberately does not fail its own requests closed when lag is high. Refusing to issue tokens would not close the hole: the other services reading the store answer from the same stale tuples regardless, and Keystone is only one of its consumers. It would trade a partial exposure for a total outage. Lag is an alarm, not a gate.

For the same reason Keystone resolves group-derived roles through the store like every other consumer rather than short-circuiting to its local tables. Doing otherwise would make Keystone the one service that keeps working correctly while the sync is broken — hiding the failure in the component the operator watches most closely.

9. Configuration

[relation_sync]
# Absent section = provider not built. This is the default.
outbox_driver = sql
targets = openfga
relay_interval = 5              # seconds between drain passes
relay_batch_size = 500
max_attempts = 8
janitor_deadletter_retention_days = 30
reconcile_interval = 3600       # seconds between Keystone-driven passes
reconcile_orphans_interval = 86400  # seconds between target-driven sweeps
reconcile_enabled = true

[relation_sync.target.openfga]
driver = openfga
api_url = https://fga.example.com/
store_id = 01J...
model_id = 01J...
membership_relation = member    # relation written for a membership edge

Target credentials live in server configuration only, never in the domain configuration API, for the reason ADR 0034 §6 gives for assignment driver configuration: an API-writable endpoint or bearer token for a store that governs authorization decisions is a privilege-escalation anchor.

membership_relation is validated at startup against the assignment driver’s role_to_relation values when both are configured against the same store; an overlap is a fatal misconfiguration, since it would let the relay write relations that grant roles.

reconcile_orphans_interval must be finite. Disabling the orphan sweep disables the only garbage collection the design has (§7).

10. Metrics

Per ADR 0031’s keystone_<subsystem>_<noun>[_<unit>] shape, all labelled by target:

MetricTypeNotes
keystone_relation_sync_outbox_depthgaugeClaimable rows only — excludes due_at in the future
keystone_relation_sync_scheduled_depthgaugeRows waiting on a future due_at (§4)
keystone_relation_sync_lag_secondsgaugeAge of the oldest claimable row
keystone_relation_sync_applied_totalcounterop = write/delete
keystone_relation_sync_failures_totalcounterreason
keystone_relation_sync_deadletter_totalcounterRows abandoned after max_attempts
keystone_relation_sync_deadletter_purged_totalcounterDead rows erased past retention
keystone_relation_sync_reconcile_repairs_totalcounterdirection = added/removed
keystone_relation_sync_reconcile_orphans_totalcounterTuples reaped for objects Keystone lacks
keystone_relation_sync_reconcile_age_secondsgaugeSince the last completed pass, per direction
keystone_relation_sync_reconcile_duration_secondshistogramSweep cost

lag_seconds and reconcile_age_seconds are the security-relevant numbers, for the two different populations §8 separates. Alert on both, on any non-zero deadletter_total, and on a reconcile_repairs_total{direction="removed"} that stops being near-zero in steady state — a rising removed-repair rate means the write path is missing revocations that the sweep is cleaning up after.

11. Rollout

  1. Configure [relation_sync] with the OpenFGA target while the assignment driver is still sql.
  2. Let the first full reconciliation pass complete — it is the bootstrap backfill; there is no separate import path. Let the first orphan sweep complete too, since a store that has been populated by hand will have objects Keystone does not know.
  3. Confirm lag_seconds, reconcile_age_seconds and reconcile_repairs_total are steady.
  4. Only then switch [assignment] driver = "openfga", globally or per domain (ADR 0034).

Reversing step 4 is immediate; the tuples the relay wrote remain valid for non-Keystone consumers of the store.

Alternatives considered

Write to the target inside the membership transaction. Rejected: makes OpenFGA mandatory for identity operations and couples Keystone’s availability to it, without actually achieving atomicity across two stores.

A ProviderHooks subscriber that writes tuples directly. Rejected: the dispatcher is at-most-once with silent loss (Context), so divergence would be permanent and undetectable. This ADR keeps the hook, but only as a wake-up.

Contextual tuples at check time. OpenFGA’s Check, BatchCheck and ListObjects accept per-request tuples, so the assignment driver could supply membership from the identity provider instead of relying on stored tuples — always fresh, nothing to sync, and structurally the same query-time expansion assignment-driver-sql already performs via list_groups_of_user. Rejected as a substitute for two reasons. It cannot serve consumers that query the store without Keystone in the call path, which is the requirement driving this ADR. And per §8 it would make Keystone the one service immune to sync breakage, removing the operator’s earliest signal that it has broken. It remains worth adding behind a flag as a diagnostic and emergency mode — a way to answer correctly while a sync fault is being repaired — but not as the default resolution path. Note also that OpenFGA bounds contextual tuples per request, which a user in very many LDAP groups can exceed.

A periodic full push instead of an outbox. Rejected: it makes reconcile_interval the revocation SLO for every writer rather than only the unobservable ones, and the scheduled-expiry mechanism of §4 already shows that predictable removals can be published when they happen.

Splitting membership out of the identity provider into its own provider. Rejected. It dissolves the SQL foreign keys and the single-transaction SCIM path, adds a cross-provider dependency on ID mapping, and forces a Python configuration parity story (ADR 0027) that makes it opt-in only — for a benefit this ADR obtains without moving anything.

Attributed membership edges (granted_by, expires_at, provenance on the edge). Out of scope: it requires altering a python-owned table, which the co-deployment constraint forbids, and its governance value is unsound while a second writer shares that table.

Consequences

Positive

  • ADR 0033’s documented hole is closed by a component an operator can choose, and the OpenFGA assignment driver becomes safe to adopt.
  • Keystone becomes a publisher of identity relations to a platform PDP, which is the deployment shape driving this work; targets beyond OpenFGA are a new crate, not a change here — including non-ReBAC pushes such as the RadosGW data-bundle case in §1.
  • Availability is not coupled: a target outage delays convergence and raises lag_seconds, it does not fail a Keystone request.
  • Correctness does not depend on event delivery, so the existing fire-and-forget dispatcher can stay exactly as it is.
  • Federated expiry — the transition with no write behind it, and the dominant one for IdP-sourced groups — is published when it happens rather than at the next sweep.
  • The design has explicit garbage collection, so deleted users and groups do not leave privilege behind in a store other services trust.
  • No python-owned table changes; a co-deployed python keystone keeps working and its writes still converge, via §7.

Negative

  • The projection is eventually consistent, and the window is a security parameter. Between a membership removal and the corresponding tuple delete, the target store answers with stale authority. Bounded by relay latency for writers this process observes, and by the reconciliation intervals for python, LDAP and lost scheduled rows — §8 is the whole reason those are two separate alarms.
  • Membership writes take a transaction where they previously took a bare insert, and user/delete.rs and set_user_groups* gain a read of the prior membership set. Small, but on a hot path, and paid to make deletion observable at all.
  • One more table, a relay and two reconciliation sweeps, and a leader dependency for all of them — a deployment with no leader runs them on every node, which is safe (the work is idempotent) but wasteful.
  • The orphan sweep is a whole-type scan of the target store; on a large cloud it is the most expensive thing this subsystem does, and its interval trades directly against how long an orphaned tuple can survive.
  • The outbox table holds scheduled rows proportional to the number of live federated memberships, not to write throughput.
  • delete_group and set_user_groups enqueue work proportional to the group or the user’s group count.
  • Raft deployments are unserved by v1. No outbox driver exists for them (§1); such a deployment falls back to reconciliation-only convergence, with reconcile_interval as the only lag bound.

Open questions

  • Whether the Keystone-driven reconciler should walk groups on a rolling shard per pass rather than the full set, once cloud sizes justify it.
  • Whether the orphan sweep can be made incremental — a stored high-water mark over the target’s tuple ids — rather than a full type scan.
  • Whether a target should be able to declare “membership is authoritative here”, letting the reconciler repair Keystone from the store rather than only the other way round — relevant if an operator manages groups in OpenFGA directly.
  • Whether the scheduled-expiry mechanism should be generalised to any time-bounded relation, which is the shape a later JIT/PIM elevation feature would need.
  • Whether the log target belongs in the shipped set or only in test builds.
  • Whether a raft outbox driver is worth building, and what it would need from identity-driver-raft (which does not exist yet) to enqueue at the same mutation sites §3 uses today.

See Also

  • ADR 0033 — OpenFGA assignment driver, and the membership-sync gap this ADR closes
  • ADR 0034 — per-domain assignment drivers; the same store may be reached by only some domains
  • ADR 0024 — SCIM v2 provisioning, the main producer of membership changes
  • ADR 0027 — LDAP identity driver, a membership source Keystone cannot write
  • ADR 0023 — audit dispatch, and why fire-and-forget was insufficient there too
  • ADR 0018 — driver crate naming and linker anchors
  • crates/core/src/events.rs — the dispatcher’s fire-and-forget semantics
  • crates/core/src/scim_resource/janitor.rs — the leader-gated sweep pattern
  • crates/config/src/federation.rs — default_authorization_ttl, which sets when a federated membership expires

36. Service Delegation: SVID-Bound Grants and On-Behalf-Of Exchange

Date: 2026-09-10

Updated: 2026-10-15

Status

Proposed. Phase 1 (§11) is implemented; see Service grants for operation and the phase 1 implementation notes below for where it differs from this text.

Supersedes the trust mechanism (/v3/OS-TRUST, AuthenticationContext::Trust, ScopeInfo::TrustProject, trust-driver-sql) once the phases in §11 are complete. Nothing changes for a deployment until it registers a delegatable service (§3).

Related:

Context

What trusts are used for

OpenStack services use trusts in two distinct situations:

  1. Deferred authority with no user present. Heat stores a trust at stack create and redeems it hours or months later for autoscaling, convergence, and stack delete. Magnum, Mistral, Senlin, Aodh, Sahara and Trove follow the same pattern. This is the only thing trusts do that nothing else in Keystone can do today.
  2. Continuation of a user-initiated operation past token expiry. Glance image upload creates a trust for every backend (TokenRefresher in glance/common/trust_auth.py), so the token survives a long transfer. Nova and Cinder solve the same problem differently, with X-Service-Token plus allow_expired, which keystone-rs already supports on GET /v3/auth/tokens.

Why trusts must go

This section is about trusts as OpenStack designs them today, not about any one implementation of them. A trust is an OpenStack-specific delegation record: a trustor grants a trustee user a subset of its roles on a project, and the trustee later authenticates with its own credential and asks for a token with the scope OS-TRUST:trust.

The decisive reason is the goal of tokens that no longer carry scope. Keystone is moving to resolve authorization at request time instead of minting a project and roles into every token. A trust cannot move with it. The trust is selected by the scope of the authentication request, and the token that results is meaningful only through that scope, so removing scope removes the trust. Every replacement for trusts must therefore carry its delegation as a fact about how the caller authenticated, outside any scope.

Three further properties of the design add to the case. It is defined only by OpenStack, so nothing outside OpenStack can interpret it. It rests on a secret held by the delegate. And it leaves no reliable record of who acted under whose authority.

Weaknesses of trusts as designed

LensWeaknessHow trusts work todayAddressed by
StandardsA home-grown protocolOS-TRUST is an OpenStack Identity API extension. No other identity provider, gateway, service mesh or audit tool understands a trust-scoped token, and its flags have no counterpart in any standard.RFC 8693 delegation on a standard token endpoint (§1, §5, §6)
SecurityThe delegate holds a secretThe trustee is a Keystone user that authenticates with a password. The trust is only as strong as that secret, and the secret is copied to wherever the service runs.SVID-bound grantee, no secret (§3, §5)
SecurityDelegation is expressed as token scopeThe trust is selected through the scope of an authentication request, so the meaning of the token depends on its scope. This blocks the goal of scope-less tokens, because a model without scope has nowhere to put it.Delegation is a claim about how the caller authenticated (§5.2, §7, §8)
SecurityA wide feature surfaceImpersonation on or off, redelegation with a depth limit, and a use counter are independent switches. Each one changes what a trust token means and has to be reviewed separately.One shape: no redelegation, no counter, chain depth one (§1, §8)
SecurityNo built-in end of lifeexpires_at is optional, so a trust created without it does not lapse by itself.Operator ceiling on grant lifetime (§3)
SecurityNo operator control over who may holdAny user can name any other user as trustee. Nothing records which services are allowed to hold delegated authority, or caps the roles they can receive.Delegatable-service registry with ceilings (§3)
AuditThe acting party can be hiddenWith impersonation the token’s user is the trustor. Downstream policy and logs see only the trustor unless they inspect the trust body.act is mandatory on every delegated token (§5.2, §6.2)
AuditThe authority used is not identifiableThe trust id sits in the token body, not in the identity fields downstream services consume. Working out which trust, held by whom, was used means inspecting the token and joining it to the trust.delegation_context names the grant or root token (§5.2, §6.2)
AuditOne identity holds everyone’s trustsEvery trust a service holds is held by the same trustee user, so activity cannot be attributed per trust from the trustee side. Trusts are listable, but a listing carries no purpose or service-meaningful name.Per-grant records with description and grantee name (§3, §4)

What grants and exchange change

The comparison is organised by the three concerns above. Service grants and on-behalf-of exchange appear as separate columns because they differ where it matters: an exchange has no stored record, and a grant exists for authority that must be available when no user token is.

Standards
AspectTrustService grant (§4, §5)On-behalf-of exchange (§6)
Defined byOpenStack Identity API extension, OS-TRUSTAn OpenStack-defined grant type on a standard OAuth 2.0 token endpointRFC 8693, OAuth 2.0 Token Exchange
How delegation is representedA trust object referenced by id in a trust-scoped tokenThe act claim of RFC 8693 §4.1 in a JWT (RFC 7519), plus a delegation_context claimThe act claim in a JWT
How the delegate authenticatesKeystone passwordmTLS client authentication in the style of RFC 8705 with a SPIFFE X.509 SVID, or a SPIFFE JWT-SVID as an RFC 7523 client assertionThe same
Workload identityA Keystone userSPIFFE ID, a CNCF graduated specificationThe same
Readable outside OpenStackNoAny JWT-aware gateway, mesh or identity tool can verify the token and read act; stock OAuth 2.0 libraries can make the requestThe same

The exchange half is fully standard. RFC 8693 does not define stored authority, so the grant record and its grant type are OpenStack-defined, but they sit on a standard endpoint, use standard client authentication and carry a standard claim. The OpenStack-specific surface shrinks from a whole API extension and scope type to one grant type. Other platforms solve the same problem the same way: AWS STS role assumption, Google service account impersonation and Microsoft Entra’s on-behalf-of flow are equivalents, and Keycloak implements RFC 8693 directly.

Security
AspectTrustService grantOn-behalf-of exchange
Secret held by the delegateYes, a passwordNoneNone
A stolen credential works elsewhereYes, until someone rotates itNo; the SVID is short-lived, rotated by SPIRE and bound to the workloadThe same
Where the delegation livesIn the token’s scopeIn the authentication chain, independent of scopeThe same
Who may be the delegateAny userA registered service client, with operator ceilings on roles and lifetimeThe same
LifetimeOptional expiryOperator-capped; every redemption re-checks grantor, roles and grant; no refresh tokenBounded by the user’s token and a per-service ceiling
Features to review separatelyImpersonation, redelegation, use counterNone; chain depth is oneNone
How it endsDelete the trust, or disable the trusteeDelete the grant, role loss or user disable, or disable the clientRevoke the root token, remove the role, or disable the user
Works with no user token at run timeYesYesNo, by design
Compatible with tokens without scopeNoYesYes
Auditability
AspectTrustService grantOn-behalf-of exchange
Acting party on every requestHidden under impersonation; the token names the trustorAlways present in act, with the service’s SPIFFE IDAlways present in act
Authority used, per requestA trust id in the token bodygrant_id in delegation_context, carried into audit recordsroot_jti ties every hop to the originating user token
Record of each useAn ordinary authentication by the trustee, then trust scopingA dedicated CADF event per redemption with grant, grantor and actor (§8)A CADF event per exchange
Lifecycle recordCreate and delete; no reason, no last-use timeCreate, redeem, revoke and disable, with a reason and the last-redeemed timeThe chain ends when the root token is revoked
Inventory for the person who grantedA list of trusts by idA list with description, expiry, last-redeemed time and the grantee’s nameNone; the user’s own token is the consent (see below)
Attribution of a service’s activityAll of a service’s trusts share one trustee userPer grant and per servicePer service

The user sees no per-exchange record of an on-behalf-of hop. Only the auditor sees it, through the CADF event (§8). That is deliberate: the user handed the service their own token for one operation, and that act is the consent. Standing authority, which needs an inventory the user can review and revoke, is what service grants are for.

What this does not fix

These limits hold after this change, and the rest of the document is written around them.

  • Delegated authority is as powerful as before. A compromised service can still act as every grantor that granted it access, within the granted roles. What changes is that the stolen identity is an SVID that cannot be used from another host, and that the operator can end all redemptions at once by clearing the client’s delegation policy. Disabling a trustee user already does the latter for trusts, so that part is not new.
  • Derived credentials must carry the delegation. Credentials created from a delegated token, such as EC2 keys, have to be bounded by the same delegation or they widen it. The design requires this (§11 Phase 4), and the risk exists for any delegation scheme.
  • Offline validation cannot see revocation until exp. Deleting a grant does not shorten tokens already minted from it (§8).
  • Tokens stay bearer tokens. SPIRE rotates certificates, so certificate-bound tokens are not available (Consequences).
  • Services still have to change. Each swaps one creation call and one authentication plugin (§10).

What already exists in keystone-rs

  • An internal SPIFFE mTLS interface ([interface_internal], type = "spiffe") on which OpenStack services present X.509 SVIDs. The SVID is flattened to spiffe.id, spiffe.trust_domain, spiffe.host, spiffe.project_id / spiffe.instance_id claims (crates/core/src/api/auth.rs) and resolved through the mapping engine to an IdentityInfo::Principal, optionally is_system.
  • An OAuth2 provider with a domain-bound token endpoint, a client_credentials grant, an RFC 8693 token-exchange grant that already re-expresses application-credential delegation as a JWT (crates/core/src/oauth2_client/token_exchange.rs), a DelegationContext claim enum reserved for exactly this extension, and token_endpoint_auth_method values tls_client_auth and private_key_jwt reserved for v2.
  • Application credentials with the lifecycle semantics this ADR copies: roles frozen to a subset of the creator’s roles, intersected with live assignments at use, deleted when the creator loses the roles.
  • A SPIRE plan that gives every control-plane service a stable SPIFFE ID and every VM a project/instance SVID.

Constraints

  • SPIFFE is not reachable from the public API. OpenStack API users have no SVID and no route to the internal interface. Anything a user does (create, list, revoke a delegation) must work with an ordinary token on the public interface. Anything a service does (redeem a delegation) happens on the internal interface with an SVID, or, for workloads that cannot reach it, with a SPIRE-issued JWT-SVID presented on the public interface (§5.3).
  • Tokens will stop carrying scope. A stated goal is to replace scope bindings in the token with runtime authorization resolution. This ADR therefore places every delegation fact in delegation_context (an authentication-chain fact) and treats the project/role projection into openstack_context as a transitional convenience that can be dropped without touching the design (§7).
  • Python coexistence. Nothing here manages Python-owned tables. New state lives in Raft-backed drivers like the OAuth2 resources. The cost is that only keystone-rs can redeem or validate a grant, so mixed deployments must route those requests to it.

Decision

1. Two mechanisms, one token endpoint

NeedMechanismGrant type at /v4/oauth2/{domain_id}/tokenToken subjectActor
Deferred authority, no user token at run timeService grant (§4, §5)urn:openstack:params:oauth:grant-type:service-grantgrantor userservice client
Continue a user-initiated operationOn-behalf-of exchange (§6)urn:ietf:params:oauth:grant-type:token-exchangeuserservice client

Both mint an OpenStackAccessTokenClaims JWT (ADR 0026 §4). Both record the service as an RFC 8693 act claim so audit never loses the acting party, and both carry the delegation boundary in delegation_context. Neither creates a Keystone user, a password, or a client-side secret. There is no impersonation flag, no redelegation, and no use counter.

2. Vocabulary

TermMeaning
GrantorThe Keystone user whose authority is delegated. Always a real user (IdentityInfo::User).
GranteeThe party that may redeem the delegation: a registered service client (v1) or another user (§9, later phase).
Service clientAn OAuth2Client with a delegation policy and a bound SPIFFE identity (§3). The operator-controlled registry of delegatable services.
ActorThe authenticated grantee at redemption time, recorded in the act claim.
GrantThe stored, user-visible, revocable delegation record (§4).

3. Delegatable service registry: OAuth2Client extension

The registry of services that may receive delegated authority is the existing OAuth2Client resource. A client becomes delegatable when an operator sets:

#![allow(unused)]
fn main() {
pub struct OAuth2ClientResource {
    // existing fields (ADR 0026 §5) ...
    pub token_endpoint_auth_method: String,   // "tls_client_auth" or "private_key_jwt" for service clients
    pub spiffe_ids: Vec<String>,              // NEW: SVIDs accepted as this client. Exact IDs or a
                                              // per-host family such as
                                              // "spiffe://td/service/heat-engine/host/*" (last segment only)
    pub delegation: Option<DelegationPolicy>, // NEW
}

pub struct DelegationPolicy {
    pub service_grants: bool,               // may be a grantee of service grants (§4)
    pub on_behalf_of: bool,                 // may perform on-behalf-of exchange (§6)
    pub cross_domain: bool,                 // may hold grants from users of other domains; SystemAdmin-only
    pub max_role_ids: Vec<String>,          // ceiling on delegated role ids; empty = no ceiling beyond the grantor's own
    pub max_grant_lifetime: Option<Duration>,     // ceiling on grant `expires_at`; None = unbounded
    pub max_operation_lifetime: Option<Duration>, // required when `on_behalf_of`; ceiling on re-exchange chains (§6.3)
    pub max_grants_per_project: Option<u32>,// per-project grant cap for this grantee; None = no per-project cap (§4.1)
}
}

max_role_ids holds role ids, and the API accepts only ids. Role names are not unique once domain-specific roles exist (ADR 0034), and the delegated roles on a grantor’s project can come from any domain. Resolving a name in the client’s own domain could pick a different role than the one meant and silently widen or narrow the ceiling. This is an operator-facing field, so the lost convenience is cheap. max_operation_lifetime must be set when on_behalf_of is true and is ignored otherwise.

  • Setting or changing delegation and spiffe_ids is gated by policy/oauth2/client/update.rego with the same SystemAdmin-only carve-out ADR 0026 uses for pre_authorized, and emits a CADF event.
  • Control-plane services register once, in the operator’s infrastructure domain, with cross_domain: true. Tenant-local automation registers in the tenant domain with cross_domain: false.
  • GET /v4/delegatable-services (public interface, any authenticated user, policy/delegatable_service/list.rego) lists the enabled clients with service_grants == true that a caller may name as grantee: those in the caller’s domain plus every cross_domain client. Disabled clients and clients that are not service-grant grantees are omitted. It returns client_id, the client’s existing name (ADR 0026 §5), and the DelegationPolicy ceilings, nothing else. Users never see or type a SPIFFE ID. This is an accepted disclosure: the existence and delegation ceilings of every cross_domain (i.e. control-plane) client are visible to every authenticated user in the cluster. That set is operator-curated, is already inferable from the service catalog, and a client_id is not a credential — nothing can be redeemed without an SVID that matches spiffe_ids, which is never returned here.

3.1 SVID-to-client matching

Done by the token endpoint itself, never by the mapping engine: the redeemed token’s subject is the grantor, not the service, so there is no principal to map. The match is normative, not “prefix-ish”:

  1. The presented SVID is parsed and normalized as a SPIFFE ID (scheme, trust domain, path); a value that does not parse is rejected before any comparison.
  2. Comparison is on the split path segments, never on the raw string. A pattern matches only when it has the same segment count and every segment is equal, with the single exception below.
  3. * is allowed only as the entire final segment of a pattern, matches exactly one non-empty segment, and never matches across a /. A bare * pattern, a * inside the trust domain, a * in any non-final segment, and a substring wildcard (heat-*) are all rejected at client-update time, not at match time.
  4. Trust domains are compared for exact equality. A pattern whose trust domain is not the deployment’s own is rejected on write.

So spiffe://td/service/heat-engine/host/* matches spiffe://td/service/heat-engine/host/compute-3 and does not match spiffe://td/service/heat-engine/host/a/b or spiffe://other-td/service/heat-engine/host/x.

3.2 Cross-domain client resolution

OAuth2Client is a domain-bound resource (ADR 0026 §5), but a service grant is redeemed at the grantor’s domain token endpoint (§5.1), and a control-plane client lives in the operator’s infrastructure domain. Therefore:

  • client_id is unique cluster-wide, not per domain. This is the Client ID Uniqueness Invariant of ADR 0026 §5. The Raft driver already enforces it at create with an atomic create_if_absent on the global client_id_idx key, while the primary key stays domain-scoped. This ADR relies on that invariant and adds nothing to it.
  • ServiceGrant.grantee stores Client { client_id, client_domain_id }. The token endpoint resolves the client by that pair, so redemption never scans domains.
  • Resolving a client from a domain other than {domain_id} in the request path is permitted only when client.delegation.cross_domain == true. Every other client authentication path at /v4/oauth2/{domain_id}/token keeps today’s same-domain-only rule.
  • The minted token is signed by the key of the target project’s domain and carries aud = openstack-apis with iss naming that domain (ADR 0026 §5 domain key isolation: isolation rests on iss + the issuer’s own JWKS, so a domain’s key only vouches for that domain’s projects). The grantor may belong to another domain, exactly as a user may hold roles on projects of other domains; the token’s user claims name the grantor’s own domain. The grantee’s own domain never appears in the token; it is a registry fact only.

4. Service grant resource

4.1 Model

#![allow(unused)]
fn main() {
pub struct ServiceGrant {
    pub id: String,
    pub version: u64,                  // bumped on every change; compared by the resolver (§7)
    pub domain_id: String,             // target project's domain; the token endpoint the grant is redeemed at
    pub grantor_user_id: String,
    pub grantee: Grantee,              // v1: Client { client_id, client_domain_id } (§3.2); later: User { user_id } (§9)
    pub target: GrantTarget,           // v1: Project { project_id }; the I2 delegation boundary
    pub role_ids: Vec<String>,         // subset of grantor's effective roles on target at creation
    pub expires_at: Option<DateTime<Utc>>,
    pub description: Option<String>,
    pub enabled: bool,
    pub created_at: DateTime<Utc>,
    pub last_redeemed_at: Option<DateTime<Utc>>,
    pub disabled_reason: Option<DisabledReason>, // RoleLost | GrantorDisabled | GranteeRemoved | Revoked | Expired
}
}

Rules applied at creation:

  • The caller is the grantor. A grant can only delegate the caller’s own authority; grantor_user_id is never client-supplied.
  • role_ids must be a non-empty subset of the caller’s effective roles on target at creation — effective meaning direct, group-derived and inherited assignments alike, exactly what calculate_effective_roles() already returns for the caller’s own scope — further capped by DelegationPolicy.max_role_ids. Omitting roles freezes the caller’s current effective roles, as application credentials do.
  • expires_at is capped by max_grant_lifetime.
  • The grantee client must exist, be enabled, and have DelegationPolicy.service_grants == true. For a grantee in another domain, cross_domain must be true. A grant naming anything else is rejected at create with 400, not stored to fail at redemption.
  • The caller’s authentication chain must not itself be delegated: a request authenticated via a trust, a restricted application credential, a service grant, or an on-behalf-of token is rejected (403). An unrestricted application credential may create grants. Python Keystone gates trusts the same way, with a global opt-out instead of a per-credential flag. This is what keeps chain depth at exactly one.
  • One project per grant. A service that needs several projects holds several grants. Domain-scoped targets are a possible later extension; system scope is never delegatable.
  • Grant count limits, configurable. Trusts have no such cap today and services hold one grant per managed object (§10.2: one per Heat stack), so a flat low cap would break the pilot at the 51st stack in a project. Therefore:
    • Grants whose grantee is a registered service client are counted per (project, grantee client) and bounded by that client’s max_grants_per_project, which defaults to None (uncapped). An operator who registers a delegatable service is already deciding to trust it at project scale; a per-service knob is where that decision belongs.
    • Grants whose grantee is a user (§9) are capped per grantor, default 50, which is the case where an unbounded count is an abuse channel rather than a workload property.
    • A cluster-wide [delegation] max_grants_per_project backstop exists for storage protection, default 10000, and is a 429/quota_exceeded, not a security control.

4.2 API

Users call these on the public interface with an ordinary token. The grantee service calls show and delete on the internal interface only, identified by SVID (see the “Who may act” list below).

MethodPathPolicy
POST/v4/service-grantspolicy/service_grant/create.rego (owner only)
GET/v4/service-grants?user_id=&project_id=&grantee_client_id=policy/service_grant/list.rego (I8/I8a: user_id filter required unless admin)
GET/v4/service-grants/{id}policy/service_grant/show.rego
DELETE/v4/service-grants/{id}policy/service_grant/delete.rego

Who may act:

  • The grantor: create, list own, show own, delete own.
  • Project admins and domain admins: list and delete grants targeting their project / domain. They cannot create grants on behalf of other users.
  • The grantee service: show and delete grants where it is the grantee, so Heat can clean up at stack delete. It cannot list arbitrarily. The caller is identified by the same spiffe_ids match as §3.1, not through the mapping engine: a mapped principal is an identity in its own right with no link back to a client_id, and the policy has to compare against grant.grantee.client_id. The handler therefore resolves the presented SVID to a client exactly as the token endpoint does, and passes credentials.actor.client_id to OPA (§8). A service that has both a mapped principal identity and a delegatable client registration keeps them separate; only the client registration authorizes grant show/delete.
  • SVID presentation exists only on the internal interface (Constraints), so the grantee path is served there. A grantee that reaches only the public interface authenticates with a JWT-SVID at the token endpoint (§5.1) but has no show/delete route. Such a grantee, for example a tenant-VM workload, cannot clean up its own grants. The grantor deletes them, or they expire. Phase 3 (§11) may add a JWT-SVID-authenticated route if a pilot needs one.

Grants are immutable except for enabled / disabled_reason, which only the lifecycle hooks (§8) set. Rotation is create-new, switch, delete-old.

4.3 Storage

New crate service-grant-driver-raft, keyed data:service_grant:<domain_id>:<id> with secondary indexes by grantor_user_id, grantee_client_id, and target.project_id. Backend trait in crates/core/src/backend.rs following the CRUD naming convention (create_service_grant, get_service_grant, list_service_grants, update_service_grant for lifecycle flags only, delete_service_grant).

Redemption must not turn into a Raft write per request. last_redeemed_at is best-effort and batched, and a missed update is acceptable because it is an inventory hint, not a control. The RoleLost transition (§5.1 check 6) is written only when enabled is still true, so a retry loop against a dead grant does not generate writes.

5. Redeeming a service grant

5.1 Request

POST /v4/oauth2/{domain_id}/token where domain_id is the grant’s domain (the grantor’s), authenticated as the service client:

grant_type=urn:openstack:params:oauth:grant-type:service-grant
grant_id=<id>
roles=<optional space-separated role ids to narrow further>
audience=<optional; narrows aud, see §7>

roles is a per-grant parameter. It is not the OAuth2 scope parameter, which keeps its ADR 0026 §4 meaning (a subset of the client’s allowed_scopes, echoed in the response). It takes role ids only, must be a subset of grant.role_ids, and an id outside that set is rejected with invalid_request. A scope value on this grant type is validated as in ADR 0026 §4 and has no effect on roles.

Client authentication is one of:

  • tls_client_auth on the internal interface: the SVID presented at the TLS layer must match one of the client’s spiffe_ids. This is the normal control-plane path.

  • private_key_jwt on the public interface: a SPIRE JWT-SVID as the client_assertion (RFC 7523). This is the path for grantees that cannot reach the internal interface, for example automation inside a tenant VM that holds a project/instance SVID from the SPIRE plan. No client secret exists in either case.

    RFC 7523 §3 requires iss == sub == client_id for client authentication. A SPIRE JWT-SVID carries the workload’s SPIFFE ID in sub and the SPIRE server in iss, so this is a deliberate deviation, spelled out because a spec-literal implementation would reject every SVID:

    • sub MUST be a SPIFFE ID matching spiffe_ids per §3.1. That match, not client_id, identifies the client; a client_id form parameter, if sent, must agree with the result or the request is rejected.
    • The signature MUST verify against the trust domain’s JWKS bundle for the trust domain named in sub, fetched from the SPIRE bundle endpoint and cached, never against Keystone’s own domain keys.
    • aud MUST contain the fixed, deployment-wide Keystone token-endpoint audience ([oauth2] jwt_svid_audience, for example keystone-oauth2), accepted at any domain’s token endpoint. Token endpoints are per domain and domains are created at run time, while SPIRE mints JWT-SVIDs with one configured audience per server, so a per-endpoint URL cannot be implemented. This is the pattern the SPIRE plan already uses for VM attestation (spiffe-attestation). An assertion minted for another relying party is still rejected, and the signature check is bound to the deployment’s trust-domain bundle.
    • exp - iat MUST be <= 300s and clock skew tolerance is 60s. This needs SPIRE-side configuration (Phase 3 prerequisites, §11).
    • The assertion jti MUST be recorded in a replay cache for the remainder of its exp window, and a repeat is rejected (invalid_client). Without this a captured assertion is a reusable bearer credential for its whole TTL, which would undercut the “no delegated secret exists anywhere” property this ADR is built on. The cache is the per-domain Raft-backed store, so a replay cannot be laundered through a different API node.

The endpoint checks, in order:

  1. Rate limiting, pre-hash, as for every other /token grant (ADR 0026 §7.A).
  2. Client authentication (including the replay/audience checks above) and DelegationPolicy.service_grants == true.
  3. grant.domain_id == {domain_id} from the request path — a grant is only ever redeemable at its own domain’s token endpoint.
  4. Grant exists, enabled, not expired, grantee is this client, and either the client’s domain equals both grant.domain_id and the grantor’s domain, or client.delegation.cross_domain (§3.2).
  5. Grantor is enabled and the grantor’s domain is enabled.
  6. Effective roles = grant.role_ids ∩ grantor's live effective roles on target ∩ roles narrowing. Empty → 403 and the grant is marked RoleLost. This differs from Python Keystone trusts, where losing any one delegated role fails the mint outright. A grant narrows to the intersection and fails only when nothing is left. This live intersection, re-run on every single redemption, is the primary role-loss control; the lifecycle hooks in §8 are an optimisation that usually gets there first, not the thing the security property rests on.

Redemption load: with no refresh token and a 15-minute access token, each active grant re-redeems roughly four times an hour, and a Heat-sized deployment holds one grant per stack. Service clients are therefore given their own /token rate-limit bucket, sized by the operator from the expected grant count rather than sharing the per-client default meant for interactive clients; the §7.A pre-hash limiter still applies first so an unauthenticated flood is dropped before any grant lookup. Deployments that find this traffic material can raise access_token_lifetime_minutes. That value is global today, so the change applies to every client and trades revocation latency (§8) for request volume. A per-client lifetime for service clients is a possible later knob, not part of this ADR.

5.2 Response and claims

A standard token response with access_token and expires_in. No refresh token. The service re-redeems the grant when the access token nears expiry; the grant record is the durable state, and every redemption re-runs the checks above. This is what makes revocation effective (§8).

{
  "iss": "https://keystone/v4/oauth2/{domain_id}",
  "sub": "<grantor user id>",
  "aud": "openstack-apis",
  "client_id": "<service client id>",
  "act": {
    "client_id": "<service client id>",
    "sub_type": "spiffe",
    "sub": "spiffe://td/service/heat-engine"
  },
  "amr": ["service_grant"],
  "token_use": "access",
  "delegation_context": {
    "auth_method": "service_grant",
    "grant_id": "<id>",
    "delegated_project_id": "<target project>"
  },
  "openstack_context": { "...": "transitional projection, see §7" },
  "exp": 0,
  "iat": 0,
  "nbf": 0,
  "jti": "..."
}

sub is the grantor because downstream services key resource ownership on user_id, which is why trusts were almost always created with impersonation=true. Unlike trusts, the acting service is always present in act; there is no way to mint a token that hides it.

act.sub_type is mandatory and disambiguates the namespace act.sub is drawn from: "spiffe" for a service client (above), "user_id" for a human grantee (§9). Consumers must branch on it rather than pattern-matching the value, so adding a third grantee kind later is not a silent reinterpretation of an existing claim.

amr is ["service_grant"] and deliberately does not carry the grantor’s original authentication methods: no user authenticates at redemption time, and the grantor’s last login may be months in the past. Policies that key on amr for MFA or PCI-DSS purposes (ADR 0010–0012) therefore never match a delegated call. That is intended — an MFA assertion about an absent user would be a fiction — and a deployment that wants to keep MFA-gated operations away from delegated callers expresses that as amr == ["service_grant"] being denied by the relevant rule, not as a stronger amr on the token.

5.3 Why the token endpoint and not a new v3 auth method

Trusts were a v3 auth method with a dedicated scope. Putting delegation on the OAuth2 token endpoint gives the RFC 8693 act semantics, the domain-bound aud, the client registry, rate limiting, and DelegationContext for free, and keeps every delegation fact in the chain (I1). It also means services need one client library shape for both mechanisms in this ADR.

6. On-behalf-of exchange

6.1 Request

POST /v4/oauth2/{domain_id}/token, authenticated as a service client with DelegationPolicy.on_behalf_of == true:

grant_type=urn:ietf:params:oauth:grant-type:token-exchange
subject_token=<the user's token as received by the service>
subject_token_type=urn:ietf:params:oauth:token-type:access_token   (JWT)
                 | urn:openstack:params:oauth:token-type:keystone   (Fernet / JWS v3 token)
audience=<optional>

The actor is the mTLS-authenticated client; no actor_token parameter is accepted. This extends the existing token-exchange grant, which today only accepts an application-credential subject and rejects everything else.

6.2 Checks and claims

  1. subject_token is validated through TokenApi::validate_to_context like any bearer token (expiry, revocation, user enabled). When the subject is itself an on-behalf-of token, its delegation_context.root_jti is checked against the revocation store as well (§6.3).

  2. The subject’s chain must be plain, application-credential, or an on-behalf-of token previously minted for the same actor (§6.3). A trust, service-grant, or on-behalf-of-for-another-actor subject is rejected: no re-delegation.

  3. sub = the subject’s user id; act = this client, with sub_type: "spiffe" as in §5.2; amr = the set union of the subject’s amr and on_behalf_of — a union, not an append, so a chain re-exchanged twenty times does not accumulate twenty on_behalf_of entries.

  4. delegation_context:

    {
      "auth_method": "on_behalf_of",
      "actor_client_id": "<client id>",
      "auth_time": 1757500000,
      "root_jti": "<jti of the user token that started the chain>",
      "delegated_project_id": "<inherited from the subject's own delegation, if any>"
    }
    

    delegated_project_id is present only when the subject’s chain was itself delegated (an application credential). A plain subject has no delegation boundary; its authority is whatever the user’s live assignments say at run time, which is the point of §7.

  5. auth_time and root_jti are taken from the subject on the first hop and preserved unchanged on every re-exchange (§6.3).

6.3 Renewal without a stored record

Long operations outlive a fifteen-minute access token. The service presents the previously exchanged token as subject_token; the endpoint accepts it if the actor is unchanged and now < auth_time + DelegationPolicy.max_operation_lifetime. The act claim is not nested; depth stays at one.

The chain is bound to the root token, not just to the last hop. On the first exchange the endpoint records root_jti — the subject token’s jti, or its audit_id when the subject is a v3 Fernet/JWS token — into delegation_context, and every subsequent hop copies it forward unchanged. Each hop then re-runs, against keystone-rs:

  1. root_jti is not revoked (the ordinary revocation store, revoke-driver-sql / the /v3/auth/tokens revocation path — the same check a plain bearer token gets, applied to the root identifier rather than to the hop token that was presented).
  2. The user is enabled, the domain is enabled.
  3. Live role and delegation-boundary checks, as on the first hop.

Without the root_jti binding, re-exchange would present a different token than the one the user handed over, with a different jti, and an explicit “revoke my token” would have no effect after the first hop — the chain would be a token-lifetime-extension primitive that outlives its own revocation, which would reproduce the unbounded, unauditable delegation this ADR exists to remove. Revoking the root token now ends the chain at the next hop, and, because the hop tokens carry root_jti as a claim, a deployment on the back-channel validation path rejects already-issued hop tokens immediately as well.

No grant record exists for on-behalf-of exchange because the user’s original token, presented to the service, is the consent, and the user’s ordinary revocation controls — revocation of the root token, role removal, user disable — all end the chain at the next hop. Offline JWT validators still honour an already-issued hop token until its own exp (§8).

Issue #1018 extends the same grant type on the same endpoint in the opposite direction: an external IdP JWT as subject_token, verified against a trusted issuer, flattened to claims and run through the mapping engine (evaluate_ruleset → authenticate_by_mapping, with a new IdentitySource::TokenExchange { issuer }) to establish a Keystone identity. That is ingress — deciding who the caller is. This ADR is delegation — an identity Keystone already established acting through a service, with the mapping engine deliberately not involved (§3.1). The two are orthogonal in semantics and can land in either order, but they share three implementation surfaces and must not grow two answers to any of them:

  • One subject_token_type dispatch. The parameter is accepted today but not branched on (crates/keystone/src/api/v4/oauth2/token.rs, #[allow(dead_code)]), because the shipped app-cred path is Keystone-native by assumption. This ADR adds urn:openstack:params:oauth:token-type:keystone (§6.1); #1018 adds urn:ietf:params:oauth:token-type:jwt. Whichever lands first builds the match; the second extends it rather than adding a parallel path.
  • verify.rs needs a validation profile, not a constant — a shared prerequisite for both. Subject-token verification used to hardcode a domain-bound audience (#1483 replaced it with the flat openstack-apis control-plane value plus an optional own-service-type narrowing, taken as a parameter of verify_openstack_access_token). #1018 still breaks on a fixed audience because an external token’s aud is never openstack-apis; this ADR needs §7.1 narrowing (openstack-apis:<service type>) accepted when §6.3 re-exchanges such a token as subject_token. One refactor to a per-subject-kind validation profile (expected issuers, accepted audiences, required claims) serves both; doing it twice guarantees divergence.
  • Confused-deputy replay gets one rule. #1018’s concern (an external token minted for someone else replayed at Keystone) is the same one §5.1 answers for JWT-SVID client assertions: pin aud to the fixed Keystone token-endpoint audience, bound the assertion lifetime, and cache the assertion jti against replay. §5.1’s rule is the stricter of the two and generalises to the external-subject case.

Separately, if #1018 proceeds, ADR 0026 §1’s “Circular Token Exchange Trap” needs an amendment note: that section records external-IdP-JWT-for-Keystone-token as eliminated by native OP issuance, which reads as prohibiting exactly what #1018 schedules. The distinction is that #1018 mints a native JWT through a standards-compliant grant rather than trading an external JWT for a Fernet token. Nothing in this ADR touches that boundary — every subject here is Keystone-native.

7. Token content and runtime authorization

Every fact a policy needs to bound a delegated caller lives in delegation_context, which is derived from the authentication chain and is immutable across audience narrowing or re-exchange. Nothing in §4–§6 keys on the token’s scope.

openstack_context (project and roles, ADR 0026 §4) is still populated at mint time as a projection of the grant for the benefit of today’s keystonemiddleware filters. When scope-less tokens land:

  • openstack_context is dropped from delegated tokens exactly as from plain ones.
  • The authorization resolver (back-channel keystone-rs, or the offline filter with a policy bundle) computes effective roles at request time as: live roles of sub on the request’s target, intersected with grant.role_ids when delegation_context.grant_id is set, and the target must equal delegated_project_id when that field is set.
  • The grant record therefore becomes runtime authorization input. keystone-rs exposes it to the resolver via GET /v4/service-grants/{id} on the internal interface, cacheable for the access-token lifetime, and any change to the grant bumps a version the resolver compares.

7.1 audience narrowing

The application-credential exchange already implements this (requested_audience() in crates/keystone/src/api/v4/oauth2/token/token_exchange.rs, whose doc comment cites this section). The rules below ratify that behavior and extend it to the grants in this ADR, so Phase 2 reuses the function and adds no new narrowing logic.

  • The request parameter is RFC 8693 audience, one or more values. resource (URI form) is not accepted in v1; it is reserved so that adding it later is additive.
  • A valid value is an OpenStack service type from the catalog (object-store, compute, …). The endpoint validates each value against the deployment’s service catalog (enabled services only) and rejects unknown ones with invalid_target rather than minting a token nobody accepts.
  • The resulting claim is aud = ["openstack-apis:<service type>", …], a narrowing of the default openstack-apis (the whole control plane; the domain is not part of aud, ADR 0026 §5). Requesting no audience keeps the default. audience may be repeated, is deduplicated, and is bounded.
  • Acceptance side: the ADR 0026 §6 middleware and the back-channel validation path accept a token when aud contains either the unnarrowed openstack-apis or openstack-apis:<its own service type>. Domain isolation is by iss (an explicit issuer allowlist) and the per-issuer JWKS, not by aud. A service that does not know its own service type accepts only the unnarrowed form, so narrowing is opt-in per deployment and cannot silently break an unupgraded service.
  • Narrowing can only ever remove reach: an audience value is honoured only if the token would otherwise have been valid there.

The narrowed aud is a transport property; it never changes delegation_context, and re-exchange (§6.3) may narrow further but never widen.

8. Keystone-side enforcement and lifecycle

New AuthenticationContext variants, with match arms added everywhere ADR 0017’s checklist requires (validate_scope_boundaries, new_for_scope, calculate_effective_roles, fully_resolved, Credentials::try_from, from_security_context):

#![allow(unused)]
fn main() {
ServiceGrant { grant: ServiceGrant, actor: ActorInfo },
OnBehalfOf   {
    actor: ActorInfo,
    subject: Box<AuthenticationContext>,
    auth_time: DateTime<Utc>,
    root_jti: String,   // §6.3: the chain is revoked with the token that started it
},
}

Invariant mapping:

InvariantHow it holds
I1is_delegated, delegated_project_id, and the new actor fields on Credentials are read from the chain variants above.
I2 / I3delegated_project_id == grant.target.project_id; the scope-drift tripwire in policy.rs and the rego helper apply unchanged.
I4Effective roles = grant roles ∩ grantor live roles, on every scope shape, including the interim scoped projection.
I5validate_scope_boundaries: ServiceGrant permits only Project == grant.target; OnBehalfOf delegates to its subject.
Token-from-tokenForbidden for both variants, as for trusts today. Renewal is re-redemption (§5) or re-exchange (§6.3).

Credentials gains actor: Option<ActorInfo { client_id, spiffe_id }> so policies can say, for example, that only the grantee client may delete its grants (§4.2).

Lifecycle hooks (event bus, crates/core/src/events.rs), mirroring application-credential cascade. These hooks are an optimisation, not the security control. The control is the live intersection re-run on every redemption (§5.1 check 6) and on every on-behalf-of hop (§6.3), which is why the gaps below are tolerable rather than holes:

  • EventPayload::RoleAssignment covers direct assignments only. Role loss through a group membership change needs its own arm on EventPayload::GroupMembership, and loss of an inherited domain assignment that projected onto the target project is only visible by recomputing effective roles, which the hook does not do.
  • With an external assignment driver (ADR 0033 OpenFGA, ADR 0034 per-domain drivers), assignment changes made outside Keystone emit no event at all, so no hook fires.

In every one of those cases the grant stays enabled in the record while already being unredeemable: the next redemption computes an empty role intersection, returns 403, and marks it RoleLost then. The user-visible enabled flag is therefore “best known state”, and GET /v4/service-grants documents it as such.

EventEffect on grants
Grantor loses a delegated role on the target (direct assignment, or group membership change)enabled=false, RoleLost. Re-enable is not possible; create a new one.
Grantor disabled / deletedGrantorDisabled / hard delete
Target project disabled / deleteddisabled / hard delete
Service client deleted or delegation removedGranteeRemoved
expires_at passedExpired; janitor deletes after a retention period
DELETE /v4/service-grants/{id}Revoked and deleted; CADF event

Revocation latency: immediate for every path that hits keystone-rs (redemption, re-exchange, Fernet/back-channel validation). Offline JWT validation is bounded by the access-token lifetime and nothing shortens it.

Deleting a grant does not populate the jti revocation list of ADR 0026 §3. That list is fed from the audit log during emergency signing-key rotation, and ADR 0026 §5 already declines to populate it on client delete for the same reason: nothing indexes minted jtis by the object that authorized them. Building that index would mean a per-grant record of every token minted from it — new write-heavy state on the redemption hot path, contradicting §5.2’s “the grant record is the durable state”, and ADR 0026 §12’s Phase 6 amendment specifically avoided adding the audit-query capability that the alternative needs. The 15-minute access-token lifetime is the accepted bound instead.

Deployments that need sub-lifetime revocation of already-minted delegated tokens have two options, both pre-existing: keep the affected services on the back-channel validation path (where a deleted grant fails immediately, since §7’s resolver reads the grant record), or trigger emergency key rotation (ADR 0026 §3) for the domain, which is a domain-wide action and priced accordingly. This is called out again in the risks below.

CADF events use the pyCADF action taxonomy so pyCADF-based audit tooling can consume them: create for a new grant, authenticate for a redemption or an exchange, revoke for a delete, and disable for a lifecycle disable. The target resource type (service_grant or on_behalf_of) tells them apart. Each event carries grant_id, grantor_user_id, actor.client_id, actor.spiffe_id, delegated_project_id.

9. Human-to-human delegation (later phase)

The same grant record with grantee: User { user_id }. The grantee redeems on the public interface with its own bearer token as client authentication:

grant_type=urn:openstack:params:oauth:grant-type:service-grant
grant_id=<id>

The minted token has sub = grantor and act = { sub_type: "user_id", sub: <grantee user id> } (§5.2). Enforcement is identical to §5, including the depth-one rule: the redeeming grantee’s own authentication chain must be plain or unrestricted application credential, exactly as §4.1 requires of a grantor, so a grantee’s delegated token cannot redeem another grant. This gives AWS AssumeRole / GCP service-account-impersonation semantics with mandatory audit and no impersonation-without-trace option, at almost no additional cost once §4–§8 exist. It is not required to retire trusts and is scheduled last.

10. How OpenStack services change

10.1 Common prerequisites

  • A SPIRE agent on every control-plane host with a registration entry per service (SPIRE plan Phase 1). Services fetch their X.509 SVID from the Workload API socket; keystonemiddleware’s SPIFFE transport (SPIRE plan Phase 4.1) already does this for the back-channel.
  • Each service registered once as an OAuth2Client with spiffe_ids and a DelegationPolicy (§3). This replaces the service user, its password, and its admin role assignment for delegation purposes; plain service-to-service calls keep using the mapping-engine identity.
  • Two new keystoneauth1 plugins (or one plugin with two modes):
    • v4servicegrant: inputs auth_url (internal interface), grant_id; authenticates with the SVID via tls_client_auth; caches the access token and re-redeems at exp - margin.
    • v4onbehalfof: inputs auth_url, the incoming user token; performs the exchange; re-exchanges its own output at exp - margin until max_operation_lifetime. Both present the result as Authorization: Bearer <JWT> to other services, which the ADR 0026 §6 offline filter or the auth_token fall-through accepts.

10.2 Heat (deferred authority)

  1. Stack create / adopt. heat-api holds the user’s token. Instead of POST /v3/OS-TRUST/trusts it calls POST /v4/service-grants with grantee_client_id = Heat’s cluster-unique client id (§3.2; discovered once via GET /v4/delegatable-services or configured), project_id = the request context project, roles = trusts_delegated_roles resolved from names to ids (the grant API takes ids only; Heat holds the user’s token and resolves them through the roles API at this step), description = stack id. It stores grant_id on the stack where trust_id / trustor_user_id are stored today. deferred_auth_method = service_grant.
  2. Deferred operation (autoscaling, convergence, timers, signals). heat-engine builds a session with v4servicegrant(grant_id). Every request to Nova, Neutron, Cinder carries a token with sub = stack owner, act = heat-engine. Nothing else in the engine changes.
  3. Stack update by another user. Create a new grant as the new user, swap grant_id, delete the old one, as Heat rotates trusts today.
  4. Stack delete. After resources are gone, heat-engine deletes the grant with its own SVID (§4.2 grantee delete). If the grant is already disabled because the owner lost roles, delete still proceeds for the owner, admins, or the grantee.
  5. Grant disabled mid-life (RoleLost). Redemption returns 403 with error=grant_disabled; Heat surfaces it as it surfaces a missing trust today and the stack owner creates a new grant via stack update.
  6. In-guest credential (independent half). Like Magnum (§10.3), Heat’s guest-side half is a workload-identity problem, not a delegation problem. Servers on the CFN transport carry a per-instance EC2 keypair whose access/secret are delivered in the server’s metadata (signal URLs are pre-signed from the same keypair); the heat and zaqar transports deliver the stack-domain user’s password instead. In current configurations the keypair is created by the stack-domain admin, never through the trust, so this half is not a gate on Phase 4; it is replaced by the VM’s project/instance SVID (SPIRE plan Phases 5/6, ADR 0032) presented as a JWT-SVID on the public interface. Only the legacy no-stack-domain configuration creates the keypair on the request session, where a delegated (trust-scoped) session stamps trust_id into the EC2 blob — the EC2 case Phase 4 rejects at /v3/ec2tokens and /v3/s3tokens.

10.3 Magnum

Magnum’s trust use splits across two independent halves, which may migrate at different times: the conductor’s deferred authority needs the grant (§5); the in-guest credential needs only the VM SVID (SPIRE plan Phases 5/6, ADR 0032). Either half may land first, but Phase 4 (trusts off) requires both: without the SVID path the in-guest components have no non-secret path left, because a grant redeems only against the service client bound to the conductor’s SVID (§3.1, §5) — a guest is not that client.

  • Conductor (deferred authority). magnum-conductor uses a service grant per cluster exactly like Heat (§10.2), created at cluster create, deleted at cluster delete, including the trust’s delete path, which uses the trustee when someone other than the creator deletes the cluster. The conductor’s SVID is the credential that redeems the grant (§5); it is not a substitute for the delegation, which only the grant record establishes.
  • Cluster nodes (in-guest credential). Magnum currently writes the per-cluster trustee user’s password and the trust id into every node’s cloud-init, and in-cluster components (cloud-controller manager, CSI, autoscaler) redeem the trust from inside the VM with that password. The cluster_user_trust config gates only the trust id; the password is written unconditionally. This half is a workload-identity problem, not a delegation problem: the components obtain a project/instance SVID from SPIRE and authenticate with a JWT-SVID through the existing JWT federation flow (ADR 0008) or client_credentials with private_key_jwt, mapped to a project-scoped identity by a mapping rule on spiffe.project_id. This half can land before the trust is gone: the SVID path is the password’s replacement, so Magnum can stop writing the password while the conductor still holds a trust. No Keystone credential is ever stored in the cluster afterwards.
  • Semantic shift. The trust Magnum writes today is created with impersonation=True, so in-cluster components act as the cluster owner with the owner’s roles. The SVID path acts as the VM itself: an ephemeral principal bound to the project, holding whatever the mapping rule grants (the SPIRE plan’s Phase 6 default is the limited compute-vm role). That narrowing is deliberate; a component whose work needs more gets a wider mapping rule or permission set, never a secret in the guest. Resources those components create, such as load balancers and volumes, are owned by the VM principal and not by the cluster owner, because services key ownership on user_id. Plan for that in the cluster’s ownership and quota handling.

10.4 Glance (continuation of a user operation)

  • On image import / task creation, glance-api holds the user’s token. It performs an on-behalf-of exchange (§6) with audience narrowed to the object store, and stores the exchanged token in the task context instead of a trust id. The trust that TokenRefresher creates on every image data upload is replaced by this exchange.
  • The task worker re-exchanges at exp - margin (§6.3) for as long as max_operation_lifetime allows; the operator sets that per the largest image they intend to support.
  • Copy-image and web-download follow the same path. Scheduled or operator-initiated cross-store copies with no user present use a service grant instead.

10.5 Nova and Cinder

Long operations (live migration, backups, volume retype) keep X-Service-Token plus allow_expired until they opt into on-behalf-of exchange; the two coexist. Migrating removes the need to honour expired user tokens at all, and gives the same act audit trail as the rest of this ADR.

10.6 Octavia, Nova-to-Neutron, and other pure service calls

No change and no delegation: these are the service acting as itself and are already covered by the mapping-engine is_system identity (ADR 0020 §6 use case 1). Octavia’s amphora certificates are out of scope.

10.7 Mistral, Aodh, Senlin, Sahara, Trove

Same shape as Heat: a service grant created when the workflow / alarm / cluster / instance is created with the user’s token, redeemed by the engine or conductor when it fires, deleted when the object is deleted.

10.8 What each service changes, and what it gets

ServiceTrust use todayChangeGain
HeatTrust per stack; trustee password in config; EC2 keypair in guest metadataGrant per stack, v4servicegrant session; JWT-SVID in the guestNo stored password, no guest secret, actor in every call, user-visible
MagnumTrust per cluster; trustee password and trust id in every nodeGrant for the conductor, JWT-SVID in the clusterNo Keystone credential inside the cluster
GlanceTrust for store accessOn-behalf-of exchange per taskNo stored record, bounded by the root token
Mistral, Senlin, Aodh, Sahara, TroveTrust per workflow or objectGrant per object, as HeatAs Heat
Nova, CinderX-Service-Token and allow_expiredOptional exchangeNo expired tokens accepted, actor recorded
Octavia and other pure service callsMapping-engine identityNoneNone needed

The three parties see different things. Operators register each service once, cap its roles and lifetimes, and get a single switch per service. Service developers swap one creation call and one auth plugin. End users get a list of the services that hold standing authority on their behalf, each with a description and an expiry, and can delete any of them.

11. Migration and deprecation of trusts

  1. Phase 1 — registry and grants. OAuth2Client extension (§3) including the normative SVID match (§3.1) and cluster-unique client_id / cross-domain resolution (§3.2), ServiceGrant resource, driver, API, policies (§4), redemption grant (§5), AuthenticationContext::ServiceGrant with the full ADR 0017 checklist and negative tests, lifecycle hooks and CADF events (§8), v4servicegrant plugin. Pilot: Heat.

  2. Phase 2 — on-behalf-of. Extend token_exchange.rs for plain and chained subjects (§6), AuthenticationContext::OnBehalfOf, v4onbehalfof plugin. Pilot: Glance import.

  3. Phase 3 — JWT-SVID client authentication. private_key_jwt with SPIRE JWT-SVIDs on the public interface (§5.1), including the RFC 7523 deviation, bundle verification, audience pinning and the assertion jti replay cache, for grantees outside the control plane. Pilot: Magnum in-cluster components. The pilot has no Phase 1 grant in the chain: the workload authenticates as itself.

    SPIRE preconditions, to be met before this phase ships:

    • JWT-SVIDs for these workloads carry the fixed Keystone token-endpoint audience, configured in SPIRE (§5.1).
    • SPIRE’s jwt_ttl is at most 300s. The stock default is an hour, which Keystone rejects at redemption.
    • Keystone can fetch and cache the trust-domain JWKS bundle.
  4. Phase 4 — trusts off by default. New config [trust] enabled (default true in this phase, false in the next). When disabled: POST /v3/OS-TRUST/trusts returns 403, existing trust tokens validate until they expire, and EC2 credentials whose blob carries trust_id are rejected at /v3/ec2tokens and /v3/s3tokens (Swift’s S3 API endpoint, which shares the same blob handling).

    EC2 under a service grant must carry the delegation, and gets the full treatment, not just a blob field. Storing service_grant_id is the smaller half:

    • Mint. An EC2 credential created while authenticated under a service grant records service_grant_id in its blob, server-managed and never client-settable, mutually exclusive with trust_id / app_cred_id / access_token_id, exactly as ADR 0019 §1 handles trust_id. A client-supplied service_grant_id is discarded on create.
    • Redeem. /v3/ec2tokens (and /v3/s3tokens, which serves the same blobs for Swift’s S3 API) must reconstruct AuthenticationContext::ServiceGrant from service_grant_id rather than falling through to AuthenticationContext::Ec2Credential. This mirrors the Trust / ApplicationCredential passthrough already documented on the Ec2Credential variant in crates/core-types/src/auth.rs, and it is what makes new_for_scope()’s bounded-object validation, the I4 role bounding (grant.role_ids ∩ grantor live roles), and the I2/I3 scope-drift tripwire apply unchanged. Reconstructing it also re-runs the §5.1 checks, so a disabled or deleted grant kills the EC2 credential’s authority immediately.
    • Redemption of a credential whose grant is gone is a 401, not a fallback to the grantor’s full roles. A missing grant must never widen authority.
    • Tests. Negative tests are required for: redeeming an EC2 credential whose grant was revoked; redeeming one whose grantor lost a delegated role (effective roles must be the intersection, never the grantor’s live set); a client attempting to set service_grant_id at create; and I6’s sha256(access) == id guard against a service_grant_id-bearing blob of the wrong type. These tests must exist before the grant kind ships.
  5. Phase 5 — human grantees (§9).

  6. Phase 6 — removal. Delete AuthenticationContext::Trust, ScopeInfo::TrustProject, TrustPayload, trust-driver-sql, /v3/OS-TRUST, the DelegationContext::Trust variant, and the rejection arm for trust subjects in token_exchange.rs (UnsupportedDelegation("trust")). The Ec2 variant and its rejection arm stay while plain EC2 credentials exist. Only after every deployment that shares a database with Python Keystone has moved trust creation off it.

Phase 1 implementation notes

Where the phase 1 implementation refines or departs from the decision above:

  • Grant domain. A grant lives in, and is redeemed at, its target project’s domain rather than the grantor’s (§3.2 and §4.1 were amended), so a user may delegate their roles on a project of another domain while each domain’s key still only signs tokens for its own projects.
  • cross_domain scope. The opt-in is required whenever the client’s domain differs from either the grantor’s domain or the project’s domain.
  • target_disabled reason. §8 disables grants when their project is disabled but names no reason; DisabledReason::TargetDisabled was added.
  • Partial role loss. The lifecycle hook disables a grant as role_lost only when the grantor holds none of its roles any more. On partial loss the grant stays enabled and tokens carry the remaining intersection, which redemption computes anyway. Redemption itself marks role_lost only when the request did not narrow the roles.
  • Grantee self-service. The grantee’s own GET/DELETE runs under a new AuthenticationContext::ServiceClient (unscoped, no roles) and reaches policy as auth_type: service_client. A missing grant answers 404 to it after the SVID is authenticated.
  • Client authentication. Only tls_client_auth with an X.509-SVID on the internal interface is implemented; JWT-SVID private_key_jwt is phase 3.
  • Errors and limits. A disabled grant or lost role answers 403 with error code invalid_grant. Redemption uses the token endpoint limiter’s quota with its own per-SVID key (service_grant:<svid>) rather than a separately configured limiter. A grant quota answers 429 without Retry-After, since waiting does not free quota.
  • Registry. Removing a client’s policy is remove_delegation: true on update (a JSON null cannot be told apart from an absent field). max_role_ids entries added by a write must be existing roles; ids already in the stored ceiling are not re-checked, so a role deleted later does not block unrelated edits.
  • List filter. domain_id on GET /v4/service-grants filters on the grant’s domain, which is the target project’s domain. Domain managers are authorized on that same domain.

Alternatives considered

Each was weighed against the weaknesses above and against the two requirements that most constrain the design: SPIFFE is not reachable by API users, and tokens will stop carrying scope.

  1. Keep trusts and require an SVID-bound trustee. This removes the secret and part of the missing operator control, and nothing else. The delegation would remain an OpenStack-only construct expressed as a token scope, with the impersonation, redelegation and use-counter switches intact and no act in the token. It also cannot survive scope-less tokens, because the meaning of a trust token is its scope.
  2. Application credentials for services. Bound to one user and one project, they carry a secret, record no actor, and cannot express one service acting for many users, which is what Heat does. ADR 0014 positions them as a step toward limited-access credentials, not as delegation to a third party.
  3. X-Service-Token with allow_expired. Fine for continuation and already supported, but it works by accepting expired user tokens, so the replay window of a captured token is set by a window parameter rather than by the token. It records no actor and cannot cover deferred authority. Nova and Cinder keep it until they opt into exchange (§10.5).
  4. RFC 8693 exchange only, with no stored record. There is no user token to exchange when Heat autoscales at night. Exchange is correct for continuation (§6) and cannot express deferred authority, so a stored grant is unavoidable for that case.
  5. OAuth2 refresh tokens held by services. A refresh token is a long-lived bearer secret per user, which is the shared-secret problem multiplied by the number of users. The rotation and family-breach detection in ADR 0026 §9 would also fire whenever two engine replicas raced to refresh the same token.
  6. Do nothing. Every weakness above stays, and the goal of scope-less tokens would need a separate answer for delegation anyway.

Consequences

Positive

  • No delegated secret exists anywhere. The grantee proves identity with an SVID that SPIRE rotates and that never leaves the workload. A leaked grant_id is inert.
  • Three trust feature switches disappear: impersonation, redelegation, remaining_uses. Chain depth is structurally one; the actor is structurally always recorded.
  • Users can see and revoke every standing delegation they hold, and operators can see and cap which services may hold any. Today neither is practical.
  • The design survives scope-less tokens because every delegation fact is a chain fact in delegation_context and the grant record is directly usable as runtime authorization input (§7).
  • All security-model invariants are reused, not re-derived. The new variants slot into the same new_for_scope() pipeline, Credentials projection, tripwire, and rego helpers as application credentials.
  • Service migrations are mechanical: replace the trust-creation call with a grant-creation call, replace the trust auth plugin with the grant plugin, store one id instead of three fields.
  • No writes to Python-owned tables; Fernet and v3 continue to work alongside.

Negative and risks

  • Bearer tokens remain bearer. SPIRE rotates certificates, so RFC 8705 certificate-thumbprint binding of the access token is not usable and the token is bound to the SPIFFE ID only through act. Mitigations: short TTL, audience narrowing, keeping redemption on the internal interface, and DPoP if ADR 0026 v1.5 lands.
  • A compromised service holds every grant it is grantee of until the operator disables its delegation policy, which stops all redemptions at once. Disabling a trustee user does the same for trusts today, so the kill switch is not new. What is new is that the stolen identity is an SVID bound to a host and rotated by SPIRE, where a stolen trustee password works from anywhere until someone rotates it.
  • Offline JWT revocation latency (ADR 0026 §11) applies to delegated tokens in full: deleting a grant does not shorten the life of tokens already minted from it, because nothing indexes minted jtis by grant (§8). The bound is the access-token lifetime. Deployments needing immediate revocation keep those services on the back-channel path, as the SPIRE plan already requires, or accept emergency key rotation as the only faster lever.
  • max_operation_lifetime is a tuning knob with security weight. Too long and a leaked on-behalf-of token can be refreshed until the ceiling; too short and large image imports fail. It is per service client, not global. Since §6.3, a leaked hop token is at least killable by revoking the root token — before that binding it would not have been.
  • Redemption traffic replaces credential caching. No refresh token means every active grant hits /token roughly four times an hour (§5.1). This is the deliberate price of making revocation effective, and it needs sizing at deployment time rather than discovery under load.
  • Upstream service patches (Heat, Magnum, Glance, keystoneauth1) are required before trusts can be turned off; §11 phases are ordered so each service can migrate independently.
  • Only keystone-rs honors grants. Python Keystone cannot validate a token that resolves to a grant, so a mixed deployment has to route those services to keystone-rs.
  • New state and a new driver crate, plus one more AuthenticationContext pair to keep in every match arm. The ADR 0017 checklist and compile-time exhaustive matches make omissions a build error rather than a runtime hole.

37. Application-Credential Access-Rule Enforcement

Date: 2026-09-14

Status

Accepted. Implemented in crates/core/src/api/auth.rs (enforce_access_rules, called from every return path of Auth::from_request_parts) and crates/core-types/src/application_credential/access_rule.rs (access_rules_permit, the pure matcher).

Reference

Closes the gap security review V5 (doc/src/contributor/security-review.md) and doc/src/contributor/security-model.md §5/§9 named as the single highest-impact known live gap: application-credential access_rules were stored and CRUD’d but never checked at request time. Extends ADR 0014 (Application Credentials) and ADR 0017 (Security Context).

Context

An application credential’s access_rules restrict it to a named subset of (service, method, path) calls — e.g. “this credential may only GET /v3/servers”. create_application_credential accepted, stored, and returned these rules from day one; nothing ever matched an incoming request against them. A rules-restricted credential could call any endpoint its underlying roles allowed, silently converting a control the operator believed was active into a no-op.

The interim mitigation (security review V5, shipped before this ADR) made that dishonesty visible rather than closing it: create_application_credential logs a WARN on any non-empty access_rules list, and application_credential.reject_unenforced_access_rules (default false) lets an operator fail the create outright. Neither enforces anything; both remain in place as they were, unrelated to whether enforcement exists.

Why Keystone can only enforce its own service’s rules

In a full OpenStack deployment, an access-rules-restricted credential is normally used against other services (compute, image, …), each fronted by keystonemiddleware, which fetches the token’s access_rules via token introspection (GET /v3/auth/tokens) and matches the incoming request itself before it reaches that service’s application code. Keystone is not in that request path and cannot enforce those rules — it never sees the request.

The gap this ADR closes is narrower and Keystone-local: when an access-rules-restricted credential calls Keystone’s own API directly (creating a trust, listing users, …), there is no middleware in front of Keystone to do this check — Keystone is both the token issuer and the resource server, so it must self-enforce. A rule naming a different service type is therefore not an error and not silently ignored; it simply never matches a request Keystone itself receives, exactly as intended (that restriction is a different service’s job).

Why this is not a tower/axum middleware layer

ADR 0022 (rate limiting) already rejected generic middleware layers for security-decision logic in this codebase, because they run before handler extraction and can’t easily reach request-specific context. The same argument applies here in reverse: the context this check needs (the resolved ValidatedSecurityContext, specifically whether the caller is an ApplicationCredential with rules) is exactly what Auth’s own FromRequestParts implementation produces. A separate middleware layer would have to re-run authentication itself (re-decoding the Fernet token, a second authorize_by_token round trip) to get that context independently — doubling authentication cost on every request, most of which are not even application-credential calls, to reach data Auth already has for free.

Instead, enforce_access_rules runs inside Auth::from_request_parts, immediately before every one of its return paths (including the #[cfg(test)] mock-injection shortcut, so handler tests can exercise it without extra plumbing). This is still “before handler dispatch” — Auth is an extractor, and extractors run before the handler body — but it costs zero extra authentication work and cannot be skipped by a handler that forgets to call something, because every handler that needs the caller’s identity already extracts Auth.

Decision

The matcher (access_rule.rs)

#![allow(unused)]
fn main() {
pub fn access_rules_permit(rules: &[AccessRule], method: &str, path: &str) -> bool
}

A pure function, no I/O, easy to exhaustively unit test. A rule matches when:

  1. rule.service case-insensitively equals OWN_SERVICE_TYPE ("identity", Keystone’s own catalog service type). A rule naming any other service never matches here (see “Why Keystone can only enforce its own service’s rules” above).
  2. rule.method case-insensitively equals the request method.
  3. rule.path matches the request path segment-by-segment, per the wildcard syntax already documented on AccessRule::path:
    • a literal segment must match exactly,
    • {tag} (any name) or * matches exactly one path segment,
    • ** matches zero or more segments, including across /.

A rule missing method, path, or service never matches anything — those fields are required by the API layer at creation time, but this function fails closed on a malformed stored rule rather than treating a missing field as “matches anything”.

access_rules_permit returns true if any rule in the list matches (the rules are a permitted-actions allowlist, not an ordered chain).

The enforcement point (auth.rs)

#![allow(unused)]
fn main() {
fn enforce_access_rules(
    vsc: &ValidatedSecurityContext,
    parts: &Parts,
) -> Result<(), KeystoneApiError>
}

A no-op unless vsc’s AuthenticationContext is ApplicationCredential with a non-empty access_rules list. When it applies and no rule permits (parts.method, parts.uri.path()), returns ApplicationCredentialProviderError::AccessRuleDenied, converted to KeystoneApiError::Forbidden (HTTP 403) — the same status code an OPA policy denial produces, so a client cannot distinguish “the policy denied this” from “your access_rules don’t cover this” by status code alone (deliberately: neither should leak more about why than a generic 403).

This runs before OPA policy evaluation (enforce()), not after: an access-rules-restricted credential that is technically authorized by policy for an endpoint outside its rules must still be denied, and there is no reason to pay for a policy round trip on a request that is going to be rejected regardless.

Enforcement is unconditional once access_rules is non-empty — it does not read reject_unenforced_access_rules. That flag’s purpose was to let an operator fail loud at creation time, while enforcement did not exist yet; now that enforcement exists, honoring a restriction the operator explicitly set is not something a config flag should be able to turn off. The flag stays for backward compatibility of the create-time API but no longer describes the true state (“unenforced”) — see the doc-comment fix in crates/config/src/application_credentials.rs and security-model.md §9.

Interaction with rescope and reauth

An application-credential token can be reauthenticated (token-from-token) but not rescoped (security-model.md §5). enforce_access_rules reads the rules straight from the AuthenticationContext::ApplicationCredential payload, which is carried unchanged through both the initial authentication and any reauthentication (ApplicationCredentialPayload, security-model.md §5’s table) — so the restriction applies identically regardless of how the current request’s token was obtained, with no separate rescope-specific logic needed.

Consequences

  • Closes the gap. An access-rules-restricted application credential can no longer call a Keystone endpoint outside its declared rules; the previous no-op silently accepted a call the operator believed was restricted, this now rejects it (HTTP 403).
  • Scope is deliberately narrow. This ADR does not implement or claim to implement enforcement for other OpenStack services — that remains keystonemiddleware’s job in each service, unchanged.
  • No migration needed. Because enforcement previously did not exist, there is no prior “working” behavior this could regress for a caller that actually depended on it; any application credential whose calls now fail as AccessRuleDenied was already calling outside a restriction its creator declared, which was always the intended behavior once enforcement shipped.
  • security-model.md §9’s “Known open gap” entry for access_rules is closed by this ADR; the entry is updated to record that.

38. Unified Limits

Date: 2026-10-03

Status

Accepted.

Reference

Closes the “Limits” gap of the Python API compatibility list (#938, #1091).

Context

Python Keystone exposes the “Unified Limits” API: operator defined registered_limits (service wide default for a resource), per project or domain limits that override the default, and a read-only enforcement model discovery. Consumers (oslo.limit, Nova, Glance, …) rely on it.

Decision

A new limit provider (crates/core/src/limit) with a pluggable backend and an SQL driver (crates/limit-driver-sql) is added. The SQL driver reuses the Python tables (registered_limit, limit) and therefore creates them through entity synchronisation (keystone-manage db sync), never through versioned migrations.

API (v3, Python compatible)

EndpointOperations
/v3/registered_limitslist (filters service_id, region_id, resource_name), batch create
/v3/registered_limits/{id}show, update, delete
/v3/limitslist (filters additionally project_id, domain_id), batch create
/v3/limits/{id}show, update, delete
/v3/limits/modelenforcement model discovery

Behaviour

  • Limit values are integers in [-1, 2147483647].
  • Referenced service, region, project and domain must exist (HTTP 400). A project_id referencing a domain-project is stored as domain_id.
  • Registered limits are unique by (service_id, resource_name, region_id), limits are unique per registered limit and project or domain (HTTP 409).
  • A limit refers to the registered limit with identical service, resource name and region. Absence of one is an error (HTTP 400).
  • A registered limit referenced by limits can neither be updated nor deleted.
  • Batch creation is atomic.
  • The registered_limit and limit tables are shared with Python Keystone and carry no database unique constraint. Uniqueness is checked inside the create transaction, so two concurrent creates of the same key can both succeed (same as Python). Closing this needs a schema change and is out of scope.
  • Nullable columns of legacy rows (service_id, resource_name) map to empty strings instead of failing the read.
  • Update applies explicitly provided values, including 0 and an empty description (Python silently ignores falsy values).
  • Enforcement model ([limit] enforcement_model): flat (default) or strict_two_level (project limit may not exceed the limit of the parent domain, domain limit may not be below any of its projects’ limits).
  • Visibility of limits in the list is bound to the token scope: system scope or admin see everything, a domain scope sees limits of the domain and its projects, a project scope sees limits of the project only. Every list item is re-checked with the show policy.

Policies

identity/registered_limit/{create,list,show,update,delete}, identity/limit/{create,list,show,update,delete,model}. Mutations require the admin role (or system admin), reads are allowed for any authenticated user with the scope restrictions described above.

39. Config engine and section registry

Date: 2026-10-05

Status

Accepted.

Reference

Tracking issue #1416. Builds on ADR 0018 (linker-anchored backend registration), ADR 0025, ADR 0033 and ADR 0034.

Context

openstack-keystone-config mixes a generic engine (INI + site-vars + OS_* overlay, Vault resolution, ConfigManager watch/reload) with the Keystone schema (~45 sections in one Config). Seventeen crates depend on it, including core-types. Driver config types (OpenFGA, distributed storage) live centrally and drivers import them back, so every driver option is a change to the central crate. Moving the storage types into the storage crate would create a config -> storage -> config cycle while Config still has a distributed_storage field. BackendRegistration takes &Config, so drivers reach into the monolith.

Decision

Option A of #1416: a generic engine crate plus a link-time section registry. Rejected: a runtime-typed global bag (loses typed core access), an extension flatten bag (no hooks, untyped), and the status quo (does not scale).

Crates

The engine crate is named oslo-config, after the Python library whose model (modules register their own option groups, one file, live reload) it implements.

oslo-config                      engine, no feature dependencies
  ^
openstack-keystone-config        Keystone schema: core sections, ConfigView alias
  ^
core, keystone
  ^
*-driver-*                       own section type + register_backend!

A section stays in the schema crate (or a shared leaf) when the core or more than one crate reads it (e.g. database, identity, token, and LdapProvider, which core-types uses for API-stored domain config). Only single-owner sections move to their crate. A section whose owner is a library crate rather than a driver (distributed_storage, owned by openstack-keystone-distributed-storage) is registered the same way. It is not a section type that every consumer needs from a shared leaf: the schema crate drops the field, so the cycle above disappears, and the startup code, the raft listener and keystone-manage read it with view.section::<DistributedStorageConfiguration>().

ConfigSection

The INI section name is bound to the type, so a lookup can never be a type mismatch. Absence follows today’s serde behaviour: a section type that implements Default is optional and is materialized from S::default() when its INI section is missing (the #[serde(default)] case); a section type without Default is required, like auth today. The type therefore decides, and the driver never handles Option:

#![allow(unused)]
fn main() {
pub trait ConfigSection: DeserializeOwned + Send + Sync + 'static {
    const NAME: &'static str;
    fn finish(&mut self, _ctx: &LoadCtx) -> Result<(), ConfigError> { Ok(()) }
    fn watch_files(&self) -> Vec<PathBuf> { vec![] }
    fn validate_with(&self, _sections: &SectionBag) -> Result<(), ConfigError> { Ok(()) }
}
}

validate_with runs in a second pass after all registered sections are materialized and sees its siblings through the SectionBag. (It is not named validate to avoid clashing with validator::Validate on types that derive both.) Cross-checks that need the core schema stay in CoreSchema::finish_load.

Registration

A driver crate registers its section type with register_section! (link-time, inventory, same linkage rule as ADR 0018). BackendRegistration<B> stays non-generic and its selected/build hooks receive a &ConfigView, so a driver reads its own section with view.section::<S>() (or view.require::<S>() for an error naming the missing section) next to the core config. A section type with a Default is materialized from it when absent (register_section!(S, default)); one without is absent from the bag. A driver that needs a required section reads it with view.require::<S>() in its build, so selecting the driver without the section fails the startup with an error naming the section (OpenFGA does this; its selected predicate keeps the section optional for deployments that do not select the global driver).

#![allow(unused)]
fn main() {
pub struct BackendRegistration<B: ?Sized + 'static> {
    pub name: &'static str,
    pub selected: fn(&ConfigView<'_>) -> bool,
    pub build: fn(&ConfigView<'_>) -> BuildFuture<B>,
}
}

Loading

Raw pipeline (file, site-vars, env; prefix parameterized) -> Vault resolution -> core parse -> every linked registered section -> validation pass. All linked sections are parsed, selected or not, so a malformed section of an unselected driver fails the load, as it did with the central schema. Env override, site-vars and Vault therefore apply to driver sections unchanged. The vault cargo feature of oslo-config (on by default) gates the resolution; without it a configuration that contains a vault:// reference fails the load instead of passing the literal string on. ConfigManager<C> keeps the core struct and the section bag in one snapshot, so reload swaps both atomically (last-known-good, deadlock avoidance and Vault teardown behaviour are unchanged). Registered sections are re-parsed and re-validated on every reload and watched files of driver sections are part of the watch set, so a driver section reloads like any core section.

Vault token lifecycle: the live runtime is created with the initial load, replaced on every reload, and revoked on ConfigManager::shutdown. A runtime that is replaced or dropped without a shutdown (one-shot loaders, the predecessor of a reload) is not revoked and its token lives out its TTL.

Per-domain blocks

AssignmentBackendConfig becomes Sql | Named { driver, config: ParsedSection }. A driver registers the type its blocks are parsed into with register_block!("assignment.backends", "openfga", S); the schema deserializes the driver discriminator and delegates the rest to the registered type at load time. The block is downcast to the driver’s own type at dispatch, so the same type serves [openfga] and [assignment.backends.<name>]. A block naming a driver that is not linked fails the load.

Fail-loud rules

  • Driver selected => its section is materialized: from the INI when present, from Default when absent (as today); a required section without Default that is absent is a startup error raised by the driver’s build through view.require.
  • Unclaimed top-level section (neither a core section nor a registered one, for example a misspelled [openfga_typo]) => warning on every load of the watching manager (server startup and each reload). One-shot loaders (load_snapshot_from, e.g. keystone-manage) skip the warning: their link-time claim set is smaller than the server’s (keystone-manage links neither the JWS token driver nor the OpenFGA assignment driver), so a valid [jws_tokens] or [openfga] section would be reported as unclaimed; a misspelling still surfaces on the server’s next load. It is a warning, not an error, so a reload of a configuration that carries a section of a driver this binary does not link keeps working. A present section whose driver is not selected is not reported.
  • Duplicate NAME across descriptors, and reserved names (DEFAULT, database, auth, …) => startup error.
  • Startup assertion that expected section names are registered (catches anchor() regressions). keystone/build.rs anchors only *-driver-* crates, webauthn and distributed-storage, so a section registered by any other crate (the next one is audit, owned by cadf) must, in the same change, expose anchor(), be added to the build.rs allow-list and be listed in the assert_registered call in server/startup.rs. keystone-manage does not use the generated anchors and calls the owner’s anchor() itself. Otherwise a linker-stripped registration silently materializes a default section.
  • CoreSchema::reserved_sections() mirrors the fields of the core schema; a unit test in the schema crate fails when the two drift apart.

Non-goals

Section registration is link-time only (no loading of section types after link). This is not a limit on reloading: live reload of the whole configuration, driver sections included, works exactly as it does today (see Loading). No change to INI/env/site-vars semantics; no change to per-domain assignment semantics or wire format.

Consequences

  • A new driver is one crate with no central-config diff.
  • core-types stops depending on the Keystone schema (it depends on oslo-config and shared leaf section types only). Not yet true: it still imports Config and the identity/LDAP/security-compliance types. That part of WP-5 remains. crates/storage no longer reads Config fields: it takes the [distributed_storage] section (the ConfigManager only supplies the snapshot and the reload notification).
  • Plugin interface changes from &Config to &ConfigView across ~31 files (mechanical).
  • Any linked crate can register a section name; same trust level as backend registration, mitigated by the rules above.

A ConfigManager built with not_watched(Config) has an empty section bag, unlike watched() which materializes the registered sections. Tests of a driver that requires its section must assemble a SectionBag (or use load_snapshot_from); the storage crate offers config::config_manager and config::config_manager_with for that.

40. OpenTelemetry as the telemetry pipeline

Date: 2026-10-06

Status

Proposed.

Reference

Tracking issue #1411. Related: #720 (metrics example), #934 (tracing instrumentation), #1409 (cadf OTel prototype), ADR 0031 (Prometheus metrics), ADR 0039 (config engine and section registry).

Context

Keystone exposes metrics through openstack-keystone-metrics: about 500 lines of atomic counters, fixed-bucket histograms and Prometheus text rendering, roughly 40 instruments in core, storage, cadf and keystone, each subsystem hand-writing its own format_prometheus_text. Tracing uses the tracing crate (about 440 #[instrument] sites), but server/startup/tracing_init.rs installs only fmt layers: nothing leaves the process as a trace.

The OpenStack ecosystem is converging on OpenTelemetry. oslo.middleware ships oslo_middleware.tracing.TracingMiddleware, which creates one SERVER span per request, propagates W3C traceparent/tracestate and exports over OTLP, and is configured by an [oslo_middleware_tracing] group (enabled, otlp_endpoint, otlp_protocol, service_name, sampling_rate, insecure). Operators who run it on other services expect Keystone to take the same configuration and join the same traces.

Decision

OTLP is a first-class output for both traces and metrics. Option 4 of #1411 is adopted: openstack-keystone-metrics is replaced by the OpenTelemetry SDK. The /metrics Prometheus endpoint stays, as a pull view of the same SDK data.

Crate and features

A new crate openstack-keystone-telemetry owns the Resource, the TracerProvider, the MeterProvider, the OTLP exporters, the propagators and the shutdown flush. tracing_init::init builds it and returns a guard that also holds the file-appender WorkerGuard; the guard is flushed from the graceful shutdown path in the listeners.

Cargo features, all off by default (keystone forwards them): otel exports over OTLP/HTTP (protobuf) and otlp-grpc adds the gRPC transport (tonic, aws-lc-rs TLS to match the rest of the workspace). Without them the configuration is still parsed and validated, and a configuration that asks for export logs that this build cannot. The SDK is built on opentelemetry and opentelemetry_sdk 0.33 with opentelemetry-otlp using the workspace’s reqwest and rustls, so no second HTTP client or crypto provider is added.

The batch span processor and the periodic metric reader run on their own threads, with bounded queues that drop on overflow, so export never blocks a request. Startup builds the pipeline inside the tokio runtime; the flush runs on a blocking task at shutdown. A pipeline that cannot be built is logged and the service starts without telemetry.

Configuration

A native [otel] section, registered through the section registry (ADR 0039): enabled, traces_enabled, metrics_enabled (both default to enabled), endpoint, protocol (http/protobuf or grpc), headers, timeout, insecure, service_name (default keystone), resource_attributes, sampler, sampling_rate, span_level, metrics_interval, include_user_ids (default false; also gates the raw url.path), include_client_address (default false), legacy_http_attributes and response_traceparent.

insecure follows oslo.middleware: it applies to gRPC only (connect without TLS) and is ignored, with a warning, for HTTP, where the URL scheme decides. span_level (default info) is the most verbose span the exporter receives; about two thirds of the #[instrument] sites are debug, so the default keeps driver-level spans local. For HTTP the signal path (/v1/traces, /v1/metrics) is appended to endpoint, as oslo.middleware does.

[oslo_middleware_tracing] is a registered alias section:

[oslo_middleware_tracing][otel]
enabledenabled (traces)
otlp_endpointendpoint
otlp_protocolprotocol
service_nameservice_name
sampling_ratesampling_rate, wrapped parent-based
insecureinsecure

If both sections set the same option the native one wins and a warning is logged. The alias drives traces only, as in oslo.middleware. osprofiler (X-Trace-Info/X-Trace-HMAC) is not supported.

Traces

A tracing-opentelemetry layer is added to the registry with its own filter: the deps_targets pinning applies to spans too, plus the span_level cut-off.

TraceLayer keeps making the request span for the local logs. It has client.addr and the raw uri as fields, and the exporter sends every field of a span it sees, so that span is excluded from export (and the new span is excluded from the log sinks, where it would only add noise to the span context). Export gets a dedicated http.server span from a small middleware inside TraceLayer, installed only while traces are exported:

  • it is named {method} {route} with route the Axum MatchedPath template (the oslo middleware uses the raw path, which is unbounded), or just the method when no route matched;
  • it carries http.request.method (a standard method or _OTHER), http.route, http.response.status_code, user_agent.original (cut to 256 characters) and openstack.request_id, and is marked as an error for 5xx responses only;
  • the remote parent comes from the traceparent and tracestate request headers;
  • with legacy_http_attributes it also carries http.method, http.user_agent and http.status_code, the names oslo.middleware uses;
  • with include_client_address it carries client.address, the caller’s IP without the port (the proxy-resolved address when proxy header parsing is on; nothing on the admin Unix socket);
  • with include_user_ids it additionally carries the raw url.path (resource ids are in it), and openstack.user_id, openstack.project_id and openstack.domain_id, recorded where the request authenticates. Without the option those fields are not declared on the span, so nothing can record into them. The provider and driver spans record the ids they were called with at debug level, and a span field cannot be switched off at runtime, so the exporter removes the attributes user_id, project_id, domain_id, user_name and params (a list filter, which holds names and ids) from every span unless the option is on. They stay in local logs;
  • response_traceparent echoes the span’s traceparent in the response, off by default.

openstack.global_request_id is not set: Keystone strips a client-supplied x-openstack-request-id (ADR 0023 section 2.1), so there is no trusted global id to report.

Only spans are exported, never events. The exporter turns an event inside a span into a span event with all of its fields, which would ship log lines (the access log carries the raw URI, error logs carry error bodies) off the host. Events stay in the log sinks.

The x-request-id layer and the access_log ordering are unchanged.

Trace context is untrusted client input. A malformed traceparent is ignored. A valid one chooses the trace id and, under the default parent_based_ratio sampler, the sampling decision, so a client can ask for its own requests to be sampled. Operators who do not want that use the ratio sampler. Trace context never influences authorization (security-model invariants). #[instrument] sites on authentication, token, credential, EC2 and trust paths must skip secrets, because spans now leave the host.

Span layers

A request produces at most one span per layer, so a trace reads GET /v3/users → api::user_list → provider.identity.list_users → driver.sql.identity.list_users, and each layer says something different:

LayerWhereNameWhat the span tells
HTTPhttp.server middleware{method} {route}the request as a whole
API handlercrates/keystone/src/**/apiapi::<operation>auth, policy and (de)serialization
Providercrates/core/src/<domain>/service.rsprovider.<domain>.<method>business logic, caching, driver orchestration
Drivercrates/*-driver-*driver.<backend>.<domain>.<method>the call that leaves the process: SQL, LDAP, OpenFGA, Raft

Rules:

  • Instrument the methods of the provider trait impl (impl *Api for *Service) and of the driver trait impl (impl *Backend for ...), not the private helpers behind them. The helpers show up as time inside the layer’s span. Drivers that only do local work (token-driver-fernet, token-driver-jws) have no span, the provider span covers them.
  • Spans from the provider and driver layers are level = "debug", as are most handler spans, so span_level = "info" keeps little more than the HTTP span and debug shows the whole stack.
  • Use skip_all and list the fields to record: the *_id arguments, a *Parameters filter (with ?params), and a plain name. Never record a request body, a secret or the execution context. A bare skip(self, state) records every other argument with Debug, so a new field on a request type would be exported silently.
  • Methods that run in a loop within one request, such as the implied-role walk (list_role_imply_rules_by_prior), use level = "trace", otherwise one request produces dozens of identical spans.
  • Spans are the only thing exported (see above). The sqlx statement log is an event, so database timing is not in the trace unless the driver span is. That is why SQL drivers keep a span per method instead of leaving it to the provider.

Metrics

Each instrument becomes an SDK instrument built from one global Meter. Histograms keep the existing DEFAULT_LATENCY_BUCKETS as explicit bounds.

The ADR 0031 cardinality rule was enforced by types (const N label names, callers pass &str). It stays type-level: openstack-keystone-telemetry provides typed attribute helpers that accept only &'static str or enum-backed label values, never an owned String.

/metrics is rendered from the same MeterProvider as the OTLP export, by a small in-tree encoder with explicit series naming (_total for counters, _bucket/_sum/_count for histograms; an instrument is registered under exactly the name it exposes, _total included, so counters that predate the convention keep their name). The SDK’s pull reader (ManualReader) is behind an experimental cargo feature in the version used, so it is not used. The provider has one stable PeriodicReader whose exporter renders the Prometheus text into a snapshot on every collection and forwards the data to the OTLP exporter when one is configured. A scrape forces a collection and reads the snapshot; forced collections are not forwarded, so scraping causes no extra OTLP requests. deploy/prometheus/alert_rules.yaml and ADR 0031 names are preserved, enforced by a golden test captured from the current implementation before the migration. A third-party Prometheus bridge crate is not used.

The metrics SDK (not the OTLP exporters) is a regular dependency of openstack-keystone-telemetry, so /metrics works in every build and there is one code path. The pipeline is process-wide and created pull-only on first use; OTLP push needs it to be created by telemetry init, so instruments must not be used before init. Differences from the hand-rolled primitives: a series exists only after it was first recorded (a series that must read zero is recorded with add(0, ..)), and a bounded label whose value is chosen at run time (idp_id) is passed through Label::bounded, which marks it for review.

Subsystems that already keep their own state (the audit dispatcher’s atomics, the live openraft metrics) expose it through observable instruments read at collection time, so the hot paths do not touch the SDK. The output differs from the hand-rolled text in presentation only: label names are sorted, families are ordered by name, and a family with no sample yet (the two audit counters labelled by result) has no # HELP/# TYPE header. Every series name, label and value is unchanged, which the golden files check. The Raft series were not served before (the formatter was never called) and now are.

Operators scrape /metrics or push OTLP, not both for the same series, or a collector that re-exposes Prometheus double counts.

Exemplars (trace-linked histogram buckets) are the main capability gained. Their stability in the Rust SDK has not been verified; if they are not ready they are follow-up work, not a blocker.

Rollout

  1. Golden /metrics test and the benchmark harness (tests/otel-bench).
  2. This ADR.
  3. openstack-keystone-telemetry, [otel] and alias sections, the otel feature, the guard and shutdown flush.
  4. Traces: layer, MakeSpan, propagation, #[instrument] audit.
  5. Metrics, part 1: typed wrappers, the pull reader and encoder, /metrics on the SDK with one subsystem migrated.
  6. Metrics, part 2: remaining subsystems, grouped by crate.
  7. Remove openstack-keystone-metrics; re-run the golden tests. The golden helper moved to openstack-keystone-telemetry (golden feature), and the benchmark keeps only the span cases, since the comparison needs the removed crate.
  8. Operator documentation (admin/features/opentelemetry.md), including migration from [oslo_middleware_tracing] and a collector example.

Benchmark

tests/otel-bench (standalone crate, cargo bench inside it) measured the hand-rolled primitives against SDK synchronous instruments before the decision, and measures the cost of the OTel span layer. The instrument comparison needs openstack-keystone-metrics, so it can only be re-run from the commits before that crate was removed; the numbers below are the recorded result. Run on a shared 4-vCPU cloud VM, criterion, 3 s measurement per case, single run. Treat as indicative: the VM is noisy and the contended cases in particular vary between runs. Versions: opentelemetry and opentelemetry_sdk 0.33, tracing-opentelemetry 0.34.

Instruments (median per call; the contended rows are the slowest of 4 threads hitting one series):

Casekeystone-metricsOTel SDK
counter, unlabeled6.4 ns12.0 ns
counter, 2 labels81 ns103 ns
histogram, 1 label106 ns72 ns
counter, 2 labels, 4 threads1.31 us0.75 us
histogram, 1 label, 4 threads0.68 us1.37 us

Spans (one two-field span, enter and exit):

CaseCost per span
no subscriber1.9 ns
bare registry (no consumer)0.2 - 1.4 us (bimodal, noisy)
OTel layer, sampler off1.25 us
OTel layer, ratio 0.11.30 us
OTel layer, always on, batch processor1.97 us

Reading:

  • Synchronous SDK instruments are in the same range as the current atomics: slower on the cheapest cases, faster on the labeled histogram and the contended counter. All are tens to hundreds of nanoseconds, against token validation and policy evaluation paths that take microseconds to milliseconds. No hot-path regression is visible at this level, so the SDK instruments are adopted directly. The observable-callback fallback (keep atomics, publish through callbacks) is not needed and is dropped.
  • The span layer costs about 1.2 us per span even when the sampler drops the span, because tracing-opentelemetry builds span data before the sampling decision. With about 440 #[instrument] sites, a request that crosses 20 spans pays roughly 25 - 40 us. The OTel layer therefore gets its own filter that admits only request-level and coarse operation spans (INFO and above for Keystone targets), not every driver-level DEBUG span; spans the layer filter rejects do not reach the layer and cost the same as today.
  • The result must be re-measured end to end with tools/run-loadtest-local.sh once step 4 lands, with the layer off, on with ratio sampling, and always on.

Consequences

  • One pipeline and one resource identity for traces and metrics; OTLP push works without a scrape path, and Keystone joins traces started by other OpenStack services using oslo.middleware tracing.
  • About 500 lines of exposition code and the per-subsystem format_prometheus_text implementations go away; a smaller encoder and a golden test replace them.
  • New dependencies (opentelemetry, opentelemetry_sdk, an OTLP exporter, tracing-opentelemetry) behind the otel feature. The default build is unchanged except that the metrics SDK, without any exporter, is always compiled in: /metrics is served from it in every build.
  • The bounded-label rule moves from a type-level check in one crate to typed helpers in another; reviewers still check that label sources are bounded.
  • ADR 0031’s “continue hand-rolled exposition” decision is superseded; its catalog, naming and cardinality rules still apply.

41. OAuth2 provider session management

Date: 2026-10-12

Status

Accepted.

Reference

Tracking issue #1482. Related: ADR 0026 (OAuth2/OIDC provider), #1475 (RP-initiated logout), #1493 and #1496 (back-channel logout).

Context

RP-initiated logout and a first back-channel logout existed: a logout token was sent to the clients of a user, without a sid, only on logout or user lifecycle events. Missing were session-scoped notification, notification when a session ends without a browser, front-channel logout and ending the session at an upstream identity provider.

Decision

  • sid. The id token carries sid = base64url(SHA-256("keystone-oauth2-sid\0" || sso_ref || 0x00 || client_id)), where sso_ref = hex(SHA-256(sso_id)). The SSO id is the bearer cookie value, so it never appears in storage keys, audit events or tokens; only its hash does. A client sees a different sid than any other client.
  • Relying-party index per SSO session. The Raft index key is oauth2:rp_idx:v1:{user}:{sso_ref}:{client}; keys of the previous layout ({client} only) are still read as sessions without a sid.
  • One path for ending a session. end_sso_session serves explicit logout, expiry, eviction, janitor purge and domain removal and emits an Oauth2SsoSession event; the back-channel hook turns it into logout tokens. A replaced session emits an audit-only event and keeps its relying-party records so a later user-wide logout still reaches them.
  • Session-scoped notification rule. A client that does not require sid may read the token as “log out everywhere”, so for a single ended session it is only notified when the user has no other SSO session. Clients requiring sid are always notified with it. Failure to list the other sessions counts as “has other sessions” (never log the user out of unrelated sessions).
  • Retries are in memory. 2 s and 10 s, then dropped with a warning. A persistent queue would need a replicated outbox; deferred.
  • Front-channel. Rendered by the logout page as hidden frames, with a per-response CSP frame-src limited to the clients’ origins and a meta refresh (the pages run no scripts) to the post_logout_redirect_uri.
  • Upstream logout. Opt-in ([oauth2] upstream_logout). Discovery is repeated at logout instead of persisting end_session_endpoint with the IdP (no federation schema change). The way back is a fixed logout/complete URI; the relying party’s redirect waits in a short-lived HttpOnly cookie that is not trusted and is validated against the client again.

Consequences

  • Several browser sessions of a user no longer log each other out at relying parties that support sid.
  • Clients and sessions are stored in Raft only, so there is no SQL migration; new client fields are trailing and defaulted.
  • Back-channel notifications can be lost when a node stops during the retry window.
  • Upstream logout needs the logout/complete URI registered at the IdP, and sends no id_token_hint because the upstream id token is not stored.

42. Provider event delivery and delete cascades

Date: 2026-10-12

Status

Accepted. Amends ADR 0023 (audit) and ADR 0035 (relation sync provider).

Reference

Tracking issue #1425. Related: ADR 0017 (security context), ADR 0019 (credentials), ADR 0023 (audit), ADR 0035 (relation sync provider), ADR 0038 (unified limits).

Context

Issue #1425 reported that ProviderHooks were not notified for authenticated API calls and that the dependent objects of a deleted resource were not cleaned up. Four separate problems sat behind it.

  1. Dispatch gap. audited_op! only calls emit_critical, the fail-closed AuditHook channel of ADR 0023. ProviderHooks were notified only on the branch without a security context, so every authenticated call, which is nearly all real traffic, never reached a hook. Per-site workarounds existed for create_domain, delete_domain and delete_project only.
  2. Empty hooks. Of the ProviderHooks implementations, only the ones for limits, OAuth2 keys, OAuth2 sessions, dynamic plugin identity and back-channel logout did any work. Identity, assignment, application credential, trust, role, resource, token, revoke and the rest were empty. Deleting a user left its application credentials, trusts and role assignments behind; deleting a domain left its projects, users and groups.
  3. Lossy delivery. EventDispatcher::emit spawns every hook as a task and discards the result. There is no ordering, no retry, and a crash between the operation and the hook loses the event. ADR 0035 already concluded that work which must happen cannot live in a hook and uses an outbox with the hook only as a wake-up.
  4. No enforcement. The audit coverage test guaranteed that every mutating provider method was audited. Nothing guaranteed that it notified hooks.

Decision

1. One dispatch point; audit stays separate

audited_op! is not extended to notify hooks. It is the audit primitive: it needs a ValidatedSecurityContext, it is also used for non-mutations (authentication, the CADF hook itself), and the audit event is not the hook event (update_user audits one Update, while hooks need Disable, password_change or mfa_enabled). Merging the two would also not fix problems 2 and 3, and internal callers (janitors, SCIM, hooks) have no context and would still need their own emit.

Instead:

  • audited_if_ctx! is the single wrapper for state-changing provider operations. It runs the audited operation when there is a security context and the bare operation when there is not, and on success in both cases emits to ProviderHooks. An optional hook_events: argument replaces the default (the audit event) when one operation needs several hook events.
  • audited_notify_op! is audited_op! followed by the same emit, for the sites that keep an explicit if/else.
  • The emit happens strictly after the audited operation succeeded, never on Err.

2. Enforcement

audit_coverage.rs rejects a bare audited_op! in a service method: it must be audited_if_ctx! or audited_notify_op!. A method that only calls other audited operations (a cascade) is allow-listed with a reason.

3. Cascades are inline, not hooks

A hook cannot carry a cascade whose absence is a security problem: delivery is at-most-once and the error is swallowed. So:

  • Cascades that must not be missed run inline in the owning service, in the same audited operation, and their errors propagate. The operation fails and can be retried; the audit event is emitted only after the cascade succeeded. delete_credentials_for_user was the template.
  • Each cascade goes through the owning provider’s own audited operation and does not reach into its storage. Assignments are revoked with revoke_grant and trusts removed with delete_trust, so the audit event and the revocation of the tokens carrying the role or minted under the trust are not skipped. Application credentials and credentials, which have no revocation side effect, use a bulk backend method.
  • Best-effort derived state stays on hooks and must be idempotent: caches, OAuth2 sessions, the plugin identity index, limits.
  • Work that needs at-least-once delivery across stores follows the outbox pattern of ADR 0035, with the hook as a wake-up only. No cascade here needs it yet.

The cascades:

DeletedRemoved with it
usercredentials, application credentials and access rules, trusts (as trustor or trustee), role assignments
projectcredentials, application credentials, role assignments on the project
domainprojects (leaf first), users, groups, role assignments on the domain, id mappings

Rules that came out of implementing them:

  • A listing by project is not a listing of the project’s own grants: it also returns the inheritable grants of the parent projects. The cascade filters on the assignment’s own target, otherwise deleting a child would revoke the parent’s grants. The direct and the inheritable grants on the target are two listings (RoleAssignmentListParameters::inherited), and the project and domain cascades run both.
  • Deleting a group revokes all of its grants, on any target, including the ones on other domains and the system (delete_assignments_for_group).
  • Project deletion is ordered: children before parents, by depth, with the depth walk bounded so corrupt parent data cannot hang it.
  • Users and groups of a read-only identity backend (LDAP) are skipped during a domain delete; they are not ours to remove, and failing on them would make such a domain undeletable.
  • The domain cascade is idempotent: a failure leaves the domain row in place and a retry resumes with what is left.
  • The per-entity cascades are retry-safe too. The entity row goes first, so a failure in a later cascade step leaves the entity gone and its dependents behind. Deleting a user, group or project that no longer exists therefore still runs the cascade and only then returns NotFound; a retry finishes the cleanup and the caller still sees that the entity was already deleted.

4. A destructive cascade needs the domain disabled

Before the cascade a domain delete removed one row. With it, one call can wipe a whole domain. Python Keystone refuses to delete an enabled domain; this implementation never had the check, and adding it unconditionally would break every caller that deletes an empty, freshly created domain.

So the check is tied to the cascade: deleting an enabled domain that still owns projects, users or groups fails with DomainEnabled (HTTP 403) and touches nothing. An empty domain deletes as before, and a disabled domain cascades.

5. Orphan sweep as the safety net

Some leftovers cannot be reached by a cascade: anything removed by a writer that emits no event (a co-deployed Python Keystone, a node that stopped half way through a cascade) and grants written after their target was removed.

A background sweep (orphan_sweep) lists all role assignments hourly and reports those whose user, group, project or domain no longer exists. It is report-only for now: it logs the count and the first IDs and deletes nothing. A failed existence lookup is counted as an error and never reported as an orphan. The pass is read-only and idempotent, so it runs on every node.

Deletion by the sweep is deferred until the false-positive rate on real deployments is known, because a wrong delete is a lost grant.

Consequences

  • Authenticated API calls now notify ProviderHooks. Hooks that were written against the old behaviour see events they never saw before; the ones that do work (limits, OAuth2, plugin identity, back-channel logout) are idempotent.
  • A user, project or domain delete does more work and can fail half way, with the earlier steps already committed. It is safe to retry; it is not atomic. Each step is audited on its own.
  • Deleting a user with many assignments or trusts issues one revocation event per item.
  • A domain delete of a large domain is a long synchronous request.
  • Deleting an enabled, non-empty domain now fails where it used to leave the contents behind.
  • Known gap: the users and groups of a read-only identity backend are skipped by a domain delete, so the grants they hold on targets outside the domain stay behind. The sweep reports them once the domain is gone.
  • Known gap: data keyed by a domain in providers that have no cascade yet (federation, mappings, k8s-auth, catalog endpoints).
  • The sweep covers role assignments only.
  • The sweep interval is a constant, not a [section] option.

43. OAuth2 sender-constrained tokens with DPoP

Date: 2026-10-16

Status

Proposed.

Reference

Extends ADR 0026 (OAuth2/OIDC provider, “Token Replay Model” and the “DPoP Deferred to v1.5” consequence). Related: ADR 0036 (service delegation, SPIFFE binding for workloads), vision §4.3 (sender constraint gate) and open question 4 (DPoP versus mTLS-bound tokens for public clients). Standard: RFC 9449.

Context

ADR 0026 issues pure bearer access tokens and refresh tokens. A stolen token is usable until exp, and a stolen refresh token until its family is revoked. The vision lists sender constraint as a gate for scope-less tokens.

Two constraint mechanisms exist:

  • Workloads with a SPIFFE SVID are bound to the SPIFFE ID at the internal interface (ADR 0036). RFC 8705 thumbprint binding is not usable there because SVIDs rotate.
  • End users and public clients (CLI, browser applications, desktop tools) have no certificate. Issuing one would need a CA, which the per-domain OAuth2 signing keys are not (they are published in JWKS, rotated on a token lifetime and used for JWS only). DPoP needs no certificate: the client holds its own keypair and proves possession on each request.

This ADR specifies DPoP for the second group. It does not change workload binding.

Decision

1. Enforcement levels and discovery

Enforcement is configurable at three levels, each disabled | optional | required:

  • Global: [oauth2] dpop (default disabled).
  • Domain: a per-domain setting, stored in Raft as data:oauth2:domain_settings:<domain_id> and managed through GET/PUT /v4/oauth2/{domain_id}/settings. No per-domain OAuth2 settings record exists today, so this record is new (trailing, defaulted, no migration). An unset domain value inherits the global one.
  • Client: OAuth2Client.dpop_bound_access_tokens: bool (default false) is the narrowest level; true means required for that client.

The effective mode is the strictest of the three (disabled < optional < required). The global value is therefore a floor: a domain can raise optional to required, never weaken a global required. A global optional with selected domains required is the intended combination. The domain is the token’s issuer (iss is /v4/oauth2/{domain_id}), so the decision at /token is a lookup on the client’s domain.

  • required: /token rejects requests without a valid proof (invalid_dpop_proof).
  • optional: a token is bound whenever the client sends a proof.

Authorization for the domain setting:

  • Raising the mode is allowed to a domain manager (self-service).
  • Lowering it, or setting it below the global value, requires admin, so a compromised domain manager cannot disable DPoP. Because the global floor always applies, the stored value only ever matters when it is stricter.
  • Every change emits a CADF event with the old and new mode.

Discovery advertises dpop_signing_alg_values_supported (ES256, RS256; none and symmetric algorithms are never accepted) and, per issuer, a dpop_required boolean for the effective domain mode. Public clients are expected to use ES256.

Switching a domain to required does not end existing refresh families: the next refresh without a proof is rejected and the client re-authenticates with a bound key.

2. Proof validation (shared verifier)

A single proof verifier is used by Keystone and by the Python middleware. A proof is a JWT in the DPoP header with:

  • header typ = dpop+jwt, an allowed alg, and the public key as jwk. The jwk must be a public key only (any private member, and x5c/x5u, are rejected), its kty/crv must match alg (P-256 for ES256), and an RSA key must be at least 2048 bits;
  • claims jti, htm, htu, iat, plus ath on resource requests and nonce when the server required one.

Checks, all fail-closed: signature against the embedded jwk; htm equals the request method; htu equals the request URI without query and fragment, compared after normalisation; iat within [oauth2] dpop_proof_lifetime_seconds (default 60) of server time, in both directions; jti unseen within that window; exactly one DPoP header. htu is compared against the external URL of the endpoint, built from [DEFAULT] public_endpoint (the same value used for iss and discovery) for Keystone endpoints, and from the service’s configured external base URL at resource servers; the internal listener address is never used, so TLS-terminating proxies and load balancers do not break matching. For ath, the value is the base64url SHA-256 of the access token.

Replay protection is a bounded in-memory jti cache keyed by (jkt, jti) and expiring with the proof window. It is per node and best effort at resource servers, which is acceptable because a proof is already bound to a method and URL. The cache is bounded per jkt and globally; when full, new proofs from a key that exceeds its share are rejected (invalid_dpop_proof) instead of evicting other keys’ entries, so a flood of fresh keys cannot push out entries and enable replay.

Keystone issues server nonces (DPoP-Nonce response header, use_dpop_nonce error). A nonce is mandatory on every /token request that carries a proof (a proof without a valid nonce is answered with use_dpop_nonce). The nonce is an HMAC of a time slot under a key derived from the cluster salt, identical on every node, so any node accepts nonces issued by another. Slots last [oauth2] dpop_nonce_lifetime_seconds (default 300); the current and the previous slot are accepted, so a nonce stays valid for up to two slots. Resource servers do not use nonces in this version.

Proof verification costs a signature check before the client is authenticated, so it runs behind the pre-hash rate limit of ADR 0022: the limit is applied to the source and to the unverified jkt before the signature is checked, and invalid proofs count against it.

3. Token endpoint

  1. The client sends the proof with the grant (authorization code, device code, refresh token).
  2. On success the access token response has token_type = DPoP and the access token carries cnf = {"jkt": <RFC 7638 SHA-256 thumbprint of the proof key>}. The token is signed with the domain signing key exactly as today; no signing key, kid, JWKS or rotation behaviour changes.
  3. A refresh token issued to a DPoP-bound client records the jkt in its family. Every refresh request must carry a proof from that same key. A valid proof from a different key is a suspected theft: the request is rejected with invalid_grant and the family is revoked (same path as reuse detection, ADR 0026 §9). A missing, malformed, expired, replayed or nonce-less proof is only a bad request (invalid_dpop_proof or use_dpop_nonce) and never revokes the family, so clock skew or a lost nonce cannot be used to end a session.
  4. The jkt is also stored with the authorization code (dpop_jkt authorization request parameter is accepted and must match the proof at /token), binding the code to the key before the redirect.
  5. client_credentials is bound the same way for clients that send a proof (or are required); SVID-holding workloads keep the SPIFFE mechanism of ADR 0036 and do not use DPoP.
  6. OpenStackAccessTokenClaims and OidcAccessTokenClaims gain cnf: Option<Confirmation>. cnf is added to the reserved claim set for claims_template. id_tokens are not bound.

4. Resource side

  • A bound token is accepted only as Authorization: DPoP <token> with a DPoP proof header. A bound token presented as Bearer, or a proof without a bound token, is rejected.
  • After the existing signature, aud, token_use and revocation checks, the verifier validates the proof (section 2, with ath) and requires the proof key thumbprint to equal cnf.jkt.
  • /userinfo and the other Keystone endpoints that take an access token apply the same rule. Unbound tokens keep working while dpop = optional.
  • A verifier reads dpop_required from the issuer’s discovery document (cached like JWKS) and, when true, rejects unbound access tokens from that issuer. Without this an unbound token issued before the switch would still be accepted as Bearer until its exp (at most one token lifetime).
  • /revoke of a bound token needs no proof, as the client is authenticated by other means.
  • Token exchange (RFC 8693, ADR 0036). A subject_token that carries cnf.jkt is accepted at /token only together with a proof from that key (ath = hash of the subject token), as for any other use of the token. Without it the exchange fails, so a stolen bound token cannot be traded for an unbound one. The exchanged token is bound to the same jkt when the requesting client uses DPoP, and is otherwise issued unbound only if the effective mode (section 1) is not required.
  • The dpop_required lookup fails closed: if the discovery document cannot be fetched and the cached copy is older than its TTL (60 s, shorter than the discovery Cache-Control), the request is rejected, so interfering with the fetch cannot downgrade enforcement.

5. Client protocol

What a conforming client sends, step by step. P(...) below is a fresh proof (section 2) signed with the client key; a proof is never reused, so every HTTP request gets its own jti and iat.

Authorization request (/authorize, browser redirect). The browser leg cannot carry a DPoP header, so the key is announced as a parameter:

GET /v4/oauth2/{domain}/authorize?response_type=code&client_id=...
    &redirect_uri=...&code_challenge=...&code_challenge_method=S256
    &state=...&dpop_jkt=<base64url SHA-256 JWK thumbprint of the client key>

dpop_jkt is optional in optional mode and recommended always; Keystone stores it with the authorization code. In required mode a missing dpop_jkt is accepted (the key is then taken from the token request, PKCE still binds the code), but a present one must match. Authorization-request pushing (PAR) is not supported.

Token request (/token). Every grant carries the proof in the DPoP header, with htm = POST and htu the token endpoint URL:

POST /v4/oauth2/{domain}/token
DPoP: P(htm=POST, htu=https://ks.example/v4/oauth2/{domain}/token, nonce=?)
grant_type=authorization_code&code=...&code_verifier=...&client_id=...
  • If Keystone requires a nonce it answers 400 with error=use_dpop_nonce and a DPoP-Nonce header. The client builds a new proof containing that nonce and retries once. The client stores the latest DPoP-Nonce per issuer and sends it on later token requests; a nonce may be returned on success as well and replaces the stored one.
  • If dpop_jkt was sent at /authorize, the proof key must match it, else invalid_grant.
  • The response has token_type: "DPoP". Anything else means the token is unbound and is used as Bearer.
  • The same applies to device_code. The key is fixed when the flow starts: the device authorization request carries the proof (or dpop_jkt) and the device code is bound to that key. Every poll must use it, and a poll with a different key is invalid_grant. Browser approval of the user code is unaffected. The same applies to refresh_token (proof from the family’s key; a new refresh token in the response stays bound to it).

Every use of the access token (any resource request, including /userinfo).

GET /v2.1/servers HTTP/1.1
Authorization: DPoP <access_token>
DPoP: P(htm=GET, htu=https://nova.example/v2.1/servers, ath=b64url(SHA-256(access_token)))
  • A new proof per request, signed with the same key as at /token.
  • htu is the URL the client called, without query and fragment. A service behind a proxy or load balancer compares against its configured external base URL, not the internal one, so both sides agree.
  • The scheme is DPoP, never Bearer, for a bound token.
  • Failures return 401 with WWW-Authenticate: DPoP error="invalid_token" (or invalid_dpop_proof) and algs="ES256 RS256". Resource servers do not require nonces in this version, so the client has no nonce to track there.
  • Clients must not send a proof to a host they did not request: a proof is per URL by construction, but a client library must not attach one to redirects to another origin.

6. Clients and key management

  • Clients generate the keypair locally and never send the private key. Browser applications should use non-extractable WebCrypto keys; CLI tools (osc, SDK) keep the key in the OS keystore or a file with owner-only permissions, one key per cloud profile and device.
  • Losing the key means a new login; there is no recovery, by design.
  • Keystone stores only the thumbprint, never the public key.

7. Out of scope

  • Service-to-service hops: delegation tokens obtained by services use the SPIFFE ID mechanism of ADR 0036, not the end user’s jkt. Accepting a bound subject token at the exchange endpoint is in scope (section 4).
  • Binding of Fernet tokens and of Horizon cookie sessions.
  • An X.509 CA for end users and RFC 8705 for them. If ever needed it would be a dedicated issuer with its own keys; the OAuth2 signing keys are not reused.

Consequences

  • A leaked access or refresh token is useless without the client key, closing the sender-constraint gate for public clients.
  • Every /token request and every resource request from a bound client costs one signature verification and a cache lookup; clients pay one signature.
  • Clients must carry a key and a DPoP-aware HTTP layer. OpenStack SDKs and keystonemiddleware need changes; until then deployments run in optional mode and bind only clients that opt in.
  • The jti cache is not shared across nodes or resource servers, so a proof can be replayed against a different node inside its short window. The method/URL binding and short lifetime bound the impact; nonces protect /token only.
  • Clock skew now matters for proofs; the window is configurable and the nonce flow lets a client recover from a skewed iat.
  • Discovery and client schema change additively; no SQL migration. The per-domain settings record and its API are new surface to document and test.
  • A domain-level required setting makes unbound tokens from that issuer unacceptable at verifiers that read dpop_required; verifiers that do not still accept them until exp.