Repository: kubernetes-sigs/cluster-api-provider-openstack

Issue:
[Feature Request] Introduce OpenStackClusterIdentity Resource for Centralized Credential Management
/kind feature

**Describe the solution you'd like**

The current implementation of identityRef in OpenStackCluster requires secrets containing credentials (e.g., clouds.yaml) to reside in the [same namespace](https://cluster-api-openstack.sigs.k8s.io/api/v1beta1/api#infrastructure.cluster.x-k8s.io/v1beta1.OpenStackIdentityReference) as the OpenStackCluster resource. This limitation poses challenges in environments with namespace-based access control, where sensitive credentials need to remain centralized and secure while still being usable by resources in other namespaces.

To address this, I propose introducing an OpenStackClusterIdentity custom resource, similar to [AWSClusterStaticIdentity](https://github.com/kubernetes-sigs/cluster-api-provider-aws/blob/b29e04c5f7673b7516cf7ef9e8b588c13b264b11/api/v1beta2/awsidentity_types.go#L79) or [AzureClusterIdentity](https://capz.sigs.k8s.io/reference/v1beta1-api#infrastructure.cluster.x-k8s.io/v1beta1.AzureClusterIdentity). This resource would allow for centralized credential management while providing fine-grained access control across namespaces.

***Proposed Implementation:***
	1.	New CRD: Create OpenStackClusterIdentity to reference credentials stored in a secret, allowing usage across namespaces with access controls.
	2.	Identity Reference: Update OpenStackCluster.spec.identityRef to reference the new resource instead of secrets directly.
	3.	Access Control: Implement validation in the CAPO controller to restrict usage based on namespaces or roles.



Hints from issue comments:
@mdbooth: Even implementing the ability to reference cross-namespace credentials raises architectural issues in CAPO akin to requiring a setuid bit in a posix application. Despite what anybody else may have done, my instinct is to actively avoid doing this. It requires us to robustly implement and enforce our own RBAC scheme. I'm not sure we're up for that.

IOW, far from protecting secrets, I believe this feature would open a potential hole where a bug might expose any secret in the cluster.

However, I'm very interested in the use case. Can you explain why the credentials cannot live in the target namespace alongside the CRs which reference them?@s3rj1k: @mdbooth Can you please elaborate on "CAPO akin to requiring a setuid bit in a posix application"?

Assuming that I understand use case correctly, to have what other major CAPI providers have, to have similar API (both AWS and Azure have this).@wkonitzer: Hi @mdbooth Your points about the potential complexity of enforcing a robust RBAC scheme and the risk of inadvertently exposing secrets are well taken.

The use case for cross-namespace credentials stems from the need to centralize credential management, especially in environments with a high number of clusters and frequent credential rotations. Here are a few key reasons why having credentials live in a single namespace is advantageous:

- Centralized Management: Storing credentials in one namespace allows for better oversight and governance. For organizations managing dozens or hundreds of clusters, centralization ensures consistency, simplifies rotation, and reduces the likelihood of credential sprawl.
- Improved Security: While I understand your concern about enforcing RBAC, centralizing credentials reduces the overall surface area of sensitive data. Rather than duplicating secrets across namespaces, centralization allows tighter access control and audit capabilities.
- Operational Efficiency: Rotating credentials across multiple namespaces adds administrative overhead and increases the likelihood of human error, which could lead to downtime or configuration drift. A single source of truth for credentials ensures that changes propagate seamlessly without manual intervention.

I hope this helps clarify the rationale behind the feature request.@lentzi90: Thank you for the feature request!
I can see the usefulness of this and also how it is more secure than direct cross namespace references to secrets.
I do share @mdbooth 's concern about bugs in the code though. This would add to CAPO's responsibilities so we do need to be careful.
@mdbooth: For posterity I'll summarise the behaviour of AWSClusterStaticIdentity.

AWSClusterStaticIdentity is a cluster-scoped, non-namespaced custom resource. I assume that users are therefore not expected to create them. It looks like users are not even strictly required to be able to read them, merely know the name of one which will permit their access. They are presumably expected to be created and managed by a service provider.

AWSClusterStaticIdentity includes `allowedNamespaces` in its spec.

Credentials are stored in a secret in the same namespace the controller is running in.

CAPA has a method isClusterPermittedToUsePrincipal which is responsible for enforcing that only clusters in an allowed namespace may use the credentials: https://github.com/kubernetes-sigs/cluster-api-provider-aws/blob/4afd74ff9f41ef714af378adabe0f5e4fa66723e/pkg/cloud/scope/session.go#L418-L459

If this method returns true, `buildAWSClusterStaticIdentity` will return a `AWSStaticPrincipalTypeProvider` containing the credentials from the secret.

I can see how this addresses the issue of centrally-managed namespaces.

At a minimum, in this context the methods `buildAWSClusterStaticIdentity` and `isClusterPermittedToUsePrincipal` are both 'security critical', meaning that an error here could allow a tenant to use another tenant's credentials.@mdbooth: Have you considered using a tool which automatically copies secrets from a central namespace to target namespace, and updates them automatically in line with the source credentials? That solves the maintenance problem while greatly reducing the potential for security bugs in code.

But I guess you don't want to expose the OpenStack credentials to your users at all? What's the thinking here?@s3rj1k: > Have you considered using a tool which automatically copies secrets from a central namespace to target namespace, and updates them automatically in line with the source credentials

This is what I was mentioning in Slack, this is exactly what I would personally like to avoid, given that `ClusterIdentity` is common practice in CAPI@bnallapeta: @mdbooth Yes, we want to avoid this as this exposes the cluster secret to all the other namespaces where we would want to provision a cluster.@bnallapeta: @mdbooth so, based on the conversation here and on Slack, are you now convinced that ClusterIdentity should be the way to address this? Please let me know. I am happy to take it up and work on it. 

We could discuss further if we reach a conclusion here.@richardcase: > Yes, we want to avoid this as this exposes the cluster secret to all the other namespaces where we would want to provision a cluster.

We have this functionality in CAPA (i am a maintainer) and this is a valid concern. In CAPA on our equivalent to this (**AWSClusterIdentitySpec**) we have the ability to lock down using the identity from specific namespaces via a [AllowedNamespaces](https://github.com/kubernetes-sigs/cluster-api-provider-aws/blob/main/api/v1beta2/awsidentity_types.go#L33) field.  This could be a good way around this concern.@mdbooth: > Yes, we want to avoid this as this exposes the cluster secret to all the other namespaces where we would want to provision a cluster.

@bnallapeta Can you explain this further? As I mentioned before we have a custom solution to this in OpenShift, but looking around it seems to have been solved a few times. Here's an example that looks very much like the sort of thing I'd expect to use for this: https://medium.com/lonto-digital-services-integrator/why-we-developed-own-kubernetes-controller-to-copy-secrets-e46368ae6db9

In this model:
* Credentials are centrally managed
* Credentials are copied only to the namespaces which need them
* Updating a centrally managed credential updates it everywhere
* Deleting a centrally managed credential deletes it everywhere

It also requires no code in CAPO which is directly responsible for mediating access to Secrets.

I also have to consider that we would need to add support for cross-namespace Secret references to ORC. I was hoping to be able to switch ORC, and maybe even CAPO, to a per-namespace controller model, which would have the benefit of load sharding in addition to greatly reduced RBAC requirements for each controller. This is a secondary consideration, but I bring it up because it's on my mind.

I want to be 100% clear that I'm not rejecting this proposal[^1], btw. AWSClusterStaticIdentity is a much safer model than 'bare' cross-namespace secret references. I especially like the control that users (presumably) can't create them. Unlike cross-namespace references, I can see a path to personally approving of this, especially as it seems to be an established pattern. However, it would represent a significant change in secure coding requirements in CAPO, and I hope we would not choose to do that without a complete understanding of why we rejected the alternatives. In particular, I would very much like to understand the concerns around using a secret controller. I don't yet understand this.

[^1]: For what that would be worth. There are 2 other maintainers who are welcome to disagree with me!@lentzi90: > I also have to consider that we would need to add support for cross-namespace Secret references to ORC. I was hoping to be able to switch ORC, and maybe even CAPO, to a per-namespace controller model, which would have the benefit of load sharding in addition to greatly reduced RBAC requirements for each controller. This is a secondary consideration, but I bring it up because it's on my mind.

With the risk of going off topic, I don't think this should be an either/or question. Admins should be able to run CAPO namespaced or cluster scoped, and this [is already possible](https://github.com/kubernetes-sigs/cluster-api-provider-openstack/blob/811167bc455699a3846543677ec493af4734fb11/main.go#L131).
I think it is important that we keep this flexibility, but other than that I do not have an issue with this feature request.

How does the cluster identity function if the controller is limited to a single namespace? I assume it is possible to run CAPA namespaced still with this. @richardcase @bnallapeta @s3rj1k: > In this model:
>     * Credentials are centrally managed
>     * Credentials are copied only to the namespaces which need them
>     * Updating a centrally managed credential updates it everywhere
>     * Deleting a centrally managed credential deletes it everywhere

Lets assume running multiple CAPx providers in cluster, all of them use ClusterIdentity model and only CAPO needs that special treatment.
@richardcase: > How does the cluster identity function if the controller is limited to a single namespace? I assume it is possible to run CAPA namespaced still with this. [@richardcase](https://github.com/richardcase) [@bnallapeta](https://github.com/bnallapeta)

Although it may be technically possible to run multiple instances of CAPA and each one look at a specific namespace, we don't say this is supported, and we don't have any tests for this.  There's also the question do we honour "watch namespace" in all controllers and in CAPI itself.

But ignoring that, as there is no controller for **AWSClusterIdentity** multiple instances of CAPA could still read the identify for the clusters it manages.@lentzi90: Thanks, got it. Honestly, the situation sounds quite similar to CAPO. We do not test the watch namespace flag anywhere that I know if.

I think a plan for how to support single namespace CAPO with cluster identity would still be good though. It would be unfortunate to break the functionality by ignorance.@mdbooth: A thought experiment. Lets say we added a new `OpenStackClusterStaticIdentity` API which is identical to `AWSClusterStaticIdentity` in all relevant ways. However, instead of implementing it as library code in CAPO, we instead implement it as a Secret manager in a separate controller. The behaviour of this controller is:

* Copy the static credentials from the central namespace to all AllowedNamespaces
* Update all copies when they are updated centrally
* Delete credential copies either when they no longer match AllowedNamespaces, or when the owning `OpenStackClusterStaticIdentity` is deleted

From an API POV this would be identical to the UX of `AWSClusterStaticIdentity` (unless I missed something). The only difference I can see is that the credentials themselves are now directly visible to users, whereas previously they are only indirectly available.

Continuing the thought experiment to phase 2:

Instead of copying the credentials, the controller provisions new application credentials for each target namespace using the central credentials. This has the added advantage of being able to correlate the OpenStack audit trail to a specific namespace.@wkonitzer: Hi @mdbooth,

I looked at the CAPI spec, and potentially, the current behavior of CAPO is not in the spirit of the spec. The current behavior of CAPO concerning identityRef requiring secrets to reside in the same namespace as the OpenStackCluster resource does not explicitly violate the CAPI spec, but it does diverge from the recommended approach of using ClusterIdentity for credential abstraction. Here's my understanding:

**CAPI Spec on Credential Management**
1. ClusterIdentity:
- The CAPI spec introduces the ClusterIdentity resource to abstract and manage credentials securely. This resource allows you to reference credentials (e.g., secrets) that can be used across namespaces, enabling centralized credential management.

2. Namespace-Scoped Secrets:
- While the CAPI spec does not explicitly mandate that secrets must be cross-namespace accessible, it provides a mechanism (ClusterIdentity) to securely abstract and reference credentials across namespaces. This mechanism is considered best practice.

3. Provider-Specific Behavior:
- The spec allows providers to implement their specific behavior. However, providers are encouraged to align with the CAPI spec to maintain consistency and interoperability across different providers.

**CAPI Security Guidelines**
- Credentials Management: Ensure credentials used by Cluster API are least privileged. Apply access control to Cluster API controller namespaces, restricting unauthorized access to cloud administrators only.

**How CAPO Diverges**
- CAPO does not implement the ClusterIdentity resource, which other providers use to handle cross-namespace credential references.
- Instead, CAPO enforces that the referenced secret (e.g., openstack-cloud-config) must reside in the same namespace as the OpenStackCluster. This creates additional complexity for users who want to centralize credentials.

Does this behavior violate the CAPI Spec? Not explicitly, as the CAPI spec does not strictly require providers to use ClusterIdentity. However, the absence of ClusterIdentity or an equivalent abstraction in CAPO can be seen as a misalignment with the spirit of the spec.

Unfortunately, only the AWS, Azure, OCI, and vSphere providers implement ClusterIdentity, enabling a more seamless and flexible credential management experience. Therefore, one could argue CAPO is aligned with the majority and thus is correct.
We could propose a CAPI spec update to enforce the ClusterIdentity resource, but it doesn't make sense for providers like metal3 or Kubevirt, for example.

The outcome we are trying to achieve is a consistent user experience across providers:
- Credential management should "just work" regardless of the provider.
- While the mechanism (e.g ClusterIdentity, or some other mechanism) may differ, the experience for end users (e.g., managing centralized credentials, cross-namespace access) should be seamless.

If needed, we could certainly build something like a secrets management controller to copy secrets, so no changes would be required in CAPO. However, this approach introduces several challenges:
- Inconsistent Behavior: CAPO would behave differently from other similar providers that natively support centralized credential management (e.g., through ClusterIdentity). This inconsistency would create a steeper learning curve for users and potentially lead to confusion when switching between providers.
- Increased Complexity: Adding a separate secrets controller introduces additional components to manage, deploy, and troubleshoot. This increases operational complexity, which goes against the goal of making credential management seamless and straightforward.
- Code Maintenance: Having a separate secrets controller to address CAPO-specific behavior would make our overall codebase harder to maintain. It would require us to maintain provider-specific logic outside of CAPO, rather than addressing the root cause within the provider itself.
- Potential Security Risks: While a secrets controller could automate the propagation of secrets, it also expands the attack surface. Maintaining a robust, secure solution for managing secrets across namespaces is better handled as part of CAPO, where proper guardrails and mechanisms can be directly integrated.

Ultimately, aligning CAPO with the behavior of other providers by supporting a centralized abstraction for credentials (or implementing an equivalent to ClusterIdentity) would result in a more consistent user experience, simplify operations, and reduce technical debt in the long run.

Hopefully, that provides some extra info outside of looking purely at the code.
@lentzi90: @wkonitzer could you add some links to this CAPI spec on ClusterIdentity? I cannot find any in their documentation, only an example for how it is handled during clusterctl move.@mdbooth: @wkonitzer Firstly let me reiterate that I accept the use case! It wasn't one that I was previously aware of, but new use cases are welcome.

Secondly, I appreciate the value of consistency. If this is a thing we're going to do, copying an existing pattern makes a great deal of sense. If we decide to do this, adding an `OpenStackClusterStaticIdentity` seems reasonable.

Lastly, I'm not 100% opposed to implementing this API similarly to how CAPA has done it. However, I am concerned that it opens new security concerns that CAPO has not previously had to consider. Specifically, in this model CAPO can now allow an object in one namespace to reference a secret in another namespace, whereas previously it could not. This opens the possibility of a new class of bug whose potential impact is a privilege escalation allowing a user able to create CAPO objects to access to any secret in the cluster. I would like to explore options for meeting the use case, possibly with the proposed API, with an architecture that does not enable this class of bug.

For the sake of argument, lets assume that we will implement `OpenStackClusterStaticIdentity`. Lets reduce the implementation of this API to 2 options:

- AWS-style direct cross-namespace referencing
- An independent 'secret manager' which copies credentials to where they are used

We assume that the API, and therefore the UX, in both cases is literally identical. Adding a new API requires new code in CAPO in either case.

So I think the only points left to consider are the final 4:

* Inconsistent Behavior: I don't believe the behaviour of an implementation using a secrets manager would be inconsistent.
* Increased Complexity: I'd argue it's secure architecture. It would move code requiring different security considerations into a separate component, reducing the opportunities for security bugs in the rest of the code base.
* Code maintenance: If we were going to add this as a new CAPO API it would need an implementation in CAPO.
* Potential Security Risks: I don't yet follow the argument about an increased attack surface. I'm arguing the opposite. What additional risks do you see?

There are trade-offs here, and it's quite possible I still don't fully understand your constraints. Can you explain why an implementation of  `OpenStackClusterStaticIdentity` which copied secrets would not meet your use case?@mdbooth: We discussed this in the CAPO office hours call today. With the requirement to not expose credentials to users in a particular namespace, I don't see any realistic option which doesn't involve accessing secrets in another namespace.

So I'm personally convinced the best way forward is the one suggested here, namely to copy the pattern set by `AWSClusterStaticIdentity`.

My concerns around arbitrary secret access remain, though. I notice that an AWS credential specify's the secret's name but not its namespace. The namespace is hardcoded to be the same namespace that the controllers are running in: https://github.com/kubernetes-sigs/cluster-api-provider-aws/blob/d020303ce12b4488aadcf7009f2cbe3307e61079/pkg/cloud/scope/session.go#L347

I think this is a good and important control for a couple of reasons. Firstly it means that it is not possible for the user to express a reference to a secret we do not want them to access, e.g. something in kube-system. Therefore, it is less likely we could write a bug which accidentally enabled this access.

Secondly, it means that in a namespace-restricted deployment, while we can no longer restrict permissions to a single namespace, we can at least restrict it to a known set of namespaces.

So:
* All cluster static credential secrets must be in the same namespace
* Users with restricted privileges cannot create static cluster identity objects
* Static cluster identities do not specify a secret namespace, because they are all the same

We could follow the AWS pattern and have this be 'manager namespace', or we could even allow it to be specified on the command line. As long as they are static for a particular deployment, it remains possible to write RBAC which allows access to secrets only in the intended namespaces.@s3rj1k: > allow it to be specified on the command line

+1 for this, let consumer decide in some opt-in way with default in-place @aglarendil: @mdbooth just a short question here: CAPA has also a capability of using non-static credentials, such as AWS IAM Roles, which makes those credentials time-bound, which in turn requires the client to query some API endpoint to actually get a new set of credentials when they expire. Do you expect to also support this way of obtaining credentials? Like, 
"get a 401 => query the secret/query some API to issue a new set of creds"? Or is it an orthogonal question and should be discussed separately?@k8s-triage-robot: The Kubernetes project currently lacks enough contributors to adequately respond to all issues.

This bot triages un-triaged issues according to the following rules:
- After 90d of inactivity, `lifecycle/stale` is applied
- After 30d of inactivity since `lifecycle/stale` was applied, `lifecycle/rotten` is applied
- After 30d of inactivity since `lifecycle/rotten` was applied, the issue is closed

You can:
- Mark this issue as fresh with `/remove-lifecycle stale`
- Close this issue with `/close`
- Offer to help out with [Issue Triage][1]

Please send feedback to sig-contributor-experience at [kubernetes/community](https://github.com/kubernetes/community).

/lifecycle stale

[1]: https://www.kubernetes.dev/docs/guide/issue-triage/@s3rj1k: /remove-lifecycle stale@k8s-triage-robot: The Kubernetes project currently lacks enough contributors to adequately respond to all issues.

This bot triages un-triaged issues according to the following rules:
- After 90d of inactivity, `lifecycle/stale` is applied
- After 30d of inactivity since `lifecycle/stale` was applied, `lifecycle/rotten` is applied
- After 30d of inactivity since `lifecycle/rotten` was applied, the issue is closed

You can:
- Mark this issue as fresh with `/remove-lifecycle stale`
- Close this issue with `/close`
- Offer to help out with [Issue Triage][1]

Please send feedback to sig-contributor-experience at [kubernetes/community](https://github.com/kubernetes/community).

/lifecycle stale

[1]: https://www.kubernetes.dev/docs/guide/issue-triage/@bnallapeta: /remove-lifecycle stale

Solution patch:
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
index 9c969474..0cb3fae9 100644
--- a/.github/dependabot.yml
+++ b/.github/dependabot.yml
@@ -148,67 +148,10 @@ updates:
   - dependency-name: "github.com/a8m/envsubst"
   - dependency-name: "github.com/onsi/gomega"
   - dependency-name: "golang.org/x/crypto"
-  labels:
-  - "area/dependency"
-  - "ok-to-test"
-## release-0.12 branch config ends here
-## release-0.11 branch config starts here
-# github-actions
-- directory: "/"
-  package-ecosystem: "github-actions"
-  schedule:
-    interval: "weekly"
-    day: "monday"
-  target-branch: release-0.11
-  groups:
-    all-github-actions:
-      patterns: ["*"]
-  commit-message:
-    prefix: ":seedling:"
-    include: scope
-  labels:
-  - "area/dependency"
-  - "ok-to-test"
-# Go directories
-- directories:
-  - "/"
-  - "/hack/tools"
-  - "/orc"
-  package-ecosystem: "gomod"
-  schedule:
-    interval: "weekly"
-    day: "monday"
-  target-branch: release-0.11
-  groups:
-    all-go-mod-patch-and-minor:
-      patterns: ["*"]
-      update-types: ["patch", "minor"]
-  commit-message:
-    prefix: ":seedling:"
-    include: scope
-  ignore:
-  # Ignore CAPI major and minor bumps
-  - dependency-name: "sigs.k8s.io/cluster-api*"
-    update-types: ["version-update:semver-major", "version-update:semver-minor"]
-  # Ignore controller-runtime major and minor bumps as its upgraded manually.
-  - dependency-name: "sigs.k8s.io/controller-runtime"
-    update-types: ["version-update:semver-major", "version-update:semver-minor"]
-  # Ignore k8s major and minor bumps and its transitives modules
-  - dependency-name: "k8s.io/*"
-    update-types: ["version-update:semver-major", "version-update:semver-minor"]
-  - dependency-name: "sigs.k8s.io/controller-tools"
-    update-types: ["version-update:semver-major", "version-update:semver-minor"]
-  # We will need k8s v0.31.3 to bump structured-merge-diff to v4.4.2 (check git history for details).
-  - dependency-name: "sigs.k8s.io/structured-merge-diff/*"
-  # These dependencies are skipped because they require a newer version of go:
-  - dependency-name: "github.com/a8m/envsubst"
-  - dependency-name: "github.com/onsi/ginkgo/v2"
-  - dependency-name: "github.com/onsi/gomega"
-  - dependency-name: "github.com/prometheus/client_golang"
-  - dependency-name: "go.uber.org/mock"
-  - dependency-name: "golang.org/x/crypto"
   - dependency-name: "golang.org/x/text"
+  # Newer kustomize requires a bump to kube-openapi, which has some incompatibility with gengo.
+  - dependency-name: "sigs.k8s.io/kustomize/kustomize/*"
   labels:
   - "area/dependency"
   - "ok-to-test"
-## release-0.11 branch config ends here
+## release-0.12 branch config ends here
diff --git a/.github/workflows/pr-gh-workflow-approve.yaml b/.github/workflows/pr-gh-workflow-approve.yaml
new file mode 100644
index 00000000..e7c6960f
--- /dev/null
+++ b/.github/workflows/pr-gh-workflow-approve.yaml
@@ -0,0 +1,44 @@
+# adapted from github.com/kubernetes-sigs/cluster-api/.github/workflows/pr-gh-workflow-approve.yaml
+# this workflow approves workflows if the PR has /ok-to-test
+# related Prow feature request https://github.com/kubernetes/test-infra/issues/25210
+
+name: Approve GH Workflows
+
+on:
+  pull_request_target:
+    types: [opened, edited, reopened, synchronize, ready_for_review]
+
+permissions: {}
+
+jobs:
+  approve:
+    name: Approve on ok-to-test
+    runs-on: ubuntu-latest
+
+    permissions:
+      actions: write
+
+    if: contains(github.event.pull_request.labels.*.name, 'ok-to-test')
+    steps:
+    - name: Update PR
+      uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
+      continue-on-error: true
+      with:
+        github-token: ${{ secrets.GITHUB_TOKEN }}
+        script: |
+          const result = await github.rest.actions.listWorkflowRunsForRepo({
+            owner: context.repo.owner,
+            repo: context.repo.repo,
+            event: "pull_request",
+            status: "action_required",
+            head_sha: context.payload.pull_request.head.sha,
+            per_page: 100
+          });
+
+          for (var run of result.data.workflow_runs) {
+            await github.rest.actions.approveWorkflowRun({
+              owner: context.repo.owner,
+              repo: context.repo.repo,
+              run_id: run.id
+            });
+          }
diff --git a/.github/workflows/pr-link-check.yaml b/.github/workflows/pr-link-check.yaml
index 9c88e357..b7744cfd 100644
--- a/.github/workflows/pr-link-check.yaml
+++ b/.github/workflows/pr-link-check.yaml
@@ -16,7 +16,7 @@ jobs:
 
     steps:
       - name: Clone repository
-        uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
+        uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0
         with:
           fetch-depth: 0
           ref: ${{github.event.pull_request.head.ref}}
@@ -46,7 +46,7 @@ jobs:
 
       - name: Check links in changed files
         if: env.foundFiles == 'true'
-        uses: lycheeverse/lychee-action@82202e5e9c2f4ef1a55a3d02563e1cb6041e5332 # v2.4.1
+        uses: lycheeverse/lychee-action@885c65f3dc543b57c898c8099f4e08c8afd178a2 # v2.6.1
         with:
           failIfEmpty: false
           args: |
diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml
index af7f88bd..f13b4e0e 100644
--- a/.github/workflows/release.yaml
+++ b/.github/workflows/release.yaml
@@ -37,7 +37,7 @@ jobs:
         env:
           GH_TOKEN: ${{ github.token }}
       - name: Release
-        uses: softprops/action-gh-release@6cbd405e2c4e67a21c47fa9e383d020e4e28b836 # tag=v2.3.3
+        uses: softprops/action-gh-release@aec2ec56f94eb8180ceec724245f64ef008b89f5 # tag=v2.4.0
         with:
           draft: true
           files: out/*
diff --git a/.github/workflows/security-scan.yaml b/.github/workflows/security-scan.yaml
index f4bdf3da..2b93cfc0 100644
--- a/.github/workflows/security-scan.yaml
+++ b/.github/workflows/security-scan.yaml
@@ -13,7 +13,7 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        branch: [main, release-0.13, release-0.12, release-0.11]
+        branch: [main, release-0.13, release-0.12]
     name: Trivy
     runs-on: ubuntu-latest
     steps:
diff --git a/.github/workflows/update-golangci-lint.yaml b/.github/workflows/update-golangci-lint.yaml
index 1d68ad05..487c44ec 100644
--- a/.github/workflows/update-golangci-lint.yaml
+++ b/.github/workflows/update-golangci-lint.yaml
@@ -6,6 +6,7 @@ on:
   workflow_dispatch: # Allows manual triggering
 
 permissions:
+  contents: write
   pull-requests: write
 
 jobs:
diff --git a/Dockerfile b/Dockerfile
index ad6c6b4b..d3aa6fc7 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -14,7 +14,7 @@
 
 # Build the manager binary
 ARG GO_VERSION
-FROM golang:${GO_VERSION} AS builder
+FROM golang:${GO_VERSION:-1.24.8} AS builder
 WORKDIR /workspace
 
 # Run this with docker build --build_arg goproxy=$(go env GOPROXY) to override the goproxy
@@ -36,13 +36,13 @@ COPY ./ ./
 # Build
 ARG package=.
 ARG ARCH
-ARG ldflags
+ARG ldflags=-s -w -extldflags=-static
 
 # Do not force rebuild of up-to-date packages (do not use -a) and use the compiler cache folder
 RUN --mount=type=cache,target=/root/.cache/go-build \
     --mount=type=cache,target=/go/pkg/mod \
     CGO_ENABLED=0 GOOS=linux GOARCH=${ARCH} \
-    go build -ldflags "${ldflags} -extldflags '-static'" \
+    go build -ldflags "${ldflags}" \
     -o manager ${package}
 
 # Production image
diff --git a/Makefile b/Makefile
index a4fe8203..59e9b320 100644
--- a/Makefile
+++ b/Makefile
@@ -27,7 +27,7 @@ unexport GOPATH
 TRACE ?= 0
 
 # Go
-GO_VERSION ?= 1.24.7
+GO_VERSION ?= 1.24.8
 
 # Directories.
 ARTIFACTS ?= $(REPO_ROOT)/_artifacts
@@ -68,6 +68,11 @@ GOLANGCI_LINT := $(TOOLS_BIN_DIR)/golangci-lint
 GOTESTSUM := $(TOOLS_BIN_DIR)/gotestsum
 KUSTOMIZE := $(TOOLS_BIN_DIR)/kustomize
 MOCKGEN := $(TOOLS_BIN_DIR)/mockgen
+OPENAPI_GEN := $(TOOLS_BIN_DIR)/openapi-gen
+APPLYCONFIGURATION_GEN := $(TOOLS_BIN_DIR)/applyconfiguration-gen
+CLIENT_GEN := $(TOOLS_BIN_DIR)/client-gen
+LISTER_GEN := $(TOOLS_BIN_DIR)/lister-gen
+INFORMER_GEN := $(TOOLS_BIN_DIR)/informer-gen
 RELEASE_NOTES := $(TOOLS_BIN_DIR)/release-notes
 SETUP_ENVTEST := $(TOOLS_BIN_DIR)/setup-envtest
 GEN_CRD_API_REFERENCE_DOCS := $(TOOLS_BIN_DIR)/gen-crd-api-reference-docs
@@ -188,7 +193,8 @@ e2e-templates: $(addprefix $(E2E_NO_ARTIFACT_TEMPLATES_DIR)/, \
 		 cluster-template-flatcar-sysext.yaml \
 		 cluster-template-no-bastion.yaml \
 		 cluster-template-health-monitor.yaml \
-		 cluster-template-capi-v1beta1.yaml)
+		 cluster-template-capi-v1beta1.yaml \
+		 cluster-template-cluster-identity.yaml)
 # Currently no templates that require CI artifacts
 # $(addprefix $(E2E_TEMPLATES_DIR)/, add-templates-here.yaml) \
 
@@ -305,8 +311,64 @@ generate-controller-gen: $(CONTROLLER_GEN)
 		object:headerFile=./hack/boilerplate/boilerplate.generatego.txt
 
 .PHONY: generate-codegen
-generate-codegen: generate-controller-gen
-	./hack/update-codegen.sh
+generate-codegen: generate-controller-gen $(OPENAPI_GEN) $(APPLYCONFIGURATION_GEN) $(CLIENT_GEN) $(LISTER_GEN) $(INFORMER_GEN)
+	@echo "** Generating OpenAPI definitions **"
+	# The package list includes:
+	# - CAPO's own API packages (v1alpha1, v1beta1) that have // +k8s:openapi-gen= markers
+	# - Dependency packages from CAPI and k8s.io that are referenced by CAPO's APIs
+	# - Base k8s.io/apimachinery packages
+	$(OPENAPI_GEN) \
+		--go-header-file=./hack/boilerplate.go.txt \
+		--output-file=zz_generated.openapi.go \
+		--output-dir=./cmd/models-schema \
+		--output-pkg=main \
+		--report-filename=./api_violations.report \
+		sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1 \
+		sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1 \
+		sigs.k8s.io/cluster-api/api/core/v1beta2 \
+		sigs.k8s.io/cluster-api/api/ipam/v1beta2 \
+		sigs.k8s.io/cluster-api/api/core/v1beta1 \
+		sigs.k8s.io/cluster-api/api/ipam/v1beta1 \
+		k8s.io/api/core/v1 \
+		k8s.io/apimachinery/pkg/apis/meta/v1 \
+		k8s.io/apimachinery/pkg/runtime \
+		k8s.io/apimachinery/pkg/version
+	@echo "** Generating openapi.json **"
+	go run ./cmd/models-schema | jq > ./openapi.json
+	@echo "** Generating applyconfiguration code **"
+	$(APPLYCONFIGURATION_GEN) \
+		--go-header-file=./hack/boilerplate.go.txt \
+		--output-dir=./pkg/generated/applyconfiguration \
+		--output-pkg=sigs.k8s.io/cluster-api-provider-openstack/pkg/generated/applyconfiguration \
+		--openapi-schema=./openapi.json \
+		sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1 \
+		sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1
+	@echo "** Generating clientset code **"
+	$(CLIENT_GEN) \
+		--go-header-file=./hack/boilerplate.go.txt \
+		--output-dir=./pkg/generated/clientset \
+		--output-pkg=sigs.k8s.io/cluster-api-provider-openstack/pkg/generated/clientset \
+		--clientset-name=clientset \
+		--input-base=sigs.k8s.io/cluster-api-provider-openstack \
+		--apply-configuration-package=sigs.k8s.io/cluster-api-provider-openstack/pkg/generated/applyconfiguration \
+		--input=api/v1alpha1 \
+		--input=api/v1beta1
+	@echo "** Generating lister code **"
+	$(LISTER_GEN) \
+		--go-header-file=./hack/boilerplate.go.txt \
+		--output-dir=./pkg/generated/listers \
+		--output-pkg=sigs.k8s.io/cluster-api-provider-openstack/pkg/generated/listers \
+		sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1 \
+		sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1
+	@echo "** Generating informer code **"
+	$(INFORMER_GEN) \
+		--go-header-file=./hack/boilerplate.go.txt \
+		--output-dir=./pkg/generated/informers \
+		--output-pkg=sigs.k8s.io/cluster-api-provider-openstack/pkg/generated/informers \
+		--versioned-clientset-package=sigs.k8s.io/cluster-api-provider-openstack/pkg/generated/clientset/clientset \
+		--listers-package=sigs.k8s.io/cluster-api-provider-openstack/pkg/generated/listers \
+		sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1 \
+		sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1
 
 .PHONY: generate-manifests
 generate-manifests: $(CONTROLLER_GEN) ## Generate manifests e.g. CRD, RBAC etc.
@@ -340,7 +402,16 @@ generate-api-docs-%: $(GEN_CRD_API_REFERENCE_DOCS) FORCE
 
 .PHONY: docker-build
 docker-build: ## Build the docker image for controller-manager
-	docker build -f Dockerfile --build-arg GO_VERSION=$(GO_VERSION) --build-arg goproxy=$(GOPROXY) --build-arg ARCH=$(ARCH) --build-arg ldflags="$(LDFLAGS)" . -t $(CONTROLLER_IMG_TAG)
+	docker build -f Dockerfile --build-arg GO_VERSION=$(GO_VERSION) \
+	--build-arg goproxy=$(GOPROXY) \
+	--build-arg ARCH=$(ARCH) . -t $(CONTROLLER_IMG_TAG)
+
+.PHONY: docker-build-debug
+docker-build-debug: ## Build the docker image for controller-manager with debug info
+	docker build -f Dockerfile --build-arg GO_VERSION=$(GO_VERSION) \
+	--build-arg goproxy=$(GOPROXY) \
+	--build-arg ARCH=$(ARCH) \
+	--build-arg ldflags="-extldflags=-static" . -t $(CONTROLLER_IMG_TAG)
 
 .PHONY: docker-push
 docker-push: ## Push the docker image
diff --git a/OWNERS_ALIASES b/OWNERS_ALIASES
index d0a42658..7f32d2e4 100644
--- a/OWNERS_ALIASES
+++ b/OWNERS_ALIASES
@@ -21,3 +21,5 @@ aliases:
   cluster-api-openstack-maintainers:
     - emilienm
     - lentzi90
+  cluster-api-openstack-reviewers:
+    - smoshiur1237
diff --git a/PROJECT b/PROJECT
index 602e6dca..a66e8768 100644
--- a/PROJECT
+++ b/PROJECT
@@ -23,5 +23,8 @@ resources:
 - group: infrastructure
   kind: OpenStackServer
   version: v1alpha1
+- group: infrastructure
+  kind: OpenStackClusterIdentity
+  version: v1alpha1
 - group: infrastructure
 version: "2"
diff --git a/api/v1alpha1/openstackclusteridentity_types.go b/api/v1alpha1/openstackclusteridentity_types.go
new file mode 100644
index 00000000..d8dbff3f
--- /dev/null
+++ b/api/v1alpha1/openstackclusteridentity_types.go
@@ -0,0 +1,68 @@
+/*
+Copyright 2025 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+	http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package v1alpha1
+
+import (
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+)
+
+// OpenStackCredentialSecretReference references a Secret containing OpenStack credentials.
+type OpenStackCredentialSecretReference struct {
+	// Name of the Secret which contains a `clouds.yaml` key (and optionally `cacert`).
+	// +kubebuilder:validation:Required
+	Name string `json:"name"`
+
+	// Namespace where the Secret resides.
+	// +kubebuilder:validation:Required
+	Namespace string `json:"namespace"`
+}
+
+// OpenStackClusterIdentitySpec defines the desired state for an OpenStackClusterIdentity.
+type OpenStackClusterIdentitySpec struct {
+	// SecretRef references the credentials Secret containing a `clouds.yaml` file.
+	// +kubebuilder:validation:Required
+	SecretRef OpenStackCredentialSecretReference `json:"secretRef"`
+
+	// NamespaceSelector limits which namespaces may use this identity. If nil, all namespaces are allowed.
+	// +optional
+	NamespaceSelector *metav1.LabelSelector `json:"namespaceSelector,omitempty"`
+}
+
+// +genclient
+// +kubebuilder:object:root=true
+// +kubebuilder:resource:path=openstackclusteridentities,scope=Cluster,categories=cluster-api,shortName=osci
+
+// OpenStackClusterIdentity is a cluster-scoped identity that centralizes OpenStack credentials.
+type OpenStackClusterIdentity struct {
+	metav1.TypeMeta   `json:",inline"`
+	metav1.ObjectMeta `json:"metadata,omitempty"`
+
+	Spec OpenStackClusterIdentitySpec `json:"spec,omitempty"`
+}
+
+// +kubebuilder:object:root=true
+
+// OpenStackClusterIdentityList contains a list of OpenStackClusterIdentity.
+type OpenStackClusterIdentityList struct {
+	metav1.TypeMeta `json:",inline"`
+	metav1.ListMeta `json:"metadata,omitempty"`
+	Items           []OpenStackClusterIdentity `json:"items"`
+}
+
+func init() {
+	SchemeBuilder.Register(&OpenStackClusterIdentity{}, &OpenStackClusterIdentityList{})
+}
diff --git a/api/v1alpha1/zz_generated.deepcopy.go b/api/v1alpha1/zz_generated.deepcopy.go
index 6a2484af..079f5330 100644
--- a/api/v1alpha1/zz_generated.deepcopy.go
+++ b/api/v1alpha1/zz_generated.deepcopy.go
@@ -21,12 +21,107 @@ limitations under the License.
 package v1alpha1
 
 import (
-	"k8s.io/api/core/v1"
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/apis/meta/v1"
 	"k8s.io/apimachinery/pkg/runtime"
 	"sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1"
 	corev1beta1 "sigs.k8s.io/cluster-api/api/core/v1beta1"
 )
 
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *OpenStackClusterIdentity) DeepCopyInto(out *OpenStackClusterIdentity) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ObjectMeta.DeepCopyInto(&out.ObjectMeta)
+	in.Spec.DeepCopyInto(&out.Spec)
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OpenStackClusterIdentity.
+func (in *OpenStackClusterIdentity) DeepCopy() *OpenStackClusterIdentity {
+	if in == nil {
+		return nil
+	}
+	out := new(OpenStackClusterIdentity)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *OpenStackClusterIdentity) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *OpenStackClusterIdentityList) DeepCopyInto(out *OpenStackClusterIdentityList) {
+	*out = *in
+	out.TypeMeta = in.TypeMeta
+	in.ListMeta.DeepCopyInto(&out.ListMeta)
+	if in.Items != nil {
+		in, out := &in.Items, &out.Items
+		*out = make([]OpenStackClusterIdentity, len(*in))
+		for i := range *in {
+			(*in)[i].DeepCopyInto(&(*out)[i])
+		}
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OpenStackClusterIdentityList.
+func (in *OpenStackClusterIdentityList) DeepCopy() *OpenStackClusterIdentityList {
+	if in == nil {
+		return nil
+	}
+	out := new(OpenStackClusterIdentityList)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyObject is an autogenerated deepcopy function, copying the receiver, creating a new runtime.Object.
+func (in *OpenStackClusterIdentityList) DeepCopyObject() runtime.Object {
+	if c := in.DeepCopy(); c != nil {
+		return c
+	}
+	return nil
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *OpenStackClusterIdentitySpec) DeepCopyInto(out *OpenStackClusterIdentitySpec) {
+	*out = *in
+	out.SecretRef = in.SecretRef
+	if in.NamespaceSelector != nil {
+		in, out := &in.NamespaceSelector, &out.NamespaceSelector
+		*out = new(v1.LabelSelector)
+		(*in).DeepCopyInto(*out)
+	}
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OpenStackClusterIdentitySpec.
+func (in *OpenStackClusterIdentitySpec) DeepCopy() *OpenStackClusterIdentitySpec {
+	if in == nil {
+		return nil
+	}
+	out := new(OpenStackClusterIdentitySpec)
+	in.DeepCopyInto(out)
+	return out
+}
+
+// DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
+func (in *OpenStackCredentialSecretReference) DeepCopyInto(out *OpenStackCredentialSecretReference) {
+	*out = *in
+}
+
+// DeepCopy is an autogenerated deepcopy function, copying the receiver, creating a new OpenStackCredentialSecretReference.
+func (in *OpenStackCredentialSecretReference) DeepCopy() *OpenStackCredentialSecretReference {
+	if in == nil {
+		return nil
+	}
+	out := new(OpenStackCredentialSecretReference)
+	in.DeepCopyInto(out)
+	return out
+}
+
 // DeepCopyInto is an autogenerated deepcopy function, copying the receiver, writing into out. in must be non-nil.
 func (in *OpenStackFloatingIPPool) DeepCopyInto(out *OpenStackFloatingIPPool) {
 	*out = *in
@@ -250,7 +345,7 @@ func (in *OpenStackServerSpec) DeepCopyInto(out *OpenStackServerSpec) {
 	}
 	if in.FloatingIPPoolRef != nil {
 		in, out := &in.FloatingIPPoolRef, &out.FloatingIPPoolRef
-		*out = new(v1.TypedLocalObjectReference)
+		*out = new(corev1.TypedLocalObjectReference)
 		(*in).DeepCopyInto(*out)
 	}
 	out.IdentityRef = in.IdentityRef
@@ -296,7 +391,7 @@ func (in *OpenStackServerSpec) DeepCopyInto(out *OpenStackServerSpec) {
 	}
 	if in.UserDataRef != nil {
 		in, out := &in.UserDataRef, &out.UserDataRef
-		*out = new(v1.LocalObjectReference)
+		*out = new(corev1.LocalObjectReference)
 		**out = **in
 	}
 	if in.SchedulerHintAdditionalProperties != nil {
@@ -333,7 +428,7 @@ func (in *OpenStackServerStatus) DeepCopyInto(out *OpenStackServerStatus) {
 	}
 	if in.Addresses != nil {
 		in, out := &in.Addresses, &out.Addresses
-		*out = make([]v1.NodeAddress, len(*in))
+		*out = make([]corev1.NodeAddress, len(*in))
 		copy(*out, *in)
 	}
 	if in.Resolved != nil {
diff --git a/api/v1beta1/identity_types.go b/api/v1beta1/identity_types.go
index 41a74bf6..85add4f6 100644
--- a/api/v1beta1/identity_types.go
+++ b/api/v1beta1/identity_types.go
@@ -20,14 +20,23 @@ package v1beta1
 // provider identity to be used to provision cluster resources.
 // +kubebuilder:validation:XValidation:rule="(!has(self.region) && !has(oldSelf.region)) || self.region == oldSelf.region",message="region is immutable"
 type OpenStackIdentityReference struct {
-	// Name is the name of a secret in the same namespace as the resource being provisioned.
-	// The secret must contain a key named `clouds.yaml` which contains an OpenStack clouds.yaml file.
-	// The secret may optionally contain a key named `cacert` containing a PEM-encoded CA certificate.
+	// Type specifies the identity reference type. Defaults to Secret for backward compatibility.
+	// +kubebuilder:validation:Enum=Secret;ClusterIdentity
+	// +kubebuilder:default=Secret
 	// +kubebuilder:validation:Required
+	Type string `json:"type,omitempty"`
+
+	// Name is the name of a Secret (type=Secret) in the same namespace as the resource being provisioned,
+	// or the name of an OpenStackClusterIdentity (type=ClusterIdentity).
+	// The Secret must contain a key named `clouds.yaml` which contains an OpenStack clouds.yaml file.
+	// The Secret may optionally contain a key named `cacert` containing a PEM-encoded CA certificate.
+	// +kubebuilder:validation:Required
+	// +kubebuilder:validation:MinLength=1
 	Name string `json:"name"`
 
 	// CloudName specifies the name of the entry in the clouds.yaml file to use.
 	// +kubebuilder:validation:Required
+	// +kubebuilder:validation:MinLength=1
 	CloudName string `json:"cloudName"`
 
 	// Region specifies an OpenStack region to use. If specified, it overrides
diff --git a/cmd/models-schema/zz_generated.openapi.go b/cmd/models-schema/zz_generated.openapi.go
index 23b7707f..a6c2ea43 100644
--- a/cmd/models-schema/zz_generated.openapi.go
+++ b/cmd/models-schema/zz_generated.openapi.go
@@ -315,6 +315,10 @@ func GetOpenAPIDefinitions(ref common.ReferenceCallback) map[string]common.OpenA
 		"k8s.io/apimachinery/pkg/runtime.TypeMeta":                                                          schema_k8sio_apimachinery_pkg_runtime_TypeMeta(ref),
 		"k8s.io/apimachinery/pkg/runtime.Unknown":                                                           schema_k8sio_apimachinery_pkg_runtime_Unknown(ref),
 		"k8s.io/apimachinery/pkg/version.Info":                                                              schema_k8sio_apimachinery_pkg_version_Info(ref),
+		"sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1.OpenStackClusterIdentity":                  schema_sigsk8sio_cluster_api_provider_openstack_api_v1alpha1_OpenStackClusterIdentity(ref),
+		"sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1.OpenStackClusterIdentityList":              schema_sigsk8sio_cluster_api_provider_openstack_api_v1alpha1_OpenStackClusterIdentityList(ref),
+		"sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1.OpenStackClusterIdentitySpec":              schema_sigsk8sio_cluster_api_provider_openstack_api_v1alpha1_OpenStackClusterIdentitySpec(ref),
+		"sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1.OpenStackCredentialSecretReference":        schema_sigsk8sio_cluster_api_provider_openstack_api_v1alpha1_OpenStackCredentialSecretReference(ref),
 		"sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1.OpenStackFloatingIPPool":                   schema_sigsk8sio_cluster_api_provider_openstack_api_v1alpha1_OpenStackFloatingIPPool(ref),
 		"sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1.OpenStackFloatingIPPoolList":               schema_sigsk8sio_cluster_api_provider_openstack_api_v1alpha1_OpenStackFloatingIPPoolList(ref),
 		"sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1.OpenStackFloatingIPPoolSpec":               schema_sigsk8sio_cluster_api_provider_openstack_api_v1alpha1_OpenStackFloatingIPPoolSpec(ref),
@@ -16754,6 +16758,155 @@ func schema_k8sio_apimachinery_pkg_version_Info(ref common.ReferenceCallback) co
 	}
 }
 
+func schema_sigsk8sio_cluster_api_provider_openstack_api_v1alpha1_OpenStackClusterIdentity(ref common.ReferenceCallback) common.OpenAPIDefinition {
+	return common.OpenAPIDefinition{
+		Schema: spec.Schema{
+			SchemaProps: spec.SchemaProps{
+				Description: "OpenStackClusterIdentity is a cluster-scoped identity that centralizes OpenStack credentials.",
+				Type:        []string{"object"},
+				Properties: map[string]spec.Schema{
+					"kind": {
+						SchemaProps: spec.SchemaProps{
+							Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
+							Type:        []string{"string"},
+							Format:      "",
+						},
+					},
+					"apiVersion": {
+						SchemaProps: spec.SchemaProps{
+							Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
+							Type:        []string{"string"},
+							Format:      "",
+						},
+					},
+					"metadata": {
+						SchemaProps: spec.SchemaProps{
+							Default: map[string]interface{}{},
+							Ref:     ref("k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta"),
+						},
+					},
+					"spec": {
+						SchemaProps: spec.SchemaProps{
+							Default: map[string]interface{}{},
+							Ref:     ref("sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1.OpenStackClusterIdentitySpec"),
+						},
+					},
+				},
+			},
+		},
+		Dependencies: []string{
+			"k8s.io/apimachinery/pkg/apis/meta/v1.ObjectMeta", "sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1.OpenStackClusterIdentitySpec"},
+	}
+}
+
+func schema_sigsk8sio_cluster_api_provider_openstack_api_v1alpha1_OpenStackClusterIdentityList(ref common.ReferenceCallback) common.OpenAPIDefinition {
+	return common.OpenAPIDefinition{
+		Schema: spec.Schema{
+			SchemaProps: spec.SchemaProps{
+				Description: "OpenStackClusterIdentityList contains a list of OpenStackClusterIdentity.",
+				Type:        []string{"object"},
+				Properties: map[string]spec.Schema{
+					"kind": {
+						SchemaProps: spec.SchemaProps{
+							Description: "Kind is a string value representing the REST resource this object represents. Servers may infer this from the endpoint the client submits requests to. Cannot be updated. In CamelCase. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds",
+							Type:        []string{"string"},
+							Format:      "",
+						},
+					},
+					"apiVersion": {
+						SchemaProps: spec.SchemaProps{
+							Description: "APIVersion defines the versioned schema of this representation of an object. Servers should convert recognized schemas to the latest internal value, and may reject unrecognized values. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources",
+							Type:        []string{"string"},
+							Format:      "",
+						},
+					},
+					"metadata": {
+						SchemaProps: spec.SchemaProps{
+							Default: map[string]interface{}{},
+							Ref:     ref("k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta"),
+						},
+					},
+					"items": {
+						SchemaProps: spec.SchemaProps{
+							Type: []string{"array"},
+							Items: &spec.SchemaOrArray{
+								Schema: &spec.Schema{
+									SchemaProps: spec.SchemaProps{
+										Default: map[string]interface{}{},
+										Ref:     ref("sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1.OpenStackClusterIdentity"),
+									},
+								},
+							},
+						},
+					},
+				},
+				Required: []string{"items"},
+			},
+		},
+		Dependencies: []string{
+			"k8s.io/apimachinery/pkg/apis/meta/v1.ListMeta", "sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1.OpenStackClusterIdentity"},
+	}
+}
+
+func schema_sigsk8sio_cluster_api_provider_openstack_api_v1alpha1_OpenStackClusterIdentitySpec(ref common.ReferenceCallback) common.OpenAPIDefinition {
+	return common.OpenAPIDefinition{
+		Schema: spec.Schema{
+			SchemaProps: spec.SchemaProps{
+				Description: "OpenStackClusterIdentitySpec defines the desired state for an OpenStackClusterIdentity.",
+				Type:        []string{"object"},
+				Properties: map[string]spec.Schema{
+					"secretRef": {
+						SchemaProps: spec.SchemaProps{
+							Description: "SecretRef references the credentials Secret containing a `clouds.yaml` file.",
+							Default:     map[string]interface{}{},
+							Ref:         ref("sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1.OpenStackCredentialSecretReference"),
+						},
+					},
+					"namespaceSelector": {
+						SchemaProps: spec.SchemaProps{
+							Description: "NamespaceSelector limits which namespaces may use this identity. If nil, all namespaces are allowed.",
+							Ref:         ref("k8s.io/apimachinery/pkg/apis/meta/v1.LabelSelector"),
+						},
+					},
+				},
+				Required: []string{"secretRef"},
+			},
+		},
+		Dependencies: []string{
+			"k8s.io/apimachinery/pkg/apis/meta/v1.LabelSelector", "sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1.OpenStackCredentialSecretReference"},
+	}
+}
+
+func schema_sigsk8sio_cluster_api_provider_openstack_api_v1alpha1_OpenStackCredentialSecretReference(ref common.ReferenceCallback) common.OpenAPIDefinition {
+	return common.OpenAPIDefinition{
+		Schema: spec.Schema{
+			SchemaProps: spec.SchemaProps{
+				Description: "OpenStackCredentialSecretReference references a Secret containing OpenStack credentials.",
+				Type:        []string{"object"},
+				Properties: map[string]spec.Schema{
+					"name": {
+						SchemaProps: spec.SchemaProps{
+							Description: "Name of the Secret which contains a `clouds.yaml` key (and optionally `cacert`).",
+							Default:     "",
+							Type:        []string{"string"},
+							Format:      "",
+						},
+					},
+					"namespace": {
+						SchemaProps: spec.SchemaProps{
+							Description: "Namespace where the Secret resides.",
+							Default:     "",
+							Type:        []string{"string"},
+							Format:      "",
+						},
+					},
+				},
+				Required: []string{"name", "namespace"},
+			},
+		},
+	}
+}
+
 func schema_sigsk8sio_cluster_api_provider_openstack_api_v1alpha1_OpenStackFloatingIPPool(ref common.ReferenceCallback) common.OpenAPIDefinition {
 	return common.OpenAPIDefinition{
 		Schema: spec.Schema{
@@ -19120,9 +19273,16 @@ func schema_sigsk8sio_cluster_api_provider_openstack_api_v1beta1_OpenStackIdenti
 				Description: "OpenStackIdentityReference is a reference to an infrastructure provider identity to be used to provision cluster resources.",
 				Type:        []string{"object"},
 				Properties: map[string]spec.Schema{
+					"type": {
+						SchemaProps: spec.SchemaProps{
+							Description: "Type specifies the identity reference type. Defaults to Secret for backward compatibility.",
+							Type:        []string{"string"},
+							Format:      "",
+						},
+					},
 					"name": {
 						SchemaProps: spec.SchemaProps{
-							Description: "Name is the name of a secret in the same namespace as the resource being provisioned. The secret must contain a key named `clouds.yaml` which contains an OpenStack clouds.yaml file. The secret may optionally contain a key named `cacert` containing a PEM-encoded CA certificate.",
+							Description: "Name is the name of a Secret (type=Secret) in the same namespace as the resource being provisioned, or the name of an OpenStackClusterIdentity (type=ClusterIdentity). The Secret must contain a key named `clouds.yaml` which contains an OpenStack clouds.yaml file. The Secret may optionally contain a key named `cacert` containing a PEM-encoded CA certificate.",
 							Default:     "",
 							Type:        []string{"string"},
 							Format:      "",
diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackclusteridentities.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackclusteridentities.yaml
new file mode 100644
index 00000000..4cdedd73
--- /dev/null
+++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackclusteridentities.yaml
@@ -0,0 +1,115 @@
+---
+apiVersion: apiextensions.k8s.io/v1
+kind: CustomResourceDefinition
+metadata:
+  annotations:
+    controller-gen.kubebuilder.io/version: v0.18.0
+  name: openstackclusteridentities.infrastructure.cluster.x-k8s.io
+spec:
+  group: infrastructure.cluster.x-k8s.io
+  names:
+    categories:
+    - cluster-api
+    kind: OpenStackClusterIdentity
+    listKind: OpenStackClusterIdentityList
+    plural: openstackclusteridentities
+    shortNames:
+    - osci
+    singular: openstackclusteridentity
+  scope: Cluster
+  versions:
+  - name: v1alpha1
+    schema:
+      openAPIV3Schema:
+        description: OpenStackClusterIdentity is a cluster-scoped identity that centralizes
+          OpenStack credentials.
+        properties:
+          apiVersion:
+            description: |-
+              APIVersion defines the versioned schema of this representation of an object.
+              Servers should convert recognized schemas to the latest internal value, and
+              may reject unrecognized values.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#resources
+            type: string
+          kind:
+            description: |-
+              Kind is a string value representing the REST resource this object represents.
+              Servers may infer this from the endpoint the client submits requests to.
+              Cannot be updated.
+              In CamelCase.
+              More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#types-kinds
+            type: string
+          metadata:
+            type: object
+          spec:
+            description: OpenStackClusterIdentitySpec defines the desired state for
+              an OpenStackClusterIdentity.
+            properties:
+              namespaceSelector:
+                description: NamespaceSelector limits which namespaces may use this
+                  identity. If nil, all namespaces are allowed.
+                properties:
+                  matchExpressions:
+                    description: matchExpressions is a list of label selector requirements.
+                      The requirements are ANDed.
+                    items:
+                      description: |-
+                        A label selector requirement is a selector that contains values, a key, and an operator that
+                        relates the key and values.
+                      properties:
+                        key:
+                          description: key is the label key that the selector applies
+                            to.
+                          type: string
+                        operator:
+                          description: |-
+                            operator represents a key's relationship to a set of values.
+                            Valid operators are In, NotIn, Exists and DoesNotExist.
+                          type: string
+                        values:
+                          description: |-
+                            values is an array of string values. If the operator is In or NotIn,
+                            the values array must be non-empty. If the operator is Exists or DoesNotExist,
+                            the values array must be empty. This array is replaced during a strategic
+                            merge patch.
+                          items:
+                            type: string
+                          type: array
+                          x-kubernetes-list-type: atomic
+                      required:
+                      - key
+                      - operator
+                      type: object
+                    type: array
+                    x-kubernetes-list-type: atomic
+                  matchLabels:
+                    additionalProperties:
+                      type: string
+                    description: |-
+                      matchLabels is a map of {key,value} pairs. A single {key,value} in the matchLabels
+                      map is equivalent to an element of matchExpressions, whose key field is "key", the
+                      operator is "In", and the values array contains only "value". The requirements are ANDed.
+                    type: object
+                type: object
+                x-kubernetes-map-type: atomic
+              secretRef:
+                description: SecretRef references the credentials Secret containing
+                  a `clouds.yaml` file.
+                properties:
+                  name:
+                    description: Name of the Secret which contains a `clouds.yaml`
+                      key (and optionally `cacert`).
+                    type: string
+                  namespace:
+                    description: Namespace where the Secret resides.
+                    type: string
+                required:
+                - name
+                - namespace
+                type: object
+            required:
+            - secretRef
+            type: object
+        type: object
+    served: true
+    storage: true
diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackclusters.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackclusters.yaml
index d533d981..4cf9b3da 100644
--- a/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackclusters.yaml
+++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackclusters.yaml
@@ -508,12 +508,15 @@ spec:
                           cloudName:
                             description: CloudName specifies the name of the entry
                               in the clouds.yaml file to use.
+                            minLength: 1
                             type: string
                           name:
                             description: |-
-                              Name is the name of a secret in the same namespace as the resource being provisioned.
-                              The secret must contain a key named `clouds.yaml` which contains an OpenStack clouds.yaml file.
-                              The secret may optionally contain a key named `cacert` containing a PEM-encoded CA certificate.
+                              Name is the name of a Secret (type=Secret) in the same namespace as the resource being provisioned,
+                              or the name of an OpenStackClusterIdentity (type=ClusterIdentity).
+                              The Secret must contain a key named `clouds.yaml` which contains an OpenStack clouds.yaml file.
+                              The Secret may optionally contain a key named `cacert` containing a PEM-encoded CA certificate.
+                            minLength: 1
                             type: string
                           region:
                             description: |-
@@ -521,9 +524,18 @@ spec:
                               any value in clouds.yaml. If specified for an OpenStackMachine, its
                               value will be included in providerID.
                             type: string
+                          type:
+                            default: Secret
+                            description: Type specifies the identity reference type.
+                              Defaults to Secret for backward compatibility.
+                            enum:
+                            - Secret
+                            - ClusterIdentity
+                            type: string
                         required:
                         - cloudName
                         - name
+                        - type
                         type: object
                         x-kubernetes-validations:
                         - message: region is immutable
@@ -1518,12 +1530,15 @@ spec:
                   cloudName:
                     description: CloudName specifies the name of the entry in the
                       clouds.yaml file to use.
+                    minLength: 1
                     type: string
                   name:
                     description: |-
-                      Name is the name of a secret in the same namespace as the resource being provisioned.
-                      The secret must contain a key named `clouds.yaml` which contains an OpenStack clouds.yaml file.
-                      The secret may optionally contain a key named `cacert` containing a PEM-encoded CA certificate.
+                      Name is the name of a Secret (type=Secret) in the same namespace as the resource being provisioned,
+                      or the name of an OpenStackClusterIdentity (type=ClusterIdentity).
+                      The Secret must contain a key named `clouds.yaml` which contains an OpenStack clouds.yaml file.
+                      The Secret may optionally contain a key named `cacert` containing a PEM-encoded CA certificate.
+                    minLength: 1
                     type: string
                   region:
                     description: |-
@@ -1531,9 +1546,18 @@ spec:
                       any value in clouds.yaml. If specified for an OpenStackMachine, its
                       value will be included in providerID.
                     type: string
+                  type:
+                    default: Secret
+                    description: Type specifies the identity reference type. Defaults
+                      to Secret for backward compatibility.
+                    enum:
+                    - Secret
+                    - ClusterIdentity
+                    type: string
                 required:
                 - cloudName
                 - name
+                - type
                 type: object
                 x-kubernetes-validations:
                 - message: region is immutable
diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackclustertemplates.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackclustertemplates.yaml
index 7dbf0539..084846a6 100644
--- a/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackclustertemplates.yaml
+++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackclustertemplates.yaml
@@ -497,12 +497,15 @@ spec:
                                   cloudName:
                                     description: CloudName specifies the name of the
                                       entry in the clouds.yaml file to use.
+                                    minLength: 1
                                     type: string
                                   name:
                                     description: |-
-                                      Name is the name of a secret in the same namespace as the resource being provisioned.
-                                      The secret must contain a key named `clouds.yaml` which contains an OpenStack clouds.yaml file.
-                                      The secret may optionally contain a key named `cacert` containing a PEM-encoded CA certificate.
+                                      Name is the name of a Secret (type=Secret) in the same namespace as the resource being provisioned,
+                                      or the name of an OpenStackClusterIdentity (type=ClusterIdentity).
+                                      The Secret must contain a key named `clouds.yaml` which contains an OpenStack clouds.yaml file.
+                                      The Secret may optionally contain a key named `cacert` containing a PEM-encoded CA certificate.
+                                    minLength: 1
                                     type: string
                                   region:
                                     description: |-
@@ -510,9 +513,18 @@ spec:
                                       any value in clouds.yaml. If specified for an OpenStackMachine, its
                                       value will be included in providerID.
                                     type: string
+                                  type:
+                                    default: Secret
+                                    description: Type specifies the identity reference
+                                      type. Defaults to Secret for backward compatibility.
+                                    enum:
+                                    - Secret
+                                    - ClusterIdentity
+                                    type: string
                                 required:
                                 - cloudName
                                 - name
+                                - type
                                 type: object
                                 x-kubernetes-validations:
                                 - message: region is immutable
@@ -1525,12 +1537,15 @@ spec:
                           cloudName:
                             description: CloudName specifies the name of the entry
                               in the clouds.yaml file to use.
+                            minLength: 1
                             type: string
                           name:
                             description: |-
-                              Name is the name of a secret in the same namespace as the resource being provisioned.
-                              The secret must contain a key named `clouds.yaml` which contains an OpenStack clouds.yaml file.
-                              The secret may optionally contain a key named `cacert` containing a PEM-encoded CA certificate.
+                              Name is the name of a Secret (type=Secret) in the same namespace as the resource being provisioned,
+                              or the name of an OpenStackClusterIdentity (type=ClusterIdentity).
+                              The Secret must contain a key named `clouds.yaml` which contains an OpenStack clouds.yaml file.
+                              The Secret may optionally contain a key named `cacert` containing a PEM-encoded CA certificate.
+                            minLength: 1
                             type: string
                           region:
                             description: |-
@@ -1538,9 +1553,18 @@ spec:
                               any value in clouds.yaml. If specified for an OpenStackMachine, its
                               value will be included in providerID.
                             type: string
+                          type:
+                            default: Secret
+                            description: Type specifies the identity reference type.
+                              Defaults to Secret for backward compatibility.
+                            enum:
+                            - Secret
+                            - ClusterIdentity
+                            type: string
                         required:
                         - cloudName
                         - name
+                        - type
                         type: object
                         x-kubernetes-validations:
                         - message: region is immutable
diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackfloatingippools.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackfloatingippools.yaml
index e4ecd7e2..71e24ac4 100644
--- a/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackfloatingippools.yaml
+++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackfloatingippools.yaml
@@ -126,12 +126,15 @@ spec:
                   cloudName:
                     description: CloudName specifies the name of the entry in the
                       clouds.yaml file to use.
+                    minLength: 1
                     type: string
                   name:
                     description: |-
-                      Name is the name of a secret in the same namespace as the resource being provisioned.
-                      The secret must contain a key named `clouds.yaml` which contains an OpenStack clouds.yaml file.
-                      The secret may optionally contain a key named `cacert` containing a PEM-encoded CA certificate.
+                      Name is the name of a Secret (type=Secret) in the same namespace as the resource being provisioned,
+                      or the name of an OpenStackClusterIdentity (type=ClusterIdentity).
+                      The Secret must contain a key named `clouds.yaml` which contains an OpenStack clouds.yaml file.
+                      The Secret may optionally contain a key named `cacert` containing a PEM-encoded CA certificate.
+                    minLength: 1
                     type: string
                   region:
                     description: |-
@@ -139,9 +142,18 @@ spec:
                       any value in clouds.yaml. If specified for an OpenStackMachine, its
                       value will be included in providerID.
                     type: string
+                  type:
+                    default: Secret
+                    description: Type specifies the identity reference type. Defaults
+                      to Secret for backward compatibility.
+                    enum:
+                    - Secret
+                    - ClusterIdentity
+                    type: string
                 required:
                 - cloudName
                 - name
+                - type
                 type: object
                 x-kubernetes-validations:
                 - message: region is immutable
diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackmachines.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackmachines.yaml
index 608664dc..0dc88ef8 100644
--- a/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackmachines.yaml
+++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackmachines.yaml
@@ -195,12 +195,15 @@ spec:
                   cloudName:
                     description: CloudName specifies the name of the entry in the
                       clouds.yaml file to use.
+                    minLength: 1
                     type: string
                   name:
                     description: |-
-                      Name is the name of a secret in the same namespace as the resource being provisioned.
-                      The secret must contain a key named `clouds.yaml` which contains an OpenStack clouds.yaml file.
-                      The secret may optionally contain a key named `cacert` containing a PEM-encoded CA certificate.
+                      Name is the name of a Secret (type=Secret) in the same namespace as the resource being provisioned,
+                      or the name of an OpenStackClusterIdentity (type=ClusterIdentity).
+                      The Secret must contain a key named `clouds.yaml` which contains an OpenStack clouds.yaml file.
+                      The Secret may optionally contain a key named `cacert` containing a PEM-encoded CA certificate.
+                    minLength: 1
                     type: string
                   region:
                     description: |-
@@ -208,9 +211,18 @@ spec:
                       any value in clouds.yaml. If specified for an OpenStackMachine, its
                       value will be included in providerID.
                     type: string
+                  type:
+                    default: Secret
+                    description: Type specifies the identity reference type. Defaults
+                      to Secret for backward compatibility.
+                    enum:
+                    - Secret
+                    - ClusterIdentity
+                    type: string
                 required:
                 - cloudName
                 - name
+                - type
                 type: object
                 x-kubernetes-validations:
                 - message: region is immutable
diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackmachinetemplates.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackmachinetemplates.yaml
index 1d37f987..e57463f1 100644
--- a/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackmachinetemplates.yaml
+++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackmachinetemplates.yaml
@@ -186,12 +186,15 @@ spec:
                           cloudName:
                             description: CloudName specifies the name of the entry
                               in the clouds.yaml file to use.
+                            minLength: 1
                             type: string
                           name:
                             description: |-
-                              Name is the name of a secret in the same namespace as the resource being provisioned.
-                              The secret must contain a key named `clouds.yaml` which contains an OpenStack clouds.yaml file.
-                              The secret may optionally contain a key named `cacert` containing a PEM-encoded CA certificate.
+                              Name is the name of a Secret (type=Secret) in the same namespace as the resource being provisioned,
+                              or the name of an OpenStackClusterIdentity (type=ClusterIdentity).
+                              The Secret must contain a key named `clouds.yaml` which contains an OpenStack clouds.yaml file.
+                              The Secret may optionally contain a key named `cacert` containing a PEM-encoded CA certificate.
+                            minLength: 1
                             type: string
                           region:
                             description: |-
@@ -199,9 +202,18 @@ spec:
                               any value in clouds.yaml. If specified for an OpenStackMachine, its
                               value will be included in providerID.
                             type: string
+                          type:
+                            default: Secret
+                            description: Type specifies the identity reference type.
+                              Defaults to Secret for backward compatibility.
+                            enum:
+                            - Secret
+                            - ClusterIdentity
+                            type: string
                         required:
                         - cloudName
                         - name
+                        - type
                         type: object
                         x-kubernetes-validations:
                         - message: region is immutable
diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackservers.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackservers.yaml
index 99905f06..31de37e0 100644
--- a/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackservers.yaml
+++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_openstackservers.yaml
@@ -192,12 +192,15 @@ spec:
                   cloudName:
                     description: CloudName specifies the name of the entry in the
                       clouds.yaml file to use.
+                    minLength: 1
                     type: string
                   name:
                     description: |-
-                      Name is the name of a secret in the same namespace as the resource being provisioned.
-                      The secret must contain a key named `clouds.yaml` which contains an OpenStack clouds.yaml file.
-                      The secret may optionally contain a key named `cacert` containing a PEM-encoded CA certificate.
+                      Name is the name of a Secret (type=Secret) in the same namespace as the resource being provisioned,
+                      or the name of an OpenStackClusterIdentity (type=ClusterIdentity).
+                      The Secret must contain a key named `clouds.yaml` which contains an OpenStack clouds.yaml file.
+                      The Secret may optionally contain a key named `cacert` containing a PEM-encoded CA certificate.
+                    minLength: 1
                     type: string
                   region:
                     description: |-
@@ -205,9 +208,18 @@ spec:
                       any value in clouds.yaml. If specified for an OpenStackMachine, its
                       value will be included in providerID.
                     type: string
+                  type:
+                    default: Secret
+                    description: Type specifies the identity reference type. Defaults
+                      to Secret for backward compatibility.
+                    enum:
+                    - Secret
+                    - ClusterIdentity
+                    type: string
                 required:
                 - cloudName
                 - name
+                - type
                 type: object
                 x-kubernetes-validations:
                 - message: region is immutable
diff --git a/config/crd/kustomization.yaml b/config/crd/kustomization.yaml
index c195c151..24258bb5 100644
--- a/config/crd/kustomization.yaml
+++ b/config/crd/kustomization.yaml
@@ -8,6 +8,7 @@ labels:
 # It should be run by config/
 resources:
 - bases/infrastructure.cluster.x-k8s.io_openstackclusters.yaml
+- bases/infrastructure.cluster.x-k8s.io_openstackclusteridentities.yaml
 - bases/infrastructure.cluster.x-k8s.io_openstackmachines.yaml
 - bases/infrastructure.cluster.x-k8s.io_openstackmachinetemplates.yaml
 - bases/infrastructure.cluster.x-k8s.io_openstackclustertemplates.yaml
diff --git a/config/rbac/role.yaml b/config/rbac/role.yaml
index 5428aab1..dc44ee6b 100644
--- a/config/rbac/role.yaml
+++ b/config/rbac/role.yaml
@@ -18,6 +18,7 @@ rules:
 - apiGroups:
   - ""
   resources:
+  - namespaces
   - secrets
   verbs:
   - get
@@ -46,6 +47,14 @@ rules:
   - get
   - list
   - watch
+- apiGroups:
+  - infrastructure.cluster.x-k8s.io
+  resources:
+  - openstackclusteridentities
+  verbs:
+  - get
+  - list
+  - watch
 - apiGroups:
   - infrastructure.cluster.x-k8s.io
   resources:
diff --git a/controllers/openstackcluster_controller.go b/controllers/openstackcluster_controller.go
index abb5ee22..3989fc15 100644
--- a/controllers/openstackcluster_controller.go
+++ b/controllers/openstackcluster_controller.go
@@ -75,6 +75,8 @@ type OpenStackClusterReconciler struct {
 // +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=openstackclusters,verbs=get;list;watch;create;update;patch;delete
 // +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=openstackclusters/status,verbs=get;update;patch
 // +kubebuilder:rbac:groups=cluster.x-k8s.io,resources=clusters;clusters/status,verbs=get;list;watch
+// +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch
+// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=openstackclusteridentities,verbs=get;list;watch
 
 func (r *OpenStackClusterReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, reterr error) {
 	log := ctrl.LoggerFrom(ctx)
diff --git a/controllers/openstackmachine_controller.go b/controllers/openstackmachine_controller.go
index 51e1c91d..d54fecf3 100644
--- a/controllers/openstackmachine_controller.go
+++ b/controllers/openstackmachine_controller.go
@@ -77,6 +77,8 @@ const (
 // +kubebuilder:rbac:groups=ipam.cluster.x-k8s.io,resources=ipaddresses;ipaddresses/status,verbs=get;list;watch
 // +kubebuilder:rbac:groups="",resources=secrets;,verbs=get;list;watch
 // +kubebuilder:rbac:groups="",resources=events,verbs=get;list;watch;create;update;patch
+// +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch
+// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=openstackclusteridentities,verbs=get;list;watch
 
 func (r *OpenStackMachineReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, reterr error) {
 	log := ctrl.LoggerFrom(ctx)
diff --git a/controllers/openstackserver_controller.go b/controllers/openstackserver_controller.go
index 8ea05b8c..524324d9 100644
--- a/controllers/openstackserver_controller.go
+++ b/controllers/openstackserver_controller.go
@@ -81,6 +81,8 @@ type OpenStackServerReconciler struct {
 // +kubebuilder:rbac:groups=ipam.cluster.x-k8s.io,resources=ipaddressclaims;ipaddressclaims/status,verbs=get;watch;create;update;patch;delete
 // +kubebuilder:rbac:groups=ipam.cluster.x-k8s.io,resources=ipaddresses;ipaddresses/status,verbs=get;list;watch
 // +kubebuilder:rbac:groups=openstack.k-orc.cloud,resources=images,verbs=get;list;watch
+// +kubebuilder:rbac:groups="",resources=namespaces,verbs=get;list;watch
+// +kubebuilder:rbac:groups=infrastructure.cluster.x-k8s.io,resources=openstackclusteridentities,verbs=get;list;watch
 
 func (r *OpenStackServerReconciler) Reconcile(ctx context.Context, req ctrl.Request) (result ctrl.Result, reterr error) {
 	log := ctrl.LoggerFrom(ctx)
diff --git a/docs/book/src/SUMMARY.md b/docs/book/src/SUMMARY.md
index 91ce91ac..70b60909 100644
--- a/docs/book/src/SUMMARY.md
+++ b/docs/book/src/SUMMARY.md
@@ -8,6 +8,7 @@
     - [hosted control plane](./topics/hosted-control-plane.md)
     - [move from bootstrap](./topics/mover.md)
     - [trouble shooting](./topics/troubleshooting.md)
+    - [OpenStackClusterIdentity](./topics/openstack-cluster-identity.md)
     - [CRD Changes](./topics/crd-changes/index.md)
         - [v1alpha4 to v1alpha5](./topics/crd-changes/v1alpha4-to-v1alpha5.md)
         - [v1alpha5 to v1alpha6](./topics/crd-changes/v1alpha5-to-v1alpha6.md)
diff --git a/docs/book/src/api/v1alpha1/api.md b/docs/book/src/api/v1alpha1/api.md
index 87776efd..4c14706b 100644
--- a/docs/book/src/api/v1alpha1/api.md
+++ b/docs/book/src/api/v1alpha1/api.md
@@ -4,8 +4,95 @@
 </p>
 Resource Types:
 <ul><li>
+<a href="#infrastructure.cluster.x-k8s.io/v1alpha1.OpenStackClusterIdentity">OpenStackClusterIdentity</a>
+</li><li>
 <a href="#infrastructure.cluster.x-k8s.io/v1alpha1.OpenStackServer">OpenStackServer</a>
 </li></ul>
+<h3 id="infrastructure.cluster.x-k8s.io/v1alpha1.OpenStackClusterIdentity">OpenStackClusterIdentity
+</h3>
+<p>
+<p>OpenStackClusterIdentity is a cluster-scoped identity that centralizes OpenStack credentials.</p>
+</p>
+<table>
+<thead>
+<tr>
+<th>Field</th>
+<th>Description</th>
+</tr>
+</thead>
+<tbody>
+<tr>
+<td>
+<code>apiVersion</code><br/>
+string</td>
+<td>
+<code>
+infrastructure.cluster.x-k8s.io/v1alpha1
+</code>
+</td>
+</tr>
+<tr>
+<td>
+<code>kind</code><br/>
+string
+</td>
+<td><code>OpenStackClusterIdentity</code></td>
+</tr>
+<tr>
+<td>
+<code>metadata</code><br/>
+<em>
+Kubernetes meta/v1.ObjectMeta
+</em>
+</td>
+<td>
+Refer to the Kubernetes API documentation for the fields of the
+<code>metadata</code> field.
+</td>
+</tr>
+<tr>
+<td>
+<code>spec</code><br/>
+<em>
+<a href="#infrastructure.cluster.x-k8s.io/v1alpha1.OpenStackClusterIdentitySpec">
+OpenStackClusterIdentitySpec
+</a>
+</em>
+</td>
+<td>
+<br/>
+<br/>
+<table>
+<tr>
+<td>
+<code>secretRef</code><br/>
+<em>
+<a href="#infrastructure.cluster.x-k8s.io/v1alpha1.OpenStackCredentialSecretReference">
+OpenStackCredentialSecretReference
+</a>
+</em>
+</td>
+<td>
+<p>SecretRef references the credentials Secret containing a <code>clouds.yaml</code> file.</p>
+</td>
+</tr>
+<tr>
+<td>
+<code>namespaceSelector</code><br/>
+<em>
+Kubernetes meta/v1.LabelSelector
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+<p>NamespaceSelector limits which namespaces may use this identity. If nil, all namespaces are allowed.</p>
+</td>
+</tr>
+</table>
+</td>
+</tr>
+</tbody>
+</table>
 <h3 id="infrastructure.cluster.x-k8s.io/v1alpha1.OpenStackServer">OpenStackServer
 </h3>
 <p>
@@ -314,6 +401,91 @@ OpenStackServerStatus
 </tr>
 </tbody>
 </table>
+<h3 id="infrastructure.cluster.x-k8s.io/v1alpha1.OpenStackClusterIdentitySpec">OpenStackClusterIdentitySpec
+</h3>
+<p>
+(<em>Appears on:</em>
+<a href="#infrastructure.cluster.x-k8s.io/v1alpha1.OpenStackClusterIdentity">OpenStackClusterIdentity</a>)
+</p>
+<p>
+<p>OpenStackClusterIdentitySpec defines the desired state for an OpenStackClusterIdentity.</p>
+</p>
+<table>
+<thead>
+<tr>
+<th>Field</th>
+<th>Description</th>
+</tr>
+</thead>
+<tbody>
+<tr>
+<td>
+<code>secretRef</code><br/>
+<em>
+<a href="#infrastructure.cluster.x-k8s.io/v1alpha1.OpenStackCredentialSecretReference">
+OpenStackCredentialSecretReference
+</a>
+</em>
+</td>
+<td>
+<p>SecretRef references the credentials Secret containing a <code>clouds.yaml</code> file.</p>
+</td>
+</tr>
+<tr>
+<td>
+<code>namespaceSelector</code><br/>
+<em>
+Kubernetes meta/v1.LabelSelector
+</em>
+</td>
+<td>
+<em>(Optional)</em>
+<p>NamespaceSelector limits which namespaces may use this identity. If nil, all namespaces are allowed.</p>
+</td>
+</tr>
+</tbody>
+</table>
+<h3 id="infrastructure.cluster.x-k8s.io/v1alpha1.OpenStackCredentialSecretReference">OpenStackCredentialSecretReference
+</h3>
+<p>
+(<em>Appears on:</em>
+<a href="#infrastructure.cluster.x-k8s.io/v1alpha1.OpenStackClusterIdentitySpec">OpenStackClusterIdentitySpec</a>)
+</p>
+<p>
+<p>OpenStackCredentialSecretReference references a Secret containing OpenStack credentials.</p>
+</p>
+<table>
+<thead>
+<tr>
+<th>Field</th>
+<th>Description</th>
+</tr>
+</thead>
+<tbody>
+<tr>
+<td>
+<code>name</code><br/>
+<em>
+string
+</em>
+</td>
+<td>
+<p>Name of the Secret which contains a <code>clouds.yaml</code> key (and optionally <code>cacert</code>).</p>
+</td>
+</tr>
+<tr>
+<td>
+<code>namespace</code><br/>
+<em>
+string
+</em>
+</td>
+<td>
+<p>Namespace where the Secret resides.</p>
+</td>
+</tr>
+</tbody>
+</table>
 <h3 id="infrastructure.cluster.x-k8s.io/v1alpha1.OpenStackFloatingIPPool">OpenStackFloatingIPPool
 </h3>
 <p>
diff --git a/docs/book/src/api/v1beta1/api.md b/docs/book/src/api/v1beta1/api.md
index 6937c5dd..0eb377d1 100644
--- a/docs/book/src/api/v1beta1/api.md
+++ b/docs/book/src/api/v1beta1/api.md
@@ -3277,15 +3277,27 @@ provider identity to be used to provision cluster resources.</p>
 <tbody>
 <tr>
 <td>
+<code>type</code><br/>
+<em>
+string
+</em>
+</td>
+<td>
+<p>Type specifies the identity reference type. Defaults to Secret for backward compatibility.</p>
+</td>
+</tr>
+<tr>
+<td>
 <code>name</code><br/>
 <em>
 string
 </em>
 </td>
 <td>
-<p>Name is the name of a secret in the same namespace as the resource being provisioned.
-The secret must contain a key named <code>clouds.yaml</code> which contains an OpenStack clouds.yaml file.
-The secret may optionally contain a key named <code>cacert</code> containing a PEM-encoded CA certificate.</p>
+<p>Name is the name of a Secret (type=Secret) in the same namespace as the resource being provisioned,
+or the name of an OpenStackClusterIdentity (type=ClusterIdentity).
+The Secret must contain a key named <code>clouds.yaml</code> which contains an OpenStack clouds.yaml file.
+The Secret may optionally contain a key named <code>cacert</code> containing a PEM-encoded CA certificate.</p>
 </td>
 </tr>
 <tr>
diff --git a/docs/book/src/topics/openstack-cluster-identity.md b/docs/book/src/topics/openstack-cluster-identity.md
new file mode 100644
index 00000000..281e68a8
--- /dev/null
+++ b/docs/book/src/topics/openstack-cluster-identity.md
@@ -0,0 +1,100 @@
+# Centralized credentials with OpenStackClusterIdentity
+
+This guide explains how to centralize OpenStack credentials using a cluster-scoped OpenStackClusterIdentity and reference it from clusters and machines.
+
+## Overview
+- OpenStackClusterIdentity (cluster-scoped): stores a reference to a Secret that contains `clouds.yaml` (and optional `cacert`), and optionally restricts which namespaces may use it via `namespaceSelector`.
+- OpenStackIdentityReference (on OpenStackCluster/OpenStackMachine/OpenStackServer): carries `type`, `name`, and `cloudName`.
+  - `type: Secret` (default): `name` is the Secret name in the same namespace.
+  - `type: ClusterIdentity`: `name` is the OpenStackClusterIdentity name; Secret location is taken from the identity.
+  - For both types, `cloudName` is required and selects the entry in `clouds.yaml`.
+
+## Prerequisites
+- A Secret containing OpenStack credentials in `clouds.yaml`:
+```yaml
+apiVersion: v1
+kind: Secret
+metadata:
+  name: openstack-credentials
+  namespace: capo-system
+stringData:
+  clouds.yaml: |
+    clouds:
+      openstack:
+        auth:
+          auth_url: https://keystone.example.com/
+          application_credential_id: <id>
+          application_credential_secret: <secret>
+        region_name: RegionOne
+        interface: public
+        identity_api_version: 3
+        auth_type: v3applicationcredential
+  # Optional CA certificate
+  # cacert: |
+  #   -----BEGIN CERTIFICATE-----
+  #   ...
+  #   -----END CERTIFICATE-----
+```
+
+## Create an OpenStackClusterIdentity
+- Optionally restrict which namespaces can use it with `namespaceSelector`.
+```yaml
+apiVersion: infrastructure.cluster.x-k8s.io/v1alpha1
+kind: OpenStackClusterIdentity
+metadata:
+  name: production-openstack
+spec:
+  secretRef:
+    name: openstack-credentials
+    namespace: capo-system
+  namespaceSelector:
+    matchExpressions:
+    - key: kubernetes.io/metadata.name
+      operator: In
+      values: [team-a, team-b]
+```
+
+## Reference the identity from OpenStackCluster
+- Use `type: ClusterIdentity`, specify the identity `name`, and the `cloudName` to select the clouds.yaml entry.
+```yaml
+apiVersion: infrastructure.cluster.x-k8s.io/v1beta1
+kind: OpenStackCluster
+metadata:
+  name: cluster-a
+  namespace: team-a
+spec:
+  identityRef:
+    type: ClusterIdentity
+    name: production-openstack
+    cloudName: openstack
+```
+
+## Using a Secret directly (default)
+- If you don’t need cross-namespace identities, use a Secret in the same namespace.
+```yaml
+spec:
+  identityRef:
+    # type defaults to Secret
+    name: my-secret
+    cloudName: openstack
+```
+
+## Access control behavior
+- If `namespaceSelector` is not set: all namespaces may use the identity.
+- If set: only namespaces matching the selector may use the identity.
+- If access is denied, the controller returns an error and reconciliation fails. A Warning event can be emitted by the controller with a reason such as `IdentityAccessDenied`.
+
+## RBAC requirements
+Ensure the controller has permissions to read: (included in role.yaml by default)
+```yaml
+- apiGroups: [""]
+  resources: ["namespaces"]
+  verbs: ["get"]
+- apiGroups: ["infrastructure.cluster.x-k8s.io"]
+  resources: ["openstackclusteridentities"]
+  verbs: ["get","list","watch"]
+```
+
+## Notes
+- `cloudName` is required on `identityRef` for both `type: Secret` and `type: ClusterIdentity`.
+- The Secret must contain a `clouds.yaml` key, and may optionally contain `cacert`.
diff --git a/docs/proposals/20250722-openstackclusteridentity.md b/docs/proposals/20250722-openstackclusteridentity.md
index 73a9e78f..0f19a1f6 100644
--- a/docs/proposals/20250722-openstackclusteridentity.md
+++ b/docs/proposals/20250722-openstackclusteridentity.md
@@ -73,7 +73,7 @@ type OpenStackIdentityReference struct {
     // Type specifies the identity reference type
     // +kubebuilder:validation:Enum=Secret;ClusterIdentity
     // +kubebuilder:default=Secret
-    // +kubebuilder:validation:XValidation:rule="self == 'Secret' ? has(self.cloudName) : !has(self.cloudName)",message="cloudName required for Secret type, forbidden for ClusterIdentity type"
+    // +kubebuilder:validation:XValidation:rule="has(self.cloudName)",message="cloudName is required"
     // +kubebuilder:validation:XValidation:rule="has(self.name)",message="name is required"
     // +optional
     Type string `json:"type,omitempty"`
@@ -82,7 +82,7 @@ type OpenStackIdentityReference struct {
     // +optional
     Name string `json:"name,omitempty"`
     
-    // CloudName required for Secret type, forbidden for ClusterIdentity type
+    // CloudName required for both types
     // +optional
     CloudName string `json:"cloudName,omitempty"`
     
@@ -174,7 +174,7 @@ type OpenStackIdentityReference struct {
     // Type specifies the identity reference type
     // +kubebuilder:validation:Enum=Secret;ClusterIdentity
     // +kubebuilder:default=Secret
-    // +kubebuilder:validation:XValidation:rule="self == 'Secret' ? has(self.cloudName) : !has(self.cloudName)",message="cloudName required for Secret type, forbidden for ClusterIdentity type"
+    // +kubebuilder:validation:XValidation:rule="has(self.cloudName)",message="cloudName is required"
     // +kubebuilder:validation:XValidation:rule="has(self.name)",message="name is required"
     // +optional
     Type string `json:"type,omitempty"`
@@ -195,7 +195,7 @@ type OpenStackIdentityReference struct {
 
 **CEL Validation Rules:**
 1. **Name Required**: `name` field is always required for both types
-2. **CloudName Logic**: Required for Secret type, forbidden for ClusterIdentity type
+2. **CloudName Logic**: Required for both Secret and ClusterIdentity types.
 3. **Type Safety**: Enum validation ensures only valid types are accepted
 
 ### Backward Compatibility
@@ -288,6 +288,7 @@ spec:
   identityRef:
     type: ClusterIdentity
     name: prod-openstack
+    cloudName: openstack
 ```
 
 ### Explicit Secret Type (optional)
diff --git a/go.mod b/go.mod
index b7e8b6e5..61719d51 100644
--- a/go.mod
+++ b/go.mod
@@ -12,13 +12,13 @@ require (
 	github.com/gophercloud/utils/v2 v2.0.0-20241220104409-2e0af06694a1
 	github.com/hashicorp/go-version v1.7.0
 	github.com/k-orc/openstack-resource-controller/v2 v2.2.0
-	github.com/onsi/ginkgo/v2 v2.25.3
+	github.com/onsi/ginkgo/v2 v2.26.0
 	github.com/onsi/gomega v1.38.2
 	github.com/prometheus/client_golang v1.23.2
 	github.com/spf13/pflag v1.0.10
 	go.uber.org/mock v0.6.0
-	golang.org/x/crypto v0.42.0
-	golang.org/x/text v0.29.0
+	golang.org/x/crypto v0.43.0
+	golang.org/x/text v0.30.0
 	gopkg.in/ini.v1 v1.67.0
 	k8s.io/api v0.33.5
 	k8s.io/apiextensions-apiserver v0.33.5
@@ -29,7 +29,7 @@ require (
 	k8s.io/klog/v2 v2.130.1
 	k8s.io/kube-openapi v0.0.0-20250610211856-8b98d1ed966a
 	k8s.io/utils v0.0.0-20250604170112-4c0f3b243397
-	sigs.k8s.io/cluster-api v1.11.1
+	sigs.k8s.io/cluster-api v1.11.2
 	sigs.k8s.io/cluster-api/test v1.11.0
 	sigs.k8s.io/controller-runtime v0.21.0
 	sigs.k8s.io/structured-merge-diff/v4 v4.7.0
@@ -129,14 +129,14 @@ require (
 	go.yaml.in/yaml/v2 v2.4.2 // indirect
 	go.yaml.in/yaml/v3 v3.0.4 // indirect
 	golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 // indirect
-	golang.org/x/mod v0.27.0 // indirect
-	golang.org/x/net v0.43.0 // indirect
+	golang.org/x/mod v0.28.0 // indirect
+	golang.org/x/net v0.45.0 // indirect
 	golang.org/x/oauth2 v0.30.0 // indirect
 	golang.org/x/sync v0.17.0 // indirect
-	golang.org/x/sys v0.36.0 // indirect
-	golang.org/x/term v0.35.0 // indirect
+	golang.org/x/sys v0.37.0 // indirect
+	golang.org/x/term v0.36.0 // indirect
 	golang.org/x/time v0.9.0 // indirect
-	golang.org/x/tools v0.36.0 // indirect
+	golang.org/x/tools v0.37.0 // indirect
 	golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated // indirect
 	gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect
 	google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 // indirect
diff --git a/go.sum b/go.sum
index 6e2327e3..6b72efb5 100644
--- a/go.sum
+++ b/go.sum
@@ -46,8 +46,8 @@ github.com/containerd/log v0.1.0 h1:TCJt7ioM2cr/tfR8GPbGf9/VRAX8D2B4PjzCpfX540I=
 github.com/containerd/log v0.1.0/go.mod h1:VRRf09a7mHDIRezVKTRCrOq78v577GXq3bSa3EhrzVo=
 github.com/coredns/caddy v1.1.1 h1:2eYKZT7i6yxIfGP3qLJoJ7HAsDJqYB+X68g4NYjSrE0=
 github.com/coredns/caddy v1.1.1/go.mod h1:A6ntJQlAWuQfFlsd9hvigKbo2WS0VUs2l1e2F+BawD4=
-github.com/coredns/corefile-migration v1.0.27 h1:WIIw5sU0LfGgoGnhdrYdVcto/aWmJoGA/C62iwkU0JM=
-github.com/coredns/corefile-migration v1.0.27/go.mod h1:56DPqONc3njpVPsdilEnfijCwNGC3/kTJLl7i7SPavY=
+github.com/coredns/corefile-migration v1.0.28 h1:O8YafUREqUcGbRtcJfOmWU6ifcw2HX76I1QvI5xZpsw=
+github.com/coredns/corefile-migration v1.0.28/go.mod h1:56DPqONc3njpVPsdilEnfijCwNGC3/kTJLl7i7SPavY=
 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
 github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
 github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -80,6 +80,12 @@ github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/
 github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
 github.com/fxamacker/cbor/v2 v2.7.0 h1:iM5WgngdRBanHcxugY4JySA0nk1wZorNOpTgCMedv5E=
 github.com/fxamacker/cbor/v2 v2.7.0/go.mod h1:pxXPTn3joSm21Gbwsv0w9OSA2y1HFR9qXEeXQVeNoDQ=
+github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs=
+github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo=
+github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M=
+github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk=
+github.com/gkampitakis/go-snaps v0.5.14 h1:3fAqdB6BCPKHDMHAKRwtPUwYexKtGrNuw8HX/T/4neo=
+github.com/gkampitakis/go-snaps v0.5.14/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc=
 github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
 github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
 github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -101,6 +107,8 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L
 github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
 github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4=
 github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs=
+github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
+github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
 github.com/gofrs/uuid/v5 v5.3.0 h1:m0mUMr+oVYUdxpMLgSYCZiXe7PuVPnI94+OMeVBNedk=
 github.com/gofrs/uuid/v5 v5.3.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
@@ -145,6 +153,8 @@ github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2
 github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
 github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
 github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
+github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE=
+github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung=
 github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
 github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
 github.com/k-orc/openstack-resource-controller/v2 v2.2.0 h1:/1gs5b799BfYHPksxLaAY/DdWg7usn4ogFJEm01GU+Y=
@@ -164,6 +174,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
 github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
+github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo=
+github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
 github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
 github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
 github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
@@ -172,6 +184,8 @@ github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D
 github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
 github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU=
 github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
+github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A=
 github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
 github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
 github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
@@ -195,8 +209,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq
 github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
 github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
 github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
-github.com/onsi/ginkgo/v2 v2.25.3 h1:Ty8+Yi/ayDAGtk4XxmmfUy4GabvM+MegeB4cDLRi6nw=
-github.com/onsi/ginkgo/v2 v2.25.3/go.mod h1:43uiyQC4Ed2tkOzLsEYm7hnrb7UJTWHYNsuy3bG/snE=
+github.com/onsi/ginkgo/v2 v2.26.0 h1:1J4Wut1IlYZNEAWIV3ALrT9NfiaGW2cDCJQSFQMs/gE=
+github.com/onsi/ginkgo/v2 v2.26.0/go.mod h1:qhEywmzWTBUY88kfO0BRvX4py7scov9yR+Az2oavUzw=
 github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
 github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
 github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
@@ -263,6 +277,14 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
 github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
+github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
+github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
+github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
+github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
+github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
+github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
 github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
 github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
 github.com/yuin/goldmark v1.1.27/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
@@ -307,21 +329,21 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
-golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
-golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
+golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
+golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
 golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 h1:1UoZQm6f0P/ZO0w1Ri+f+ifG/gXhegadRdwBIXEFWDo=
 golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
-golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
+golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
+golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
 golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
-golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
-golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
+golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
+golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
 golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
 golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -339,23 +361,23 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20211007075335-d3039528d8ac/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
-golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
+golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
-golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
+golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
+golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
-golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
+golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
+golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
 golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
 golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
 golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
 golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
 golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
-golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
+golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
+golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
 golang.org/x/tools/go/expect v0.1.0-deprecated h1:jY2C5HGYR5lqex3gEniOQL0r7Dq5+VGVgY1nudX5lXY=
 golang.org/x/tools/go/expect v0.1.0-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
 golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
@@ -414,8 +436,8 @@ k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8
 k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
 sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM=
 sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw=
-sigs.k8s.io/cluster-api v1.11.1 h1:7CyGCTxv1p3Y2kRe1ljTj/w4TcdIdWNj0CTBc4i1aBo=
-sigs.k8s.io/cluster-api v1.11.1/go.mod h1:zyrjgJ5RbXhwKcAdUlGPNK5YOHpcmxXvur+5I8lkMUQ=
+sigs.k8s.io/cluster-api v1.11.2 h1:uAczaBavU5Y6aDgyoXWtq28k1kalpSZnVItwXHusw1c=
+sigs.k8s.io/cluster-api v1.11.2/go.mod h1:C1gJVAjMXRG+M+djjGYNkoi5kBMhFnOUI9QqZDAtMms=
 sigs.k8s.io/cluster-api/test v1.11.0 h1:dvwMAb5rm4Z7Kj3l9FkeYTWfSthpN0oX3gvUrd8ej24=
 sigs.k8s.io/cluster-api/test v1.11.0/go.mod h1:2f489Lp5TKPGVhNL6V3huq8fp6eb23APlY2cLbhuDBU=
 sigs.k8s.io/controller-runtime v0.21.0 h1:CYfjpEuicjUecRk+KAeyYh+ouUBn4llGyDYytIGcJS8=
diff --git a/hack/tools/Makefile b/hack/tools/Makefile
index 54b9c50b..f027449f 100644
--- a/hack/tools/Makefile
+++ b/hack/tools/Makefile
@@ -15,7 +15,7 @@
 ROOT_DIR_RELATIVE := ../..
 include $(ROOT_DIR_RELATIVE)/common.mk
 
-GOLANGCI_LINT_VERSION ?= v2.4.0
+GOLANGCI_LINT_VERSION ?= v2.5.0
 
 # GOTESTSUM version without the leading 'v'
 GOTESTSUM_VERSION ?= 1.12.0
@@ -71,6 +71,26 @@ CONVERSION_GEN := $(BIN_DIR)/conversion-gen
 $(CONVERSION_GEN): go.mod go.sum | $(BIN_DIR)
 	go build -tags=tools -o $@ k8s.io/code-generator/cmd/conversion-gen
 
+OPENAPI_GEN := $(BIN_DIR)/openapi-gen
+$(OPENAPI_GEN): go.mod go.sum | $(BIN_DIR)
+	go build -tags=tools -o $@ k8s.io/kube-openapi/cmd/openapi-gen
+
+APPLYCONFIGURATION_GEN := $(BIN_DIR)/applyconfiguration-gen
+$(APPLYCONFIGURATION_GEN): go.mod go.sum | $(BIN_DIR)
+	go build -tags=tools -o $@ k8s.io/code-generator/cmd/applyconfiguration-gen
+
+CLIENT_GEN := $(BIN_DIR)/client-gen
+$(CLIENT_GEN): go.mod go.sum | $(BIN_DIR)
+	go build -tags=tools -o $@ k8s.io/code-generator/cmd/client-gen
+
+LISTER_GEN := $(BIN_DIR)/lister-gen
+$(LISTER_GEN): go.mod go.sum | $(BIN_DIR)
+	go build -tags=tools -o $@ k8s.io/code-generator/cmd/lister-gen
+
+INFORMER_GEN := $(BIN_DIR)/informer-gen
+$(INFORMER_GEN): go.mod go.sum | $(BIN_DIR)
+	go build -tags=tools -o $@ k8s.io/code-generator/cmd/informer-gen
+
 ENVSUBST := $(BIN_DIR)/envsubst
 $(ENVSUBST): go.mod go.sum | $(BIN_DIR) # Build envsubst from tools folder.
 	go build -tags=tools -o $@ github.com/a8m/envsubst/cmd/envsubst
diff --git a/hack/tools/go.mod b/hack/tools/go.mod
index b6c319b3..940e1a82 100644
--- a/hack/tools/go.mod
+++ b/hack/tools/go.mod
@@ -6,7 +6,7 @@ require (
 	github.com/a8m/envsubst v1.4.3
 	github.com/ahmetb/gen-crd-api-reference-docs v0.3.1-0.20220420215017-3f29e6853552
 	github.com/itchyny/gojq v0.12.17
-	github.com/onsi/ginkgo/v2 v2.25.3
+	github.com/onsi/ginkgo/v2 v2.26.0
 	go.uber.org/mock v0.6.0
 	k8s.io/code-generator v0.33.5
 	sigs.k8s.io/cluster-api-provider-openstack v0.0.0
@@ -119,17 +119,17 @@ require (
 	go.uber.org/zap v1.27.0 // indirect
 	go.yaml.in/yaml/v2 v2.4.2 // indirect
 	go.yaml.in/yaml/v3 v3.0.4 // indirect
-	golang.org/x/crypto v0.42.0 // indirect
+	golang.org/x/crypto v0.43.0 // indirect
 	golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 // indirect
-	golang.org/x/mod v0.27.0 // indirect
-	golang.org/x/net v0.43.0 // indirect
+	golang.org/x/mod v0.28.0 // indirect
+	golang.org/x/net v0.45.0 // indirect
 	golang.org/x/oauth2 v0.30.0 // indirect
 	golang.org/x/sync v0.17.0 // indirect
-	golang.org/x/sys v0.36.0 // indirect
-	golang.org/x/term v0.35.0 // indirect
-	golang.org/x/text v0.29.0 // indirect
+	golang.org/x/sys v0.37.0 // indirect
+	golang.org/x/term v0.36.0 // indirect
+	golang.org/x/text v0.30.0 // indirect
 	golang.org/x/time v0.12.0 // indirect
-	golang.org/x/tools v0.36.0 // indirect
+	golang.org/x/tools v0.37.0 // indirect
 	golang.org/x/tools/go/expect v0.1.0-deprecated // indirect
 	gomodules.xyz/jsonpatch/v2 v2.5.0 // indirect
 	google.golang.org/genproto/googleapis/api v0.0.0-20250721164621-a45f3dfb1074 // indirect
@@ -156,7 +156,7 @@ require (
 	k8s.io/release v0.16.9 // indirect
 	k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 // indirect
 	sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 // indirect
-	sigs.k8s.io/cluster-api v1.11.1 // indirect
+	sigs.k8s.io/cluster-api v1.11.2 // indirect
 	sigs.k8s.io/controller-runtime v0.21.0 // indirect
 	sigs.k8s.io/json v0.0.0-20241014173422-cfa47c3a1cc8 // indirect
 	sigs.k8s.io/kubebuilder/docs/book/utils v0.0.0-20211028165026-57688c578b5d // indirect
diff --git a/hack/tools/go.sum b/hack/tools/go.sum
index 920cb9a0..8eb02d51 100644
--- a/hack/tools/go.sum
+++ b/hack/tools/go.sum
@@ -49,8 +49,8 @@ github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151X
 github.com/containerd/errdefs/pkg v0.3.0/go.mod h1:NJw6s9HwNuRhnjJhM7pylWwMyAkmCQvQ4GpJHEqRLVk=
 github.com/coredns/caddy v1.1.1 h1:2eYKZT7i6yxIfGP3qLJoJ7HAsDJqYB+X68g4NYjSrE0=
 github.com/coredns/caddy v1.1.1/go.mod h1:A6ntJQlAWuQfFlsd9hvigKbo2WS0VUs2l1e2F+BawD4=
-github.com/coredns/corefile-migration v1.0.27 h1:WIIw5sU0LfGgoGnhdrYdVcto/aWmJoGA/C62iwkU0JM=
-github.com/coredns/corefile-migration v1.0.27/go.mod h1:56DPqONc3njpVPsdilEnfijCwNGC3/kTJLl7i7SPavY=
+github.com/coredns/corefile-migration v1.0.28 h1:O8YafUREqUcGbRtcJfOmWU6ifcw2HX76I1QvI5xZpsw=
+github.com/coredns/corefile-migration v1.0.28/go.mod h1:56DPqONc3njpVPsdilEnfijCwNGC3/kTJLl7i7SPavY=
 github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
 github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E=
 github.com/cyphar/filepath-securejoin v0.2.5 h1:6iR5tXJ/e6tJZzzdMc1km3Sa7RRIVBKAK32O2s7AYfo=
@@ -87,6 +87,12 @@ github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/
 github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
 github.com/fxamacker/cbor/v2 v2.9.0 h1:NpKPmjDBgUfBms6tr6JZkTHtfFGcMKsw3eGcmD/sapM=
 github.com/fxamacker/cbor/v2 v2.9.0/go.mod h1:vM4b+DJCtHn+zz7h3FFp/hDAI9WNWCsZj23V5ytsSxQ=
+github.com/gkampitakis/ciinfo v0.3.2 h1:JcuOPk8ZU7nZQjdUhctuhQofk7BGHuIy0c9Ez8BNhXs=
+github.com/gkampitakis/ciinfo v0.3.2/go.mod h1:1NIwaOcFChN4fa/B0hEBdAb6npDlFL8Bwx4dfRLRqAo=
+github.com/gkampitakis/go-diff v1.3.2 h1:Qyn0J9XJSDTgnsgHRdz9Zp24RaJeKMUHg2+PDZZdC4M=
+github.com/gkampitakis/go-diff v1.3.2/go.mod h1:LLgOrpqleQe26cte8s36HTWcTmMEur6OPYerdAAS9tk=
+github.com/gkampitakis/go-snaps v0.5.14 h1:3fAqdB6BCPKHDMHAKRwtPUwYexKtGrNuw8HX/T/4neo=
+github.com/gkampitakis/go-snaps v0.5.14/go.mod h1:HNpx/9GoKisdhw9AFOBT1N7DBs9DiHo/hGheFGBZ+mc=
 github.com/gliderlabs/ssh v0.3.8 h1:a4YXD1V7xMF9g5nTkdfnja3Sxy1PVDCj1Zg4Wb8vY6c=
 github.com/gliderlabs/ssh v0.3.8/go.mod h1:xYoytBv1sV0aL3CavoDuJIQNURXkkfPA/wxQ1pL1fAU=
 github.com/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
@@ -121,6 +127,8 @@ github.com/go-viper/mapstructure/v2 v2.4.0 h1:EBsztssimR/CONLSZZ04E8qAkxNYq4Qp9L
 github.com/go-viper/mapstructure/v2 v2.4.0/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
 github.com/gobuffalo/flect v1.0.3 h1:xeWBM2nui+qnVvNM4S3foBhCAL2XgPU+a7FdpelbTq4=
 github.com/gobuffalo/flect v1.0.3/go.mod h1:A5msMlrHtLqh9umBSnvabjsMrCcCpAyzglnDvkbYKHs=
+github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw=
+github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
 github.com/gofrs/uuid/v5 v5.3.0 h1:m0mUMr+oVYUdxpMLgSYCZiXe7PuVPnI94+OMeVBNedk=
 github.com/gofrs/uuid/v5 v5.3.0/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8=
 github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q=
@@ -175,6 +183,8 @@ github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOl
 github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
 github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY=
 github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
+github.com/joshdk/go-junit v1.0.0 h1:S86cUKIdwBHWwA6xCmFlf3RTLfVXYQfvanM5Uh+K6GE=
+github.com/joshdk/go-junit v1.0.0/go.mod h1:TiiV0PqkaNfFXjEiyjWM3XXrhVyCa1K4Zfga6W52ung=
 github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
 github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
 github.com/k-orc/openstack-resource-controller/v2 v2.2.0 h1:/1gs5b799BfYHPksxLaAY/DdWg7usn4ogFJEm01GU+Y=
@@ -198,6 +208,8 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0
 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
 github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0=
 github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc=
+github.com/maruel/natural v1.1.1 h1:Hja7XhhmvEFhcByqDoHz9QZbkWey+COd9xWfCfn1ioo=
+github.com/maruel/natural v1.1.1/go.mod h1:v+Rfd79xlw1AgVBjbO0BEQmptqb5HvL/k9GRHB7ZKEg=
 github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
 github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
 github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
@@ -205,6 +217,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE
 github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
 github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U=
 github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
+github.com/mfridman/tparse v0.18.0 h1:wh6dzOKaIwkUGyKgOntDW4liXSo37qg5AXbIhkMV3vE=
+github.com/mfridman/tparse v0.18.0/go.mod h1:gEvqZTuCgEhPbYk/2lS3Kcxg1GmTxxU7kTC8DvP0i/A=
 github.com/mitchellh/copystructure v1.2.0 h1:vpKXTN4ewci03Vljg/q9QvCGUDttBOGBIa15WveJJGw=
 github.com/mitchellh/copystructure v1.2.0/go.mod h1:qLl+cE2AmVv+CoeAwDPye/v+N2HKCj9FbZEVFJRxO9s=
 github.com/mitchellh/reflectwalk v1.0.2 h1:G2LzWKi524PWgd3mLHV8Y5k7s6XUvT0Gef6zxSIeXaQ=
@@ -229,8 +243,8 @@ github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N
 github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
 github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
 github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
-github.com/onsi/ginkgo/v2 v2.25.3 h1:Ty8+Yi/ayDAGtk4XxmmfUy4GabvM+MegeB4cDLRi6nw=
-github.com/onsi/ginkgo/v2 v2.25.3/go.mod h1:43uiyQC4Ed2tkOzLsEYm7hnrb7UJTWHYNsuy3bG/snE=
+github.com/onsi/ginkgo/v2 v2.26.0 h1:1J4Wut1IlYZNEAWIV3ALrT9NfiaGW2cDCJQSFQMs/gE=
+github.com/onsi/ginkgo/v2 v2.26.0/go.mod h1:qhEywmzWTBUY88kfO0BRvX4py7scov9yR+Az2oavUzw=
 github.com/onsi/gomega v1.38.2 h1:eZCjf2xjZAqe+LeWvKb5weQ+NcPwX84kqJ0cZNxok2A=
 github.com/onsi/gomega v1.38.2/go.mod h1:W2MJcYxRGV63b418Ai34Ud0hEdTVXq9NW9+Sx6uXf3k=
 github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
@@ -312,6 +326,14 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
 github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
 github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
 github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
+github.com/tidwall/gjson v1.18.0 h1:FIDeeyB800efLX89e5a8Y0BNH+LOngJyGrIWxG2FKQY=
+github.com/tidwall/gjson v1.18.0/go.mod h1:/wbyibRr2FHMks5tjHJ5F8dMZh3AcwJEMf5vlfC0lxk=
+github.com/tidwall/match v1.1.1 h1:+Ho715JplO36QYgwN9PGYNhgZvoUSc9X2c80KVTi+GA=
+github.com/tidwall/match v1.1.1/go.mod h1:eRSPERbgtNPcGhD8UCthc6PmLEQXEWd3PRB5JTxsfmM=
+github.com/tidwall/pretty v1.2.1 h1:qjsOFOWWQl+N3RsoF5/ssm1pHmJJwhjlSbZ51I6wMl4=
+github.com/tidwall/pretty v1.2.1/go.mod h1:ITEVvHYasfjBbM0u2Pg8T2nJnzm8xPwvNhhsoaGGjNU=
+github.com/tidwall/sjson v1.2.5 h1:kLy8mja+1c9jlljvWTlSazM7cKDRfJuR/bOJhcY5NcY=
+github.com/tidwall/sjson v1.2.5/go.mod h1:Fvgq9kS/6ociJEDnK0Fk1cpYF4FIW6ZF7LAe+6jwd28=
 github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM=
 github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg=
 github.com/xanzy/ssh-agent v0.3.3 h1:+/15pJfg/RsTxqYcX6fHqOXZwwMP+2VyYWJeWM2qQFM=
@@ -358,21 +380,21 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk
 golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
 golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
 golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
-golang.org/x/crypto v0.42.0 h1:chiH31gIWm57EkTXpwnqf8qeuMUi0yekh6mT2AvFlqI=
-golang.org/x/crypto v0.42.0/go.mod h1:4+rDnOTJhQCx2q7/j6rAN5XDw8kPjeaXEUR2eL94ix8=
+golang.org/x/crypto v0.43.0 h1:dduJYIi3A3KOfdGOHX8AVZ/jGiyPa3IbBozJ5kNuE04=
+golang.org/x/crypto v0.43.0/go.mod h1:BFbav4mRNlXJL4wNeejLpWxB7wMbc79PdRGhWKncxR0=
 golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67 h1:1UoZQm6f0P/ZO0w1Ri+f+ifG/gXhegadRdwBIXEFWDo=
 golang.org/x/exp v0.0.0-20241217172543-b2144cdd0a67/go.mod h1:qj5a5QZpwLU2NLQudwIN5koi3beDhSAlJwa67PuM98c=
 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
-golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
-golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
+golang.org/x/mod v0.28.0 h1:gQBtGhjxykdjY9YhZpSlZIsbnaE2+PgjfLWUQTnoZ1U=
+golang.org/x/mod v0.28.0/go.mod h1:yfB/L0NOf/kmEbXjzCPOx1iK1fRutOydrCMsqRhEBxI=
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20200226121028-0de0cce0169b/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
 golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
 golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
-golang.org/x/net v0.43.0 h1:lat02VYK2j4aLzMzecihNvTlJNQUq316m2Mr9rnM6YE=
-golang.org/x/net v0.43.0/go.mod h1:vhO1fvI4dGsIjh73sWfUVjj3N7CA9WkKJNQm2svM6Jg=
+golang.org/x/net v0.45.0 h1:RLBg5JKixCy82FtLJpeNlVM0nrSqpCRYzVU1n8kj0tM=
+golang.org/x/net v0.45.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
 golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
 golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
 golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -391,16 +413,16 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
 golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
 golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
-golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
+golang.org/x/sys v0.37.0 h1:fdNQudmxPjkdUTPnLn5mdQv7Zwvbvpaxqs831goi9kQ=
+golang.org/x/sys v0.37.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
 golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
-golang.org/x/term v0.35.0 h1:bZBVKBudEyhRcajGcNc3jIfWPqV4y/Kt2XcoigOWtDQ=
-golang.org/x/term v0.35.0/go.mod h1:TPGtkTLesOwf2DE8CgVYiZinHAOuy5AYUYT1lENIZnA=
+golang.org/x/term v0.36.0 h1:zMPR+aF8gfksFprF/Nc/rd1wRS1EI6nDBGyWAvDzx2Q=
+golang.org/x/term v0.36.0/go.mod h1:Qu394IJq6V6dCBRgwqshf3mPF85AqzYEzofzRdZkWss=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
 golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
 golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
-golang.org/x/text v0.29.0 h1:1neNs90w9YzJ9BocxfsQNHKuAT4pkghyXc4nhZ6sJvk=
-golang.org/x/text v0.29.0/go.mod h1:7MhJOA9CD2qZyOKYazxdYMF85OwPdEr9jTtBpO7ydH4=
+golang.org/x/text v0.30.0 h1:yznKA/E9zq54KzlzBEAWn1NXSQ8DIp/NYMy88xJjl4k=
+golang.org/x/text v0.30.0/go.mod h1:yDdHFIX9t+tORqspjENWgzaCVXgk0yYnYuSZ8UzzBVM=
 golang.org/x/time v0.12.0 h1:ScB/8o8olJvc+CQPWrK3fPZNfh7qgwCrY0zJmoEQLSE=
 golang.org/x/time v0.12.0/go.mod h1:CDIdPxbZBQxdj6cxyCIdrNogrJKMJ7pr37NYpMcMDSg=
 golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
@@ -408,8 +430,8 @@ golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtn
 golang.org/x/tools v0.0.0-20200505023115-26f46d2f7ef8/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
 golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE=
 golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
-golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
-golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
+golang.org/x/tools v0.37.0 h1:DVSRzp7FwePZW356yEAChSdNcQo6Nsp+fex1SUW09lE=
+golang.org/x/tools v0.37.0/go.mod h1:MBN5QPQtLMHVdvsbtarmTNukZDdgwdwlO5qGacAzF0w=
 golang.org/x/tools/go/expect v0.1.0-deprecated h1:jY2C5HGYR5lqex3gEniOQL0r7Dq5+VGVgY1nudX5lXY=
 golang.org/x/tools/go/expect v0.1.0-deprecated/go.mod h1:eihoPOH+FgIqa3FpoTwguz/bVUSGBlGQU67vpBeOrBY=
 golang.org/x/tools/go/packages/packagestest v0.1.1-deprecated h1:1h2MnaIAIXISqTFKdENegdpAgUXz6NrPEsbIeWaBRvM=
@@ -480,8 +502,8 @@ k8s.io/utils v0.0.0-20250604170112-4c0f3b243397 h1:hwvWFiBzdWw1FhfY1FooPn3kzWuJ8
 k8s.io/utils v0.0.0-20250604170112-4c0f3b243397/go.mod h1:OLgZIPagt7ERELqWJFomSt595RzquPNLL48iOWgYOg0=
 sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2 h1:jpcvIRr3GLoUoEKRkHKSmGjxb6lWwrBlJsXc+eUYQHM=
 sigs.k8s.io/apiserver-network-proxy/konnectivity-client v0.31.2/go.mod h1:Ve9uj1L+deCXFrPOk1LpFXqTg7LCFzFso6PA48q/XZw=
-sigs.k8s.io/cluster-api v1.11.1 h1:7CyGCTxv1p3Y2kRe1ljTj/w4TcdIdWNj0CTBc4i1aBo=
-sigs.k8s.io/cluster-api v1.11.1/go.mod h1:zyrjgJ5RbXhwKcAdUlGPNK5YOHpcmxXvur+5I8lkMUQ=
+sigs.k8s.io/cluster-api v1.11.2 h1:uAczaBavU5Y6aDgyoXWtq28k1kalpSZnVItwXHusw1c=
+sigs.k8s.io/cluster-api v1.11.2/go.mod h1:C1gJVAjMXRG+M+djjGYNkoi5kBMhFnOUI9QqZDAtMms=
 sigs.k8s.io/cluster-api/hack/tools v0.0.0-20250805173327-a7b9f27af519 h1:WkOO6Fg3tmbuFXqTMFfs80mapaQyBbdIC5p86LIVlBI=
 sigs.k8s.io/cluster-api/hack/tools v0.0.0-20250805173327-a7b9f27af519/go.mod h1:/Zjkh19AmjuI4piKJ1fAZW7k0cDZsSoAAcGHKGZJTZU=
 sigs.k8s.io/cluster-api/test v1.11.0 h1:dvwMAb5rm4Z7Kj3l9FkeYTWfSthpN0oX3gvUrd8ej24=
diff --git a/hack/update-codegen.sh b/hack/update-codegen.sh
deleted file mode 100755
index f7161d81..00000000
--- a/hack/update-codegen.sh
+++ /dev/null
@@ -1,77 +0,0 @@
-#!/usr/bin/env bash
-
-# Copyright 2024 The Kubernetes Authors.
-#
-# Licensed under the Apache License, Version 2.0 (the "License");
-# you may not use this file except in compliance with the License.
-# You may obtain a copy of the License at
-#
-# 	http://www.apache.org/licenses/LICENSE-2.0
-#
-# Unless required by applicable law or agreed to in writing, software
-# distributed under the License is distributed on an "AS IS" BASIS,
-# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
-# See the License for the specific language governing permissions and
-# limitations under the License.
-
-set -euo pipefail
-set -x
-
-SCRIPT_ROOT=$(realpath $(dirname "${BASH_SOURCE[0]}"))
-PROJECT_ROOT=$(realpath "${SCRIPT_ROOT}/..")
-
-GENERATED_PKG="pkg/generated"
-
-# Ensure tools built by kube_codegen go in our own GOBIN rather than something
-# shared under GOPATH. This guards against these tools being rebuilt by some
-# other concurrent invocation, potentially with a different version.
-export GOBIN="${PROJECT_ROOT}/bin"
-
-cd "$PROJECT_ROOT"
-
-# For this to work, the current working directory must be under a Go module which
-# lists k8s.io/code-generator
-CODEGEN_PKG=$(go list -f '{{ .Dir }}' k8s.io/code-generator)
-
-source "${CODEGEN_PKG}/kube_codegen.sh"
-
-# Deep-copies and what-not are generated by controller-gen, so we don't need to use kube::codegen::gen_helpers
-
-declare -a gen_openapi_args=(
-    --report-filename "${PROJECT_ROOT}/api_violations.report"
-    --output-dir "${PROJECT_ROOT}/cmd/models-schema"
-    --output-pkg main
-    --boilerplate "${SCRIPT_ROOT}/boilerplate.go.txt"
-
-    # We need to include all referenced types in our generated openapi schema
-    # or applyconfiguration-gen won't be able to use it. Helpfully it will
-    # generate an error including the missing type.
-    --extra-pkgs sigs.k8s.io/cluster-api/api/core/v1beta2
-    --extra-pkgs sigs.k8s.io/cluster-api/api/ipam/v1beta2
-    --extra-pkgs sigs.k8s.io/cluster-api/api/core/v1beta1
-    --extra-pkgs sigs.k8s.io/cluster-api/api/ipam/v1beta1
-    --extra-pkgs k8s.io/api/core/v1
-)
-
-# It is an error to make a change which updates the api violations. When doing
-# this intentionally, for example when fixing violations, run with
-# UPDATE_API_KNOWN_VIOLATIONS=true to update the api violations report.
-if [ ! -z "${UPDATE_API_KNOWN_VIOLATIONS:-}" ]; then
-    gen_openapi_args+=(--update-report)
-fi
-
-kube::codegen::gen_openapi "${gen_openapi_args[@]}" "${PROJECT_ROOT}/api"
-
-openapi="${PROJECT_ROOT}/openapi.json"
-go run "${PROJECT_ROOT}/cmd/models-schema" | jq > "$openapi"
-
-kube::codegen::gen_client \
-    --with-applyconfig \
-    --with-watch \
-    --applyconfig-openapi-schema "$openapi" \
-    --output-dir "${PROJECT_ROOT}/${GENERATED_PKG}" \
-    --output-pkg sigs.k8s.io/cluster-api-provider-openstack/${GENERATED_PKG} \
-    --versioned-name clientset \
-    --boilerplate "${SCRIPT_ROOT}/boilerplate.go.txt" \
-    --one-input-api "api" \
-    "${PROJECT_ROOT}"
diff --git a/pkg/cloud/services/compute/instance.go b/pkg/cloud/services/compute/instance.go
index cf626023..255faf6d 100644
--- a/pkg/cloud/services/compute/instance.go
+++ b/pkg/cloud/services/compute/instance.go
@@ -241,14 +241,14 @@ func (s *Service) getOrCreateVolumeBuilder(eventObject runtime.Object, instanceS
 
 func resolveVolumeOpts(instanceSpec *InstanceSpec, volumeOpts *infrav1.BlockDeviceVolume) (az, volType string) {
 	if volumeOpts == nil {
-		return
+		return az, volType
 	}
 
 	volType = volumeOpts.Type
 
 	volumeAZ := volumeOpts.AvailabilityZone
 	if volumeAZ == nil {
-		return
+		return az, volType
 	}
 
 	switch volumeAZ.From {
@@ -260,7 +260,7 @@ func resolveVolumeOpts(instanceSpec *InstanceSpec, volumeOpts *infrav1.BlockDevi
 	case infrav1.VolumeAZFromMachine:
 		az = instanceSpec.FailureDomain
 	}
-	return
+	return az, volType
 }
 
 // getBlockDevices returns a list of block devices that were created and attached to the instance. It returns an error
diff --git a/pkg/generated/applyconfiguration/api/v1alpha1/openstackclusteridentity.go b/pkg/generated/applyconfiguration/api/v1alpha1/openstackclusteridentity.go
new file mode 100644
index 00000000..4a75dae4
--- /dev/null
+++ b/pkg/generated/applyconfiguration/api/v1alpha1/openstackclusteridentity.go
@@ -0,0 +1,255 @@
+/*
+Copyright 2024 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+	http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Code generated by applyconfiguration-gen. DO NOT EDIT.
+
+package v1alpha1
+
+import (
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	types "k8s.io/apimachinery/pkg/types"
+	managedfields "k8s.io/apimachinery/pkg/util/managedfields"
+	v1 "k8s.io/client-go/applyconfigurations/meta/v1"
+	apiv1alpha1 "sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1"
+	internal "sigs.k8s.io/cluster-api-provider-openstack/pkg/generated/applyconfiguration/internal"
+)
+
+// OpenStackClusterIdentityApplyConfiguration represents a declarative configuration of the OpenStackClusterIdentity type for use
+// with apply.
+type OpenStackClusterIdentityApplyConfiguration struct {
+	v1.TypeMetaApplyConfiguration    `json:",inline"`
+	*v1.ObjectMetaApplyConfiguration `json:"metadata,omitempty"`
+	Spec                             *OpenStackClusterIdentitySpecApplyConfiguration `json:"spec,omitempty"`
+}
+
+// OpenStackClusterIdentity constructs a declarative configuration of the OpenStackClusterIdentity type for use with
+// apply.
+func OpenStackClusterIdentity(name, namespace string) *OpenStackClusterIdentityApplyConfiguration {
+	b := &OpenStackClusterIdentityApplyConfiguration{}
+	b.WithName(name)
+	b.WithNamespace(namespace)
+	b.WithKind("OpenStackClusterIdentity")
+	b.WithAPIVersion("infrastructure.cluster.x-k8s.io/v1alpha1")
+	return b
+}
+
+// ExtractOpenStackClusterIdentity extracts the applied configuration owned by fieldManager from
+// openStackClusterIdentity. If no managedFields are found in openStackClusterIdentity for fieldManager, a
+// OpenStackClusterIdentityApplyConfiguration is returned with only the Name, Namespace (if applicable),
+// APIVersion and Kind populated. It is possible that no managed fields were found for because other
+// field managers have taken ownership of all the fields previously owned by fieldManager, or because
+// the fieldManager never owned fields any fields.
+// openStackClusterIdentity must be a unmodified OpenStackClusterIdentity API object that was retrieved from the Kubernetes API.
+// ExtractOpenStackClusterIdentity provides a way to perform a extract/modify-in-place/apply workflow.
+// Note that an extracted apply configuration will contain fewer fields than what the fieldManager previously
+// applied if another fieldManager has updated or force applied any of the previously applied fields.
+// Experimental!
+func ExtractOpenStackClusterIdentity(openStackClusterIdentity *apiv1alpha1.OpenStackClusterIdentity, fieldManager string) (*OpenStackClusterIdentityApplyConfiguration, error) {
+	return extractOpenStackClusterIdentity(openStackClusterIdentity, fieldManager, "")
+}
+
+// ExtractOpenStackClusterIdentityStatus is the same as ExtractOpenStackClusterIdentity except
+// that it extracts the status subresource applied configuration.
+// Experimental!
+func ExtractOpenStackClusterIdentityStatus(openStackClusterIdentity *apiv1alpha1.OpenStackClusterIdentity, fieldManager string) (*OpenStackClusterIdentityApplyConfiguration, error) {
+	return extractOpenStackClusterIdentity(openStackClusterIdentity, fieldManager, "status")
+}
+
+func extractOpenStackClusterIdentity(openStackClusterIdentity *apiv1alpha1.OpenStackClusterIdentity, fieldManager string, subresource string) (*OpenStackClusterIdentityApplyConfiguration, error) {
+	b := &OpenStackClusterIdentityApplyConfiguration{}
+	err := managedfields.ExtractInto(openStackClusterIdentity, internal.Parser().Type("io.k8s.sigs.cluster-api-provider-openstack.api.v1alpha1.OpenStackClusterIdentity"), fieldManager, b, subresource)
+	if err != nil {
+		return nil, err
+	}
+	b.WithName(openStackClusterIdentity.Name)
+	b.WithNamespace(openStackClusterIdentity.Namespace)
+
+	b.WithKind("OpenStackClusterIdentity")
+	b.WithAPIVersion("infrastructure.cluster.x-k8s.io/v1alpha1")
+	return b, nil
+}
+
+// WithKind sets the Kind field in the declarative configuration to the given value
+// and returns the receiver, so that objects can be built by chaining "With" function invocations.
+// If called multiple times, the Kind field is set to the value of the last call.
+func (b *OpenStackClusterIdentityApplyConfiguration) WithKind(value string) *OpenStackClusterIdentityApplyConfiguration {
+	b.TypeMetaApplyConfiguration.Kind = &value
+	return b
+}
+
+// WithAPIVersion sets the APIVersion field in the declarative configuration to the given value
+// and returns the receiver, so that objects can be built by chaining "With" function invocations.
+// If called multiple times, the APIVersion field is set to the value of the last call.
+func (b *OpenStackClusterIdentityApplyConfiguration) WithAPIVersion(value string) *OpenStackClusterIdentityApplyConfiguration {
+	b.TypeMetaApplyConfiguration.APIVersion = &value
+	return b
+}
+
+// WithName sets the Name field in the declarative configuration to the given value
+// and returns the receiver, so that objects can be built by chaining "With" function invocations.
+// If called multiple times, the Name field is set to the value of the last call.
+func (b *OpenStackClusterIdentityApplyConfiguration) WithName(value string) *OpenStackClusterIdentityApplyConfiguration {
+	b.ensureObjectMetaApplyConfigurationExists()
+	b.ObjectMetaApplyConfiguration.Name = &value
+	return b
+}
+
+// WithGenerateName sets the GenerateName field in the declarative configuration to the given value
+// and returns the receiver, so that objects can be built by chaining "With" function invocations.
+// If called multiple times, the GenerateName field is set to the value of the last call.
+func (b *OpenStackClusterIdentityApplyConfiguration) WithGenerateName(value string) *OpenStackClusterIdentityApplyConfiguration {
+	b.ensureObjectMetaApplyConfigurationExists()
+	b.ObjectMetaApplyConfiguration.GenerateName = &value
+	return b
+}
+
+// WithNamespace sets the Namespace field in the declarative configuration to the given value
+// and returns the receiver, so that objects can be built by chaining "With" function invocations.
+// If called multiple times, the Namespace field is set to the value of the last call.
+func (b *OpenStackClusterIdentityApplyConfiguration) WithNamespace(value string) *OpenStackClusterIdentityApplyConfiguration {
+	b.ensureObjectMetaApplyConfigurationExists()
+	b.ObjectMetaApplyConfiguration.Namespace = &value
+	return b
+}
+
+// WithUID sets the UID field in the declarative configuration to the given value
+// and returns the receiver, so that objects can be built by chaining "With" function invocations.
+// If called multiple times, the UID field is set to the value of the last call.
+func (b *OpenStackClusterIdentityApplyConfiguration) WithUID(value types.UID) *OpenStackClusterIdentityApplyConfiguration {
+	b.ensureObjectMetaApplyConfigurationExists()
+	b.ObjectMetaApplyConfiguration.UID = &value
+	return b
+}
+
+// WithResourceVersion sets the ResourceVersion field in the declarative configuration to the given value
+// and returns the receiver, so that objects can be built by chaining "With" function invocations.
+// If called multiple times, the ResourceVersion field is set to the value of the last call.
+func (b *OpenStackClusterIdentityApplyConfiguration) WithResourceVersion(value string) *OpenStackClusterIdentityApplyConfiguration {
+	b.ensureObjectMetaApplyConfigurationExists()
+	b.ObjectMetaApplyConfiguration.ResourceVersion = &value
+	return b
+}
+
+// WithGeneration sets the Generation field in the declarative configuration to the given value
+// and returns the receiver, so that objects can be built by chaining "With" function invocations.
+// If called multiple times, the Generation field is set to the value of the last call.
+func (b *OpenStackClusterIdentityApplyConfiguration) WithGeneration(value int64) *OpenStackClusterIdentityApplyConfiguration {
+	b.ensureObjectMetaApplyConfigurationExists()
+	b.ObjectMetaApplyConfiguration.Generation = &value
+	return b
+}
+
+// WithCreationTimestamp sets the CreationTimestamp field in the declarative configuration to the given value
+// and returns the receiver, so that objects can be built by chaining "With" function invocations.
+// If called multiple times, the CreationTimestamp field is set to the value of the last call.
+func (b *OpenStackClusterIdentityApplyConfiguration) WithCreationTimestamp(value metav1.Time) *OpenStackClusterIdentityApplyConfiguration {
+	b.ensureObjectMetaApplyConfigurationExists()
+	b.ObjectMetaApplyConfiguration.CreationTimestamp = &value
+	return b
+}
+
+// WithDeletionTimestamp sets the DeletionTimestamp field in the declarative configuration to the given value
+// and returns the receiver, so that objects can be built by chaining "With" function invocations.
+// If called multiple times, the DeletionTimestamp field is set to the value of the last call.
+func (b *OpenStackClusterIdentityApplyConfiguration) WithDeletionTimestamp(value metav1.Time) *OpenStackClusterIdentityApplyConfiguration {
+	b.ensureObjectMetaApplyConfigurationExists()
+	b.ObjectMetaApplyConfiguration.DeletionTimestamp = &value
+	return b
+}
+
+// WithDeletionGracePeriodSeconds sets the DeletionGracePeriodSeconds field in the declarative configuration to the given value
+// and returns the receiver, so that objects can be built by chaining "With" function invocations.
+// If called multiple times, the DeletionGracePeriodSeconds field is set to the value of the last call.
+func (b *OpenStackClusterIdentityApplyConfiguration) WithDeletionGracePeriodSeconds(value int64) *OpenStackClusterIdentityApplyConfiguration {
+	b.ensureObjectMetaApplyConfigurationExists()
+	b.ObjectMetaApplyConfiguration.DeletionGracePeriodSeconds = &value
+	return b
+}
+
+// WithLabels puts the entries into the Labels field in the declarative configuration
+// and returns the receiver, so that objects can be build by chaining "With" function invocations.
+// If called multiple times, the entries provided by each call will be put on the Labels field,
+// overwriting an existing map entries in Labels field with the same key.
+func (b *OpenStackClusterIdentityApplyConfiguration) WithLabels(entries map[string]string) *OpenStackClusterIdentityApplyConfiguration {
+	b.ensureObjectMetaApplyConfigurationExists()
+	if b.ObjectMetaApplyConfiguration.Labels == nil && len(entries) > 0 {
+		b.ObjectMetaApplyConfiguration.Labels = make(map[string]string, len(entries))
+	}
+	for k, v := range entries {
+		b.ObjectMetaApplyConfiguration.Labels[k] = v
+	}
+	return b
+}
+
+// WithAnnotations puts the entries into the Annotations field in the declarative configuration
+// and returns the receiver, so that objects can be build by chaining "With" function invocations.
+// If called multiple times, the entries provided by each call will be put on the Annotations field,
+// overwriting an existing map entries in Annotations field with the same key.
+func (b *OpenStackClusterIdentityApplyConfiguration) WithAnnotations(entries map[string]string) *OpenStackClusterIdentityApplyConfiguration {
+	b.ensureObjectMetaApplyConfigurationExists()
+	if b.ObjectMetaApplyConfiguration.Annotations == nil && len(entries) > 0 {
+		b.ObjectMetaApplyConfiguration.Annotations = make(map[string]string, len(entries))
+	}
+	for k, v := range entries {
+		b.ObjectMetaApplyConfiguration.Annotations[k] = v
+	}
+	return b
+}
+
+// WithOwnerReferences adds the given value to the OwnerReferences field in the declarative configuration
+// and returns the receiver, so that objects can be build by chaining "With" function invocations.
+// If called multiple times, values provided by each call will be appended to the OwnerReferences field.
+func (b *OpenStackClusterIdentityApplyConfiguration) WithOwnerReferences(values ...*v1.OwnerReferenceApplyConfiguration) *OpenStackClusterIdentityApplyConfiguration {
+	b.ensureObjectMetaApplyConfigurationExists()
+	for i := range values {
+		if values[i] == nil {
+			panic("nil value passed to WithOwnerReferences")
+		}
+		b.ObjectMetaApplyConfiguration.OwnerReferences = append(b.ObjectMetaApplyConfiguration.OwnerReferences, *values[i])
+	}
+	return b
+}
+
+// WithFinalizers adds the given value to the Finalizers field in the declarative configuration
+// and returns the receiver, so that objects can be build by chaining "With" function invocations.
+// If called multiple times, values provided by each call will be appended to the Finalizers field.
+func (b *OpenStackClusterIdentityApplyConfiguration) WithFinalizers(values ...string) *OpenStackClusterIdentityApplyConfiguration {
+	b.ensureObjectMetaApplyConfigurationExists()
+	for i := range values {
+		b.ObjectMetaApplyConfiguration.Finalizers = append(b.ObjectMetaApplyConfiguration.Finalizers, values[i])
+	}
+	return b
+}
+
+func (b *OpenStackClusterIdentityApplyConfiguration) ensureObjectMetaApplyConfigurationExists() {
+	if b.ObjectMetaApplyConfiguration == nil {
+		b.ObjectMetaApplyConfiguration = &v1.ObjectMetaApplyConfiguration{}
+	}
+}
+
+// WithSpec sets the Spec field in the declarative configuration to the given value
+// and returns the receiver, so that objects can be built by chaining "With" function invocations.
+// If called multiple times, the Spec field is set to the value of the last call.
+func (b *OpenStackClusterIdentityApplyConfiguration) WithSpec(value *OpenStackClusterIdentitySpecApplyConfiguration) *OpenStackClusterIdentityApplyConfiguration {
+	b.Spec = value
+	return b
+}
+
+// GetName retrieves the value of the Name field in the declarative configuration.
+func (b *OpenStackClusterIdentityApplyConfiguration) GetName() *string {
+	b.ensureObjectMetaApplyConfigurationExists()
+	return b.ObjectMetaApplyConfiguration.Name
+}
diff --git a/pkg/generated/applyconfiguration/api/v1alpha1/openstackclusteridentityspec.go b/pkg/generated/applyconfiguration/api/v1alpha1/openstackclusteridentityspec.go
new file mode 100644
index 00000000..e8d4ce41
--- /dev/null
+++ b/pkg/generated/applyconfiguration/api/v1alpha1/openstackclusteridentityspec.go
@@ -0,0 +1,52 @@
+/*
+Copyright 2024 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+	http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Code generated by applyconfiguration-gen. DO NOT EDIT.
+
+package v1alpha1
+
+import (
+	v1 "k8s.io/client-go/applyconfigurations/meta/v1"
+)
+
+// OpenStackClusterIdentitySpecApplyConfiguration represents a declarative configuration of the OpenStackClusterIdentitySpec type for use
+// with apply.
+type OpenStackClusterIdentitySpecApplyConfiguration struct {
+	SecretRef         *OpenStackCredentialSecretReferenceApplyConfiguration `json:"secretRef,omitempty"`
+	NamespaceSelector *v1.LabelSelectorApplyConfiguration                   `json:"namespaceSelector,omitempty"`
+}
+
+// OpenStackClusterIdentitySpecApplyConfiguration constructs a declarative configuration of the OpenStackClusterIdentitySpec type for use with
+// apply.
+func OpenStackClusterIdentitySpec() *OpenStackClusterIdentitySpecApplyConfiguration {
+	return &OpenStackClusterIdentitySpecApplyConfiguration{}
+}
+
+// WithSecretRef sets the SecretRef field in the declarative configuration to the given value
+// and returns the receiver, so that objects can be built by chaining "With" function invocations.
+// If called multiple times, the SecretRef field is set to the value of the last call.
+func (b *OpenStackClusterIdentitySpecApplyConfiguration) WithSecretRef(value *OpenStackCredentialSecretReferenceApplyConfiguration) *OpenStackClusterIdentitySpecApplyConfiguration {
+	b.SecretRef = value
+	return b
+}
+
+// WithNamespaceSelector sets the NamespaceSelector field in the declarative configuration to the given value
+// and returns the receiver, so that objects can be built by chaining "With" function invocations.
+// If called multiple times, the NamespaceSelector field is set to the value of the last call.
+func (b *OpenStackClusterIdentitySpecApplyConfiguration) WithNamespaceSelector(value *v1.LabelSelectorApplyConfiguration) *OpenStackClusterIdentitySpecApplyConfiguration {
+	b.NamespaceSelector = value
+	return b
+}
diff --git a/pkg/generated/applyconfiguration/api/v1alpha1/openstackcredentialsecretreference.go b/pkg/generated/applyconfiguration/api/v1alpha1/openstackcredentialsecretreference.go
new file mode 100644
index 00000000..5ab540b7
--- /dev/null
+++ b/pkg/generated/applyconfiguration/api/v1alpha1/openstackcredentialsecretreference.go
@@ -0,0 +1,48 @@
+/*
+Copyright 2024 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+	http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Code generated by applyconfiguration-gen. DO NOT EDIT.
+
+package v1alpha1
+
+// OpenStackCredentialSecretReferenceApplyConfiguration represents a declarative configuration of the OpenStackCredentialSecretReference type for use
+// with apply.
+type OpenStackCredentialSecretReferenceApplyConfiguration struct {
+	Name      *string `json:"name,omitempty"`
+	Namespace *string `json:"namespace,omitempty"`
+}
+
+// OpenStackCredentialSecretReferenceApplyConfiguration constructs a declarative configuration of the OpenStackCredentialSecretReference type for use with
+// apply.
+func OpenStackCredentialSecretReference() *OpenStackCredentialSecretReferenceApplyConfiguration {
+	return &OpenStackCredentialSecretReferenceApplyConfiguration{}
+}
+
+// WithName sets the Name field in the declarative configuration to the given value
+// and returns the receiver, so that objects can be built by chaining "With" function invocations.
+// If called multiple times, the Name field is set to the value of the last call.
+func (b *OpenStackCredentialSecretReferenceApplyConfiguration) WithName(value string) *OpenStackCredentialSecretReferenceApplyConfiguration {
+	b.Name = &value
+	return b
+}
+
+// WithNamespace sets the Namespace field in the declarative configuration to the given value
+// and returns the receiver, so that objects can be built by chaining "With" function invocations.
+// If called multiple times, the Namespace field is set to the value of the last call.
+func (b *OpenStackCredentialSecretReferenceApplyConfiguration) WithNamespace(value string) *OpenStackCredentialSecretReferenceApplyConfiguration {
+	b.Namespace = &value
+	return b
+}
diff --git a/pkg/generated/applyconfiguration/api/v1beta1/openstackidentityreference.go b/pkg/generated/applyconfiguration/api/v1beta1/openstackidentityreference.go
index 5703481b..acb31b6e 100644
--- a/pkg/generated/applyconfiguration/api/v1beta1/openstackidentityreference.go
+++ b/pkg/generated/applyconfiguration/api/v1beta1/openstackidentityreference.go
@@ -21,6 +21,7 @@ package v1beta1
 // OpenStackIdentityReferenceApplyConfiguration represents a declarative configuration of the OpenStackIdentityReference type for use
 // with apply.
 type OpenStackIdentityReferenceApplyConfiguration struct {
+	Type      *string `json:"type,omitempty"`
 	Name      *string `json:"name,omitempty"`
 	CloudName *string `json:"cloudName,omitempty"`
 	Region    *string `json:"region,omitempty"`
@@ -32,6 +33,14 @@ func OpenStackIdentityReference() *OpenStackIdentityReferenceApplyConfiguration
 	return &OpenStackIdentityReferenceApplyConfiguration{}
 }
 
+// WithType sets the Type field in the declarative configuration to the given value
+// and returns the receiver, so that objects can be built by chaining "With" function invocations.
+// If called multiple times, the Type field is set to the value of the last call.
+func (b *OpenStackIdentityReferenceApplyConfiguration) WithType(value string) *OpenStackIdentityReferenceApplyConfiguration {
+	b.Type = &value
+	return b
+}
+
 // WithName sets the Name field in the declarative configuration to the given value
 // and returns the receiver, so that objects can be built by chaining "With" function invocations.
 // If called multiple times, the Name field is set to the value of the last call.
diff --git a/pkg/generated/applyconfiguration/internal/internal.go b/pkg/generated/applyconfiguration/internal/internal.go
index 20e8f16c..b11934fa 100644
--- a/pkg/generated/applyconfiguration/internal/internal.go
+++ b/pkg/generated/applyconfiguration/internal/internal.go
@@ -85,6 +85,38 @@ var schemaYAML = typed.YAMLObject(`types:
         elementType:
           namedType: __untyped_deduced_
         elementRelationship: separable
+- name: io.k8s.apimachinery.pkg.apis.meta.v1.LabelSelector
+  map:
+    fields:
+    - name: matchExpressions
+      type:
+        list:
+          elementType:
+            namedType: io.k8s.apimachinery.pkg.apis.meta.v1.LabelSelectorRequirement
+          elementRelationship: atomic
+    - name: matchLabels
+      type:
+        map:
+          elementType:
+            scalar: string
+    elementRelationship: atomic
+- name: io.k8s.apimachinery.pkg.apis.meta.v1.LabelSelectorRequirement
+  map:
+    fields:
+    - name: key
+      type:
+        scalar: string
+      default: ""
+    - name: operator
+      type:
+        scalar: string
+      default: ""
+    - name: values
+      type:
+        list:
+          elementType:
+            scalar: string
+          elementRelationship: atomic
 - name: io.k8s.apimachinery.pkg.apis.meta.v1.ManagedFieldsEntry
   map:
     fields:
@@ -200,6 +232,44 @@ var schemaYAML = typed.YAMLObject(`types:
     elementRelationship: atomic
 - name: io.k8s.apimachinery.pkg.apis.meta.v1.Time
   scalar: untyped
+- name: io.k8s.sigs.cluster-api-provider-openstack.api.v1alpha1.OpenStackClusterIdentity
+  map:
+    fields:
+    - name: apiVersion
+      type:
+        scalar: string
+    - name: kind
+      type:
+        scalar: string
+    - name: metadata
+      type:
+        namedType: io.k8s.apimachinery.pkg.apis.meta.v1.ObjectMeta
+      default: {}
+    - name: spec
+      type:
+        namedType: io.k8s.sigs.cluster-api-provider-openstack.api.v1alpha1.OpenStackClusterIdentitySpec
+      default: {}
+- name: io.k8s.sigs.cluster-api-provider-openstack.api.v1alpha1.OpenStackClusterIdentitySpec
+  map:
+    fields:
+    - name: namespaceSelector
+      type:
+        namedType: io.k8s.apimachinery.pkg.apis.meta.v1.LabelSelector
+    - name: secretRef
+      type:
+        namedType: io.k8s.sigs.cluster-api-provider-openstack.api.v1alpha1.OpenStackCredentialSecretReference
+      default: {}
+- name: io.k8s.sigs.cluster-api-provider-openstack.api.v1alpha1.OpenStackCredentialSecretReference
+  map:
+    fields:
+    - name: name
+      type:
+        scalar: string
+      default: ""
+    - name: namespace
+      type:
+        scalar: string
+      default: ""
 - name: io.k8s.sigs.cluster-api-provider-openstack.api.v1alpha1.OpenStackServer
   map:
     fields:
@@ -920,6 +990,9 @@ var schemaYAML = typed.YAMLObject(`types:
     - name: region
       type:
         scalar: string
+    - name: type
+      type:
+        scalar: string
 - name: io.k8s.sigs.cluster-api-provider-openstack.api.v1beta1.OpenStackMachine
   map:
     fields:
diff --git a/pkg/generated/applyconfiguration/utils.go b/pkg/generated/applyconfiguration/utils.go
index dfa39a2a..494dc2af 100644
--- a/pkg/generated/applyconfiguration/utils.go
+++ b/pkg/generated/applyconfiguration/utils.go
@@ -34,6 +34,12 @@ import (
 func ForKind(kind schema.GroupVersionKind) interface{} {
 	switch kind {
 	// Group=infrastructure.cluster.x-k8s.io, Version=v1alpha1
+	case v1alpha1.SchemeGroupVersion.WithKind("OpenStackClusterIdentity"):
+		return &apiv1alpha1.OpenStackClusterIdentityApplyConfiguration{}
+	case v1alpha1.SchemeGroupVersion.WithKind("OpenStackClusterIdentitySpec"):
+		return &apiv1alpha1.OpenStackClusterIdentitySpecApplyConfiguration{}
+	case v1alpha1.SchemeGroupVersion.WithKind("OpenStackCredentialSecretReference"):
+		return &apiv1alpha1.OpenStackCredentialSecretReferenceApplyConfiguration{}
 	case v1alpha1.SchemeGroupVersion.WithKind("OpenStackServer"):
 		return &apiv1alpha1.OpenStackServerApplyConfiguration{}
 	case v1alpha1.SchemeGroupVersion.WithKind("OpenStackServerSpec"):
diff --git a/pkg/generated/clientset/clientset/typed/api/v1alpha1/api_client.go b/pkg/generated/clientset/clientset/typed/api/v1alpha1/api_client.go
index 97190032..788c390b 100644
--- a/pkg/generated/clientset/clientset/typed/api/v1alpha1/api_client.go
+++ b/pkg/generated/clientset/clientset/typed/api/v1alpha1/api_client.go
@@ -28,6 +28,7 @@ import (
 
 type InfrastructureV1alpha1Interface interface {
 	RESTClient() rest.Interface
+	OpenStackClusterIdentitiesGetter
 	OpenStackServersGetter
 }
 
@@ -36,6 +37,10 @@ type InfrastructureV1alpha1Client struct {
 	restClient rest.Interface
 }
 
+func (c *InfrastructureV1alpha1Client) OpenStackClusterIdentities(namespace string) OpenStackClusterIdentityInterface {
+	return newOpenStackClusterIdentities(c, namespace)
+}
+
 func (c *InfrastructureV1alpha1Client) OpenStackServers(namespace string) OpenStackServerInterface {
 	return newOpenStackServers(c, namespace)
 }
diff --git a/pkg/generated/clientset/clientset/typed/api/v1alpha1/fake/fake_api_client.go b/pkg/generated/clientset/clientset/typed/api/v1alpha1/fake/fake_api_client.go
index 01944c1a..57bb4ca8 100644
--- a/pkg/generated/clientset/clientset/typed/api/v1alpha1/fake/fake_api_client.go
+++ b/pkg/generated/clientset/clientset/typed/api/v1alpha1/fake/fake_api_client.go
@@ -28,6 +28,10 @@ type FakeInfrastructureV1alpha1 struct {
 	*testing.Fake
 }
 
+func (c *FakeInfrastructureV1alpha1) OpenStackClusterIdentities(namespace string) v1alpha1.OpenStackClusterIdentityInterface {
+	return newFakeOpenStackClusterIdentities(c, namespace)
+}
+
 func (c *FakeInfrastructureV1alpha1) OpenStackServers(namespace string) v1alpha1.OpenStackServerInterface {
 	return newFakeOpenStackServers(c, namespace)
 }
diff --git a/pkg/generated/clientset/clientset/typed/api/v1alpha1/fake/fake_openstackclusteridentity.go b/pkg/generated/clientset/clientset/typed/api/v1alpha1/fake/fake_openstackclusteridentity.go
new file mode 100644
index 00000000..c7ae6ff3
--- /dev/null
+++ b/pkg/generated/clientset/clientset/typed/api/v1alpha1/fake/fake_openstackclusteridentity.go
@@ -0,0 +1,53 @@
+/*
+Copyright 2024 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+	http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Code generated by client-gen. DO NOT EDIT.
+
+package fake
+
+import (
+	gentype "k8s.io/client-go/gentype"
+	v1alpha1 "sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1"
+	apiv1alpha1 "sigs.k8s.io/cluster-api-provider-openstack/pkg/generated/applyconfiguration/api/v1alpha1"
+	typedapiv1alpha1 "sigs.k8s.io/cluster-api-provider-openstack/pkg/generated/clientset/clientset/typed/api/v1alpha1"
+)
+
+// fakeOpenStackClusterIdentities implements OpenStackClusterIdentityInterface
+type fakeOpenStackClusterIdentities struct {
+	*gentype.FakeClientWithListAndApply[*v1alpha1.OpenStackClusterIdentity, *v1alpha1.OpenStackClusterIdentityList, *apiv1alpha1.OpenStackClusterIdentityApplyConfiguration]
+	Fake *FakeInfrastructureV1alpha1
+}
+
+func newFakeOpenStackClusterIdentities(fake *FakeInfrastructureV1alpha1, namespace string) typedapiv1alpha1.OpenStackClusterIdentityInterface {
+	return &fakeOpenStackClusterIdentities{
+		gentype.NewFakeClientWithListAndApply[*v1alpha1.OpenStackClusterIdentity, *v1alpha1.OpenStackClusterIdentityList, *apiv1alpha1.OpenStackClusterIdentityApplyConfiguration](
+			fake.Fake,
+			namespace,
+			v1alpha1.SchemeGroupVersion.WithResource("openstackclusteridentities"),
+			v1alpha1.SchemeGroupVersion.WithKind("OpenStackClusterIdentity"),
+			func() *v1alpha1.OpenStackClusterIdentity { return &v1alpha1.OpenStackClusterIdentity{} },
+			func() *v1alpha1.OpenStackClusterIdentityList { return &v1alpha1.OpenStackClusterIdentityList{} },
+			func(dst, src *v1alpha1.OpenStackClusterIdentityList) { dst.ListMeta = src.ListMeta },
+			func(list *v1alpha1.OpenStackClusterIdentityList) []*v1alpha1.OpenStackClusterIdentity {
+				return gentype.ToPointerSlice(list.Items)
+			},
+			func(list *v1alpha1.OpenStackClusterIdentityList, items []*v1alpha1.OpenStackClusterIdentity) {
+				list.Items = gentype.FromPointerSlice(items)
+			},
+		),
+		fake,
+	}
+}
diff --git a/pkg/generated/clientset/clientset/typed/api/v1alpha1/generated_expansion.go b/pkg/generated/clientset/clientset/typed/api/v1alpha1/generated_expansion.go
index 7bfe8593..a6b176dc 100644
--- a/pkg/generated/clientset/clientset/typed/api/v1alpha1/generated_expansion.go
+++ b/pkg/generated/clientset/clientset/typed/api/v1alpha1/generated_expansion.go
@@ -18,4 +18,6 @@ limitations under the License.
 
 package v1alpha1
 
+type OpenStackClusterIdentityExpansion interface{}
+
 type OpenStackServerExpansion interface{}
diff --git a/pkg/generated/clientset/clientset/typed/api/v1alpha1/openstackclusteridentity.go b/pkg/generated/clientset/clientset/typed/api/v1alpha1/openstackclusteridentity.go
new file mode 100644
index 00000000..80d7788f
--- /dev/null
+++ b/pkg/generated/clientset/clientset/typed/api/v1alpha1/openstackclusteridentity.go
@@ -0,0 +1,70 @@
+/*
+Copyright 2024 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+	http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Code generated by client-gen. DO NOT EDIT.
+
+package v1alpha1
+
+import (
+	context "context"
+
+	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	types "k8s.io/apimachinery/pkg/types"
+	watch "k8s.io/apimachinery/pkg/watch"
+	gentype "k8s.io/client-go/gentype"
+	apiv1alpha1 "sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1"
+	applyconfigurationapiv1alpha1 "sigs.k8s.io/cluster-api-provider-openstack/pkg/generated/applyconfiguration/api/v1alpha1"
+	scheme "sigs.k8s.io/cluster-api-provider-openstack/pkg/generated/clientset/clientset/scheme"
+)
+
+// OpenStackClusterIdentitiesGetter has a method to return a OpenStackClusterIdentityInterface.
+// A group's client should implement this interface.
+type OpenStackClusterIdentitiesGetter interface {
+	OpenStackClusterIdentities(namespace string) OpenStackClusterIdentityInterface
+}
+
+// OpenStackClusterIdentityInterface has methods to work with OpenStackClusterIdentity resources.
+type OpenStackClusterIdentityInterface interface {
+	Create(ctx context.Context, openStackClusterIdentity *apiv1alpha1.OpenStackClusterIdentity, opts v1.CreateOptions) (*apiv1alpha1.OpenStackClusterIdentity, error)
+	Update(ctx context.Context, openStackClusterIdentity *apiv1alpha1.OpenStackClusterIdentity, opts v1.UpdateOptions) (*apiv1alpha1.OpenStackClusterIdentity, error)
+	Delete(ctx context.Context, name string, opts v1.DeleteOptions) error
+	DeleteCollection(ctx context.Context, opts v1.DeleteOptions, listOpts v1.ListOptions) error
+	Get(ctx context.Context, name string, opts v1.GetOptions) (*apiv1alpha1.OpenStackClusterIdentity, error)
+	List(ctx context.Context, opts v1.ListOptions) (*apiv1alpha1.OpenStackClusterIdentityList, error)
+	Watch(ctx context.Context, opts v1.ListOptions) (watch.Interface, error)
+	Patch(ctx context.Context, name string, pt types.PatchType, data []byte, opts v1.PatchOptions, subresources ...string) (result *apiv1alpha1.OpenStackClusterIdentity, err error)
+	Apply(ctx context.Context, openStackClusterIdentity *applyconfigurationapiv1alpha1.OpenStackClusterIdentityApplyConfiguration, opts v1.ApplyOptions) (result *apiv1alpha1.OpenStackClusterIdentity, err error)
+	OpenStackClusterIdentityExpansion
+}
+
+// openStackClusterIdentities implements OpenStackClusterIdentityInterface
+type openStackClusterIdentities struct {
+	*gentype.ClientWithListAndApply[*apiv1alpha1.OpenStackClusterIdentity, *apiv1alpha1.OpenStackClusterIdentityList, *applyconfigurationapiv1alpha1.OpenStackClusterIdentityApplyConfiguration]
+}
+
+// newOpenStackClusterIdentities returns a OpenStackClusterIdentities
+func newOpenStackClusterIdentities(c *InfrastructureV1alpha1Client, namespace string) *openStackClusterIdentities {
+	return &openStackClusterIdentities{
+		gentype.NewClientWithListAndApply[*apiv1alpha1.OpenStackClusterIdentity, *apiv1alpha1.OpenStackClusterIdentityList, *applyconfigurationapiv1alpha1.OpenStackClusterIdentityApplyConfiguration](
+			"openstackclusteridentities",
+			c.RESTClient(),
+			scheme.ParameterCodec,
+			namespace,
+			func() *apiv1alpha1.OpenStackClusterIdentity { return &apiv1alpha1.OpenStackClusterIdentity{} },
+			func() *apiv1alpha1.OpenStackClusterIdentityList { return &apiv1alpha1.OpenStackClusterIdentityList{} },
+		),
+	}
+}
diff --git a/pkg/generated/informers/externalversions/api/v1alpha1/interface.go b/pkg/generated/informers/externalversions/api/v1alpha1/interface.go
index f94a37f0..93a99b51 100644
--- a/pkg/generated/informers/externalversions/api/v1alpha1/interface.go
+++ b/pkg/generated/informers/externalversions/api/v1alpha1/interface.go
@@ -24,6 +24,8 @@ import (
 
 // Interface provides access to all the informers in this group version.
 type Interface interface {
+	// OpenStackClusterIdentities returns a OpenStackClusterIdentityInformer.
+	OpenStackClusterIdentities() OpenStackClusterIdentityInformer
 	// OpenStackServers returns a OpenStackServerInformer.
 	OpenStackServers() OpenStackServerInformer
 }
@@ -39,6 +41,11 @@ func New(f internalinterfaces.SharedInformerFactory, namespace string, tweakList
 	return &version{factory: f, namespace: namespace, tweakListOptions: tweakListOptions}
 }
 
+// OpenStackClusterIdentities returns a OpenStackClusterIdentityInformer.
+func (v *version) OpenStackClusterIdentities() OpenStackClusterIdentityInformer {
+	return &openStackClusterIdentityInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions}
+}
+
 // OpenStackServers returns a OpenStackServerInformer.
 func (v *version) OpenStackServers() OpenStackServerInformer {
 	return &openStackServerInformer{factory: v.factory, namespace: v.namespace, tweakListOptions: v.tweakListOptions}
diff --git a/pkg/generated/informers/externalversions/api/v1alpha1/openstackclusteridentity.go b/pkg/generated/informers/externalversions/api/v1alpha1/openstackclusteridentity.go
new file mode 100644
index 00000000..4899bed4
--- /dev/null
+++ b/pkg/generated/informers/externalversions/api/v1alpha1/openstackclusteridentity.go
@@ -0,0 +1,102 @@
+/*
+Copyright 2024 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+	http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Code generated by informer-gen. DO NOT EDIT.
+
+package v1alpha1
+
+import (
+	context "context"
+	time "time"
+
+	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	runtime "k8s.io/apimachinery/pkg/runtime"
+	watch "k8s.io/apimachinery/pkg/watch"
+	cache "k8s.io/client-go/tools/cache"
+	clusterapiprovideropenstackapiv1alpha1 "sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1"
+	clientset "sigs.k8s.io/cluster-api-provider-openstack/pkg/generated/clientset/clientset"
+	internalinterfaces "sigs.k8s.io/cluster-api-provider-openstack/pkg/generated/informers/externalversions/internalinterfaces"
+	apiv1alpha1 "sigs.k8s.io/cluster-api-provider-openstack/pkg/generated/listers/api/v1alpha1"
+)
+
+// OpenStackClusterIdentityInformer provides access to a shared informer and lister for
+// OpenStackClusterIdentities.
+type OpenStackClusterIdentityInformer interface {
+	Informer() cache.SharedIndexInformer
+	Lister() apiv1alpha1.OpenStackClusterIdentityLister
+}
+
+type openStackClusterIdentityInformer struct {
+	factory          internalinterfaces.SharedInformerFactory
+	tweakListOptions internalinterfaces.TweakListOptionsFunc
+	namespace        string
+}
+
+// NewOpenStackClusterIdentityInformer constructs a new informer for OpenStackClusterIdentity type.
+// Always prefer using an informer factory to get a shared informer instead of getting an independent
+// one. This reduces memory footprint and number of connections to the server.
+func NewOpenStackClusterIdentityInformer(client clientset.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers) cache.SharedIndexInformer {
+	return NewFilteredOpenStackClusterIdentityInformer(client, namespace, resyncPeriod, indexers, nil)
+}
+
+// NewFilteredOpenStackClusterIdentityInformer constructs a new informer for OpenStackClusterIdentity type.
+// Always prefer using an informer factory to get a shared informer instead of getting an independent
+// one. This reduces memory footprint and number of connections to the server.
+func NewFilteredOpenStackClusterIdentityInformer(client clientset.Interface, namespace string, resyncPeriod time.Duration, indexers cache.Indexers, tweakListOptions internalinterfaces.TweakListOptionsFunc) cache.SharedIndexInformer {
+	return cache.NewSharedIndexInformer(
+		&cache.ListWatch{
+			ListFunc: func(options v1.ListOptions) (runtime.Object, error) {
+				if tweakListOptions != nil {
+					tweakListOptions(&options)
+				}
+				return client.InfrastructureV1alpha1().OpenStackClusterIdentities(namespace).List(context.Background(), options)
+			},
+			WatchFunc: func(options v1.ListOptions) (watch.Interface, error) {
+				if tweakListOptions != nil {
+					tweakListOptions(&options)
+				}
+				return client.InfrastructureV1alpha1().OpenStackClusterIdentities(namespace).Watch(context.Background(), options)
+			},
+			ListWithContextFunc: func(ctx context.Context, options v1.ListOptions) (runtime.Object, error) {
+				if tweakListOptions != nil {
+					tweakListOptions(&options)
+				}
+				return client.InfrastructureV1alpha1().OpenStackClusterIdentities(namespace).List(ctx, options)
+			},
+			WatchFuncWithContext: func(ctx context.Context, options v1.ListOptions) (watch.Interface, error) {
+				if tweakListOptions != nil {
+					tweakListOptions(&options)
+				}
+				return client.InfrastructureV1alpha1().OpenStackClusterIdentities(namespace).Watch(ctx, options)
+			},
+		},
+		&clusterapiprovideropenstackapiv1alpha1.OpenStackClusterIdentity{},
+		resyncPeriod,
+		indexers,
+	)
+}
+
+func (f *openStackClusterIdentityInformer) defaultInformer(client clientset.Interface, resyncPeriod time.Duration) cache.SharedIndexInformer {
+	return NewFilteredOpenStackClusterIdentityInformer(client, f.namespace, resyncPeriod, cache.Indexers{cache.NamespaceIndex: cache.MetaNamespaceIndexFunc}, f.tweakListOptions)
+}
+
+func (f *openStackClusterIdentityInformer) Informer() cache.SharedIndexInformer {
+	return f.factory.InformerFor(&clusterapiprovideropenstackapiv1alpha1.OpenStackClusterIdentity{}, f.defaultInformer)
+}
+
+func (f *openStackClusterIdentityInformer) Lister() apiv1alpha1.OpenStackClusterIdentityLister {
+	return apiv1alpha1.NewOpenStackClusterIdentityLister(f.Informer().GetIndexer())
+}
diff --git a/pkg/generated/informers/externalversions/generic.go b/pkg/generated/informers/externalversions/generic.go
index a1d13ef1..c603ff05 100644
--- a/pkg/generated/informers/externalversions/generic.go
+++ b/pkg/generated/informers/externalversions/generic.go
@@ -54,6 +54,8 @@ func (f *genericInformer) Lister() cache.GenericLister {
 func (f *sharedInformerFactory) ForResource(resource schema.GroupVersionResource) (GenericInformer, error) {
 	switch resource {
 	// Group=infrastructure.cluster.x-k8s.io, Version=v1alpha1
+	case v1alpha1.SchemeGroupVersion.WithResource("openstackclusteridentities"):
+		return &genericInformer{resource: resource.GroupResource(), informer: f.Infrastructure().V1alpha1().OpenStackClusterIdentities().Informer()}, nil
 	case v1alpha1.SchemeGroupVersion.WithResource("openstackservers"):
 		return &genericInformer{resource: resource.GroupResource(), informer: f.Infrastructure().V1alpha1().OpenStackServers().Informer()}, nil
 
diff --git a/pkg/generated/listers/api/v1alpha1/expansion_generated.go b/pkg/generated/listers/api/v1alpha1/expansion_generated.go
index bd2ce412..1a9f025d 100644
--- a/pkg/generated/listers/api/v1alpha1/expansion_generated.go
+++ b/pkg/generated/listers/api/v1alpha1/expansion_generated.go
@@ -18,6 +18,14 @@ limitations under the License.
 
 package v1alpha1
 
+// OpenStackClusterIdentityListerExpansion allows custom methods to be added to
+// OpenStackClusterIdentityLister.
+type OpenStackClusterIdentityListerExpansion interface{}
+
+// OpenStackClusterIdentityNamespaceListerExpansion allows custom methods to be added to
+// OpenStackClusterIdentityNamespaceLister.
+type OpenStackClusterIdentityNamespaceListerExpansion interface{}
+
 // OpenStackServerListerExpansion allows custom methods to be added to
 // OpenStackServerLister.
 type OpenStackServerListerExpansion interface{}
diff --git a/pkg/generated/listers/api/v1alpha1/openstackclusteridentity.go b/pkg/generated/listers/api/v1alpha1/openstackclusteridentity.go
new file mode 100644
index 00000000..2251390b
--- /dev/null
+++ b/pkg/generated/listers/api/v1alpha1/openstackclusteridentity.go
@@ -0,0 +1,70 @@
+/*
+Copyright 2024 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+	http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// Code generated by lister-gen. DO NOT EDIT.
+
+package v1alpha1
+
+import (
+	labels "k8s.io/apimachinery/pkg/labels"
+	listers "k8s.io/client-go/listers"
+	cache "k8s.io/client-go/tools/cache"
+	apiv1alpha1 "sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1"
+)
+
+// OpenStackClusterIdentityLister helps list OpenStackClusterIdentities.
+// All objects returned here must be treated as read-only.
+type OpenStackClusterIdentityLister interface {
+	// List lists all OpenStackClusterIdentities in the indexer.
+	// Objects returned here must be treated as read-only.
+	List(selector labels.Selector) (ret []*apiv1alpha1.OpenStackClusterIdentity, err error)
+	// OpenStackClusterIdentities returns an object that can list and get OpenStackClusterIdentities.
+	OpenStackClusterIdentities(namespace string) OpenStackClusterIdentityNamespaceLister
+	OpenStackClusterIdentityListerExpansion
+}
+
+// openStackClusterIdentityLister implements the OpenStackClusterIdentityLister interface.
+type openStackClusterIdentityLister struct {
+	listers.ResourceIndexer[*apiv1alpha1.OpenStackClusterIdentity]
+}
+
+// NewOpenStackClusterIdentityLister returns a new OpenStackClusterIdentityLister.
+func NewOpenStackClusterIdentityLister(indexer cache.Indexer) OpenStackClusterIdentityLister {
+	return &openStackClusterIdentityLister{listers.New[*apiv1alpha1.OpenStackClusterIdentity](indexer, apiv1alpha1.Resource("openstackclusteridentity"))}
+}
+
+// OpenStackClusterIdentities returns an object that can list and get OpenStackClusterIdentities.
+func (s *openStackClusterIdentityLister) OpenStackClusterIdentities(namespace string) OpenStackClusterIdentityNamespaceLister {
+	return openStackClusterIdentityNamespaceLister{listers.NewNamespaced[*apiv1alpha1.OpenStackClusterIdentity](s.ResourceIndexer, namespace)}
+}
+
+// OpenStackClusterIdentityNamespaceLister helps list and get OpenStackClusterIdentities.
+// All objects returned here must be treated as read-only.
+type OpenStackClusterIdentityNamespaceLister interface {
+	// List lists all OpenStackClusterIdentities in the indexer for a given namespace.
+	// Objects returned here must be treated as read-only.
+	List(selector labels.Selector) (ret []*apiv1alpha1.OpenStackClusterIdentity, err error)
+	// Get retrieves the OpenStackClusterIdentity from the indexer for a given namespace and name.
+	// Objects returned here must be treated as read-only.
+	Get(name string) (*apiv1alpha1.OpenStackClusterIdentity, error)
+	OpenStackClusterIdentityNamespaceListerExpansion
+}
+
+// openStackClusterIdentityNamespaceLister implements the OpenStackClusterIdentityNamespaceLister
+// interface.
+type openStackClusterIdentityNamespaceLister struct {
+	listers.ResourceIndexer[*apiv1alpha1.OpenStackClusterIdentity]
+}
diff --git a/pkg/scope/provider.go b/pkg/scope/provider.go
index d23e3323..bb21afb4 100644
--- a/pkg/scope/provider.go
+++ b/pkg/scope/provider.go
@@ -31,12 +31,15 @@ import (
 	osclient "github.com/gophercloud/utils/v2/client"
 	"github.com/gophercloud/utils/v2/openstack/clientconfig"
 	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/labels"
 	"k8s.io/apimachinery/pkg/types"
 	"k8s.io/apimachinery/pkg/util/cache"
 	"k8s.io/klog/v2"
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/yaml"
 
+	infrav1alpha1 "sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1"
 	infrav1 "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1"
 	"sigs.k8s.io/cluster-api-provider-openstack/pkg/clients"
 	"sigs.k8s.io/cluster-api-provider-openstack/version"
@@ -47,6 +50,16 @@ const (
 	CASecretKey     = "cacert"
 )
 
+// IdentityAccessDeniedError is returned when a namespace is not permitted to use a ClusterIdentity.
+type IdentityAccessDeniedError struct {
+	IdentityName       string
+	RequesterNamespace string
+}
+
+func (e *IdentityAccessDeniedError) Error() string {
+	return fmt.Sprintf("namespace %s not allowed to use cluster identity %s", e.RequesterNamespace, e.IdentityName)
+}
+
 type providerScopeFactory struct {
 	clientCache *cache.LRUExpireCache
 }
@@ -69,8 +82,45 @@ func (f *providerScopeFactory) NewClientScopeFromObject(ctx context.Context, ctr
 	var cloud clientconfig.Cloud
 	var caCert []byte
 
-	var err error
-	cloud, caCert, err = getCloudFromSecret(ctx, ctrlClient, *namespace, identityRef.Name, identityRef.CloudName)
+	// Determine which secret to read based on identity type
+	var secretNamespace string
+	var secretName string
+
+	switch identityRef.Type {
+	case "", "Secret":
+		secretNamespace = *namespace
+		secretName = identityRef.Name
+		logger.V(4).Info("Using Secret for OpenStack credentials", "namespace", secretNamespace, "name", secretName, "cloudName", identityRef.CloudName)
+	case "ClusterIdentity":
+		// Fetch cluster-scoped identity and validate namespace access
+		identity := &infrav1alpha1.OpenStackClusterIdentity{}
+		if err := ctrlClient.Get(ctx, types.NamespacedName{Name: identityRef.Name}, identity); err != nil {
+			return nil, fmt.Errorf("failed to get OpenStackClusterIdentity %s: %w", identityRef.Name, err)
+		}
+		// Validate selector (if any) against the caller namespace
+		if identity.Spec.NamespaceSelector != nil {
+			ns := &corev1.Namespace{}
+			if err := ctrlClient.Get(ctx, types.NamespacedName{Name: *namespace}, ns); err != nil {
+				return nil, fmt.Errorf("failed to get namespace %s: %w", *namespace, err)
+			}
+			selector, err := metav1.LabelSelectorAsSelector(identity.Spec.NamespaceSelector)
+			if err != nil {
+				return nil, fmt.Errorf("invalid namespace selector on identity %s: %w", identity.Name, err)
+			}
+			if !selector.Matches(labels.Set(ns.Labels)) {
+				logger.V(2).Info("Namespace not allowed to use ClusterIdentity", "identity", identity.Name, "namespace", *namespace)
+				return nil, &IdentityAccessDeniedError{IdentityName: identity.Name, RequesterNamespace: *namespace}
+			}
+		}
+		secretNamespace = identity.Spec.SecretRef.Namespace
+		secretName = identity.Spec.SecretRef.Name
+		logger.V(4).Info("Using ClusterIdentity for OpenStack credentials", "identity", identityRef.Name, "secretNamespace", secretNamespace, "secretName", secretName, "cloudName", identityRef.CloudName)
+	default:
+		return nil, fmt.Errorf("unsupported identity type: %s", identityRef.Type)
+	}
+
+	// Read cloud from the resolved secret using the provided cloudName
+	cloud, caCert, err := getCloudFromSecret(ctx, ctrlClient, secretNamespace, secretName, identityRef.CloudName)
 	if err != nil {
 		return nil, err
 	}
diff --git a/pkg/utils/filterconvert/convert.go b/pkg/utils/filterconvert/convert.go
index d71a1d25..25346b7d 100644
--- a/pkg/utils/filterconvert/convert.go
+++ b/pkg/utils/filterconvert/convert.go
@@ -93,7 +93,7 @@ func RouterFilterToListOpts(routerFilter *infrav1.RouterFilter) routers.ListOpts
 
 func ImageFilterToListOpts(imageFilter *infrav1.ImageFilter) (listOpts images.ListOpts) {
 	if imageFilter == nil {
-		return
+		return listOpts
 	}
 
 	if imageFilter.Name != nil && *imageFilter.Name != "" {
@@ -103,5 +103,5 @@ func ImageFilterToListOpts(imageFilter *infrav1.ImageFilter) (listOpts images.Li
 	if len(imageFilter.Tags) > 0 {
 		listOpts.Tags = imageFilter.Tags
 	}
-	return
+	return listOpts
 }



Test patch:
diff --git a/controllers/openstackcluster_controller_test.go b/controllers/openstackcluster_controller_test.go
index 48c9bf19..548e7a28 100644
--- a/controllers/openstackcluster_controller_test.go
+++ b/controllers/openstackcluster_controller_test.go
@@ -39,6 +39,7 @@ import (
 	"sigs.k8s.io/controller-runtime/pkg/client"
 	"sigs.k8s.io/controller-runtime/pkg/reconcile"
 
+	infrav1alpha1 "sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1"
 	infrav1 "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1"
 	"sigs.k8s.io/cluster-api-provider-openstack/pkg/cloud/services/networking"
 	"sigs.k8s.io/cluster-api-provider-openstack/pkg/scope"
@@ -89,7 +90,12 @@ var _ = Describe("OpenStackCluster controller", func() {
 					},
 				},
 			},
-			Spec:   infrav1.OpenStackClusterSpec{},
+			Spec: infrav1.OpenStackClusterSpec{
+				IdentityRef: infrav1.OpenStackIdentityReference{
+					Name:      "test-creds",
+					CloudName: "openstack",
+				},
+			},
 			Status: infrav1.OpenStackClusterStatus{},
 		}
 		capiCluster = &clusterv1.Cluster{
@@ -148,6 +154,120 @@ var _ = Describe("OpenStackCluster controller", func() {
 		framework.DeleteNamespace(ctx, input)
 	})
 
+	It("should create OpenStackClusterIdentity (CRD present)", func() {
+		err := k8sClient.Create(ctx, testCluster)
+		Expect(err).To(BeNil())
+		err = k8sClient.Create(ctx, capiCluster)
+		Expect(err).To(BeNil())
+
+		id := &infrav1alpha1.OpenStackClusterIdentity{
+			ObjectMeta: metav1.ObjectMeta{
+				Name: fmt.Sprintf("id-%d", GinkgoRandomSeed()),
+			},
+			Spec: infrav1alpha1.OpenStackClusterIdentitySpec{
+				SecretRef: infrav1alpha1.OpenStackCredentialSecretReference{
+					Name:      "creds",
+					Namespace: "capo-system",
+				},
+			},
+		}
+		Expect(k8sClient.Create(ctx, id)).To(Succeed())
+
+		// Cleanup cluster-scoped resource since it won't be deleted with namespace
+		DeferCleanup(func() {
+			Expect(k8sClient.Delete(ctx, id)).To(Succeed())
+		})
+	})
+
+	It("should successfully create OpenStackCluster with valid identityRef", func() {
+		testCluster.Spec.IdentityRef = infrav1.OpenStackIdentityReference{
+			Name:      "creds",
+			CloudName: "openstack",
+			// Type should default to "Secret"
+		}
+		err := k8sClient.Create(ctx, testCluster)
+		Expect(err).To(BeNil())
+		err = k8sClient.Create(ctx, capiCluster)
+		Expect(err).To(BeNil())
+
+		// Verify the object was created and Type was defaulted
+		created := &infrav1.OpenStackCluster{}
+		err = k8sClient.Get(ctx, client.ObjectKey{Name: testCluster.Name, Namespace: testCluster.Namespace}, created)
+		Expect(err).To(Succeed())
+		Expect(created.Spec.IdentityRef.Type).To(Equal("Secret"))
+		Expect(created.Spec.IdentityRef.Name).To(Equal("creds"))
+		Expect(created.Spec.IdentityRef.CloudName).To(Equal("openstack"))
+	})
+
+	It("should successfully create OpenStackCluster with ClusterIdentity type", func() {
+		testCluster.Spec.IdentityRef = infrav1.OpenStackIdentityReference{
+			Type:      "ClusterIdentity",
+			Name:      "global-creds",
+			CloudName: "openstack",
+			Region:    "RegionOne",
+		}
+		err := k8sClient.Create(ctx, testCluster)
+		Expect(err).To(BeNil())
+		err = k8sClient.Create(ctx, capiCluster)
+		Expect(err).To(BeNil())
+
+		// Verify all fields are preserved
+		created := &infrav1.OpenStackCluster{}
+		err = k8sClient.Get(ctx, client.ObjectKey{Name: testCluster.Name, Namespace: testCluster.Namespace}, created)
+		Expect(err).To(Succeed())
+		Expect(created.Spec.IdentityRef.Type).To(Equal("ClusterIdentity"))
+		Expect(created.Spec.IdentityRef.Name).To(Equal("global-creds"))
+		Expect(created.Spec.IdentityRef.CloudName).To(Equal("openstack"))
+		Expect(created.Spec.IdentityRef.Region).To(Equal("RegionOne"))
+	})
+
+	It("should fail when namespace is denied access to ClusterIdentity", func() {
+		testCluster.SetName("identity-access-denied")
+		testCluster.Spec.IdentityRef = infrav1.OpenStackIdentityReference{
+			Type:      "ClusterIdentity",
+			Name:      "test-cluster-identity",
+			CloudName: "openstack",
+		}
+
+		err := k8sClient.Create(ctx, testCluster)
+		Expect(err).To(BeNil())
+		err = k8sClient.Create(ctx, capiCluster)
+		Expect(err).To(BeNil())
+
+		identityAccessErr := &scope.IdentityAccessDeniedError{
+			IdentityName:       "test-cluster-identity",
+			RequesterNamespace: testNamespace,
+		}
+		mockScopeFactory.SetClientScopeCreateError(identityAccessErr)
+
+		req := createRequestFromOSCluster(testCluster)
+		result, err := reconciler.Reconcile(ctx, req)
+
+		Expect(err).To(MatchError(identityAccessErr))
+		Expect(result).To(Equal(reconcile.Result{}))
+	})
+
+	It("should reject updates that modify identityRef.region (immutable)", func() {
+		testCluster.Spec = infrav1.OpenStackClusterSpec{
+			IdentityRef: infrav1.OpenStackIdentityReference{
+				Type:      "Secret",
+				Name:      "creds",
+				CloudName: "openstack",
+				Region:    "RegionOne",
+			},
+		}
+		err := k8sClient.Create(ctx, testCluster)
+		Expect(err).To(BeNil())
+		err = k8sClient.Create(ctx, capiCluster)
+		Expect(err).To(BeNil())
+
+		// Try to update region
+		fetched := &infrav1.OpenStackCluster{}
+		Expect(k8sClient.Get(ctx, types.NamespacedName{Name: testClusterName, Namespace: testNamespace}, fetched)).To(Succeed())
+		fetched.Spec.IdentityRef.Region = "RegionTwo"
+		Expect(k8sClient.Update(ctx, fetched)).ToNot(Succeed())
+	})
+
 	It("should do nothing when owner is missing", func() {
 		testCluster.SetName("missing-owner")
 		testCluster.SetOwnerReferences([]metav1.OwnerReference{})
@@ -197,6 +317,10 @@ var _ = Describe("OpenStackCluster controller", func() {
 	It("should be able to reconcile when bastion is explicitly disabled and does not exist", func() {
 		testCluster.SetName("no-bastion-explicit")
 		testCluster.Spec = infrav1.OpenStackClusterSpec{
+			IdentityRef: infrav1.OpenStackIdentityReference{
+				Name:      "test-creds",
+				CloudName: "openstack",
+			},
 			Bastion: &infrav1.Bastion{Enabled: ptr.To(false)},
 		}
 		err := k8sClient.Create(ctx, testCluster)
@@ -221,7 +345,12 @@ var _ = Describe("OpenStackCluster controller", func() {
 	})
 	It("should delete an existing bastion even if its uuid is not stored in status", func() {
 		testCluster.SetName("delete-existing-bastion")
-		testCluster.Spec = infrav1.OpenStackClusterSpec{}
+		testCluster.Spec = infrav1.OpenStackClusterSpec{
+			IdentityRef: infrav1.OpenStackIdentityReference{
+				Name:      "test-creds",
+				CloudName: "openstack",
+			},
+		}
 		err := k8sClient.Create(ctx, testCluster)
 		Expect(err).To(BeNil())
 		err = k8sClient.Create(ctx, capiCluster)
@@ -252,6 +381,10 @@ var _ = Describe("OpenStackCluster controller", func() {
 
 		testCluster.SetName("subnet-filtering")
 		testCluster.Spec = infrav1.OpenStackClusterSpec{
+			IdentityRef: infrav1.OpenStackIdentityReference{
+				Name:      "test-creds",
+				CloudName: "openstack",
+			},
 			Bastion: &infrav1.Bastion{
 				Enabled: ptr.To(true),
 				Spec:    &bastionSpec,
@@ -322,6 +455,10 @@ var _ = Describe("OpenStackCluster controller", func() {
 
 		testCluster.SetName("subnet-filtering")
 		testCluster.Spec = infrav1.OpenStackClusterSpec{
+			IdentityRef: infrav1.OpenStackIdentityReference{
+				Name:      "test-creds",
+				CloudName: "openstack",
+			},
 			Bastion: &infrav1.Bastion{
 				Enabled: ptr.To(true),
 				Spec:    &bastionSpec,
@@ -399,6 +536,10 @@ var _ = Describe("OpenStackCluster controller", func() {
 
 		testCluster.SetName("subnet-filtering")
 		testCluster.Spec = infrav1.OpenStackClusterSpec{
+			IdentityRef: infrav1.OpenStackIdentityReference{
+				Name:      "test-creds",
+				CloudName: "openstack",
+			},
 			DisableAPIServerFloatingIP: ptr.To(true),
 			APIServerFixedIP:           ptr.To("10.0.0.1"),
 			DisableExternalNetwork:     ptr.To(true),
@@ -442,6 +583,10 @@ var _ = Describe("OpenStackCluster controller", func() {
 
 		testCluster.SetName("pre-existing-network-components-by-id")
 		testCluster.Spec = infrav1.OpenStackClusterSpec{
+			IdentityRef: infrav1.OpenStackIdentityReference{
+				Name:      "test-creds",
+				CloudName: "openstack",
+			},
 			Network: &infrav1.NetworkParam{
 				ID: ptr.To(clusterNetworkID),
 			},
@@ -501,6 +646,10 @@ var _ = Describe("OpenStackCluster controller", func() {
 
 		testCluster.SetName("pre-existing-network-components-by-id")
 		testCluster.Spec = infrav1.OpenStackClusterSpec{
+			IdentityRef: infrav1.OpenStackIdentityReference{
+				Name:      "test-creds",
+				CloudName: "openstack",
+			},
 			Network: &infrav1.NetworkParam{
 				Filter: &infrav1.NetworkFilter{
 					Name: clusterNetworkName,
diff --git a/pkg/scope/provider_resolution_test.go b/pkg/scope/provider_resolution_test.go
new file mode 100644
index 00000000..e5c86f2f
--- /dev/null
+++ b/pkg/scope/provider_resolution_test.go
@@ -0,0 +1,304 @@
+/*
+Copyright 2025 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+	http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package scope
+
+import (
+	"context"
+	"errors"
+	"strings"
+	"testing"
+
+	"github.com/go-logr/logr"
+	corev1 "k8s.io/api/core/v1"
+	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/client-go/kubernetes/scheme"
+	"sigs.k8s.io/controller-runtime/pkg/client"
+	"sigs.k8s.io/controller-runtime/pkg/client/fake"
+
+	infrav1alpha1 "sigs.k8s.io/cluster-api-provider-openstack/api/v1alpha1"
+	infrav1 "sigs.k8s.io/cluster-api-provider-openstack/api/v1beta1"
+)
+
+const (
+	testNSA          = "ns-a"
+	testNSAllowed    = "allowed-team"
+	testNSTeamY      = "team-y"
+	testNSTeamZ      = "team-z"
+	testNSTeamW      = "team-w"
+	testNSTeamA      = "team-a"
+	testNSCapo       = "capo-system"
+	resTestCloudName = "mycloud"
+	testProdCloud    = "prodcloud"
+)
+
+var (
+	testValidCloudsYAML = []byte(`clouds:
+  mycloud:
+    auth:
+      auth_url: https://keystone.example.com/
+      application_credential_id: id
+      application_credential_secret: secret
+    region_name: RegionOne
+`)
+	testProdCloudsYAML = []byte(`clouds:
+  prodcloud:
+    auth:
+      auth_url: https://keystone.prod.com/
+      application_credential_id: prod-id
+      application_credential_secret: prod-secret
+    region_name: RegionOne
+`)
+	testEmptyCloudsYAML   = []byte("clouds: {}\n")
+	testDefaultCloudsYAML = []byte("clouds: { default: {} }\n")
+)
+
+// ensureSchemes creates a runtime scheme with all required API types for testing.
+func ensureSchemes(t *testing.T) *runtime.Scheme {
+	t.Helper()
+	local := runtime.NewScheme()
+	if err := scheme.AddToScheme(local); err != nil {
+		t.Fatalf("failed to add core scheme: %v", err)
+	}
+	if err := infrav1.AddToScheme(local); err != nil {
+		t.Fatalf("failed to add v1beta1 scheme: %v", err)
+	}
+	if err := infrav1alpha1.AddToScheme(local); err != nil {
+		t.Fatalf("failed to add v1alpha1 scheme: %v", err)
+	}
+	return local
+}
+
+// createResTestSecret creates a test Secret with the given namespace, name, and data.
+func createResTestSecret(namespace, name string, data map[string][]byte) *corev1.Secret {
+	secret := &corev1.Secret{}
+	secret.Namespace = namespace
+	secret.Name = name
+	secret.Data = data
+	return secret
+}
+
+// createTestNamespace creates a test Namespace with the given name and labels.
+func createTestNamespace(name string, labels map[string]string) *corev1.Namespace {
+	ns := &corev1.Namespace{}
+	ns.Name = name
+	ns.Labels = labels
+	return ns
+}
+
+// createTestClusterIdentity creates a test OpenStackClusterIdentity with the given name and namespace selector.
+func createTestClusterIdentity(name string, selector *metav1.LabelSelector) *infrav1alpha1.OpenStackClusterIdentity {
+	identity := &infrav1alpha1.OpenStackClusterIdentity{}
+	identity.Name = name
+	identity.Spec.SecretRef = infrav1alpha1.OpenStackCredentialSecretReference{
+		Name:      "creds",
+		Namespace: testNSCapo,
+	}
+	identity.Spec.NamespaceSelector = selector
+	return identity
+}
+
+// newFakeClient creates a fake Kubernetes client with the provided scheme and objects.
+func newFakeClient(sch *runtime.Scheme, objs ...client.Object) client.Client {
+	return fake.NewClientBuilder().WithScheme(sch).WithObjects(objs...).Build()
+}
+
+// assertResolutionReached ensures the error is not caused by credential resolution (missing secret/namespace or access denied).
+// We still expect an error (typically from OpenStack auth) when running full scope creation with the real factory.
+func assertResolutionReached(t *testing.T, err error) {
+	t.Helper()
+	if err == nil {
+		t.Fatalf("expected OpenStack auth error, got success")
+	}
+	if strings.Contains(err.Error(), "secret") && strings.Contains(err.Error(), "not found") {
+		t.Fatalf("credential resolution failed: %v", err)
+	}
+	var denied *IdentityAccessDeniedError
+	if errors.As(err, &denied) {
+		t.Fatalf("credential resolution failed: %v", err)
+	}
+}
+
+// assertDenied verifies that the error is an IdentityAccessDeniedError.
+func assertDenied(t *testing.T, err error) {
+	t.Helper()
+	var denied *IdentityAccessDeniedError
+	if err == nil || !errors.As(err, &denied) {
+		t.Fatalf("expected IdentityAccessDeniedError, got %T %v", err, err)
+	}
+}
+
+// assertNotDenied verifies that the error is NOT an IdentityAccessDeniedError.
+func assertNotDenied(t *testing.T, err error) {
+	t.Helper()
+	var denied *IdentityAccessDeniedError
+	if errors.As(err, &denied) {
+		t.Fatalf("did not expect IdentityAccessDeniedError, got %v", err)
+	}
+}
+
+// TestNewClientScopeFromObject_Resolution tests credential resolution logic for both Secret and ClusterIdentity paths.
+func TestNewClientScopeFromObject_Resolution(t *testing.T) {
+	t.Parallel()
+	localScheme := ensureSchemes(t)
+	type testCase struct {
+		name      string
+		objects   []client.Object
+		namespace string
+		identity  infrav1.OpenStackIdentityReference
+		assertErr func(*testing.T, error)
+	}
+
+	cases := []testCase{
+		{
+			name: "secret path returns scope",
+			objects: []client.Object{
+				createResTestSecret(testNSA, "valid-creds", map[string][]byte{CloudsSecretKey: testValidCloudsYAML}),
+			},
+			namespace: testNSA,
+			identity:  infrav1.OpenStackIdentityReference{Name: "valid-creds", CloudName: resTestCloudName},
+			assertErr: func(t *testing.T, err error) {
+				t.Helper()
+				assertResolutionReached(t, err)
+			},
+		},
+		{
+			name: "clusteridentity returns scope when selector allows",
+			objects: []client.Object{
+				createTestNamespace(testNSAllowed, map[string]string{"env": "prod"}),
+				createTestClusterIdentity("prod-id", &metav1.LabelSelector{MatchLabels: map[string]string{"env": "prod"}}),
+				createResTestSecret(testNSCapo, "creds", map[string][]byte{CloudsSecretKey: testProdCloudsYAML}),
+			},
+			namespace: testNSAllowed,
+			identity:  infrav1.OpenStackIdentityReference{Type: "ClusterIdentity", Name: "prod-id", CloudName: testProdCloud},
+			assertErr: func(t *testing.T, err error) {
+				t.Helper()
+				assertResolutionReached(t, err)
+			},
+		},
+		{
+			name:      "secret path: missing secret returns error",
+			objects:   []client.Object{},
+			namespace: testNSA,
+			identity:  infrav1.OpenStackIdentityReference{Name: "missing", CloudName: "cloudA"},
+			assertErr: func(t *testing.T, err error) {
+				t.Helper()
+				if err == nil {
+					t.Fatalf("expected error")
+				}
+			},
+		},
+		{
+			name:      "secret path: empty cloudName returns error",
+			objects:   []client.Object{createResTestSecret(testNSA, "creds", map[string][]byte{CloudsSecretKey: testEmptyCloudsYAML})},
+			namespace: testNSA,
+			identity:  infrav1.OpenStackIdentityReference{Name: "creds", CloudName: ""},
+			assertErr: func(t *testing.T, err error) {
+				t.Helper()
+				if err == nil {
+					t.Fatalf("expected error")
+				}
+			},
+		},
+		{
+			name:      "clusteridentity: identity not found",
+			objects:   []client.Object{},
+			namespace: "team-x",
+			identity:  infrav1.OpenStackIdentityReference{Type: "ClusterIdentity", Name: "missing-id", CloudName: "cloudA"},
+			assertErr: func(t *testing.T, err error) {
+				t.Helper()
+				if err == nil {
+					t.Fatalf("expected error")
+				}
+			},
+		},
+		{
+			name: "clusteridentity: selector denies -> access denied",
+			objects: []client.Object{
+				createTestNamespace(testNSTeamY, nil),
+				createTestClusterIdentity("prod-id", &metav1.LabelSelector{MatchLabels: map[string]string{"allowed": "true"}}),
+				createResTestSecret(testNSCapo, "creds", map[string][]byte{CloudsSecretKey: testEmptyCloudsYAML}),
+			},
+			namespace: testNSTeamY,
+			identity:  infrav1.OpenStackIdentityReference{Type: "ClusterIdentity", Name: "prod-id", CloudName: "cloudA"},
+			assertErr: func(t *testing.T, err error) {
+				t.Helper()
+				assertDenied(t, err)
+			},
+		},
+		{
+			name: "clusteridentity: selector nil allows (not denied)",
+			objects: []client.Object{
+				createTestNamespace(testNSTeamZ, nil),
+				createTestClusterIdentity("any-id", nil),
+				createResTestSecret(testNSCapo, "creds", map[string][]byte{CloudsSecretKey: testDefaultCloudsYAML}),
+			},
+			namespace: testNSTeamZ,
+			identity:  infrav1.OpenStackIdentityReference{Type: "ClusterIdentity", Name: "any-id", CloudName: "default"},
+			assertErr: func(t *testing.T, err error) {
+				t.Helper()
+				assertNotDenied(t, err)
+			},
+		},
+		{
+			name: "clusteridentity: empty selector matches all (not denied)",
+			objects: []client.Object{
+				createTestNamespace(testNSTeamW, nil),
+				createTestClusterIdentity("empty-selector-id", &metav1.LabelSelector{}),
+				createResTestSecret(testNSCapo, "creds", map[string][]byte{CloudsSecretKey: testDefaultCloudsYAML}),
+			},
+			namespace: testNSTeamW,
+			identity:  infrav1.OpenStackIdentityReference{Type: "ClusterIdentity", Name: "empty-selector-id", CloudName: "default"},
+			assertErr: func(t *testing.T, err error) {
+				t.Helper()
+				assertNotDenied(t, err)
+			},
+		},
+		{
+			name: "clusteridentity: cross-namespace secret allowed (not denied)",
+			objects: []client.Object{
+				createTestNamespace(testNSTeamA, nil),
+				createTestNamespace(testNSCapo, nil),
+				createTestClusterIdentity("cross-ns-id", nil),
+				createResTestSecret(testNSCapo, "creds", map[string][]byte{CloudsSecretKey: testDefaultCloudsYAML}),
+			},
+			namespace: testNSTeamA,
+			identity:  infrav1.OpenStackIdentityReference{Type: "ClusterIdentity", Name: "cross-ns-id", CloudName: "default"},
+			assertErr: func(t *testing.T, err error) {
+				t.Helper()
+				assertNotDenied(t, err)
+			},
+		},
+	}
+
+	for _, tc := range cases {
+		t.Run(tc.name, func(t *testing.T) {
+			ctx := context.Background()
+			c := newFakeClient(localScheme, tc.objects...)
+			// Always use real factory with fake k8s client - this tests credential resolution
+			// without making OpenStack API calls
+			factory := &providerScopeFactory{}
+
+			srv := &infrav1alpha1.OpenStackServer{}
+			srv.Namespace = tc.namespace
+			srv.Spec.IdentityRef = tc.identity
+
+			_, err := factory.NewClientScopeFromObject(ctx, c, nil, logr.Discard(), srv)
+			tc.assertErr(t, err)
+		})
+	}
+}
diff --git a/pkg/scope/provider_test.go b/pkg/scope/provider_test.go
new file mode 100644
index 00000000..345f2418
--- /dev/null
+++ b/pkg/scope/provider_test.go
@@ -0,0 +1,190 @@
+/*
+Copyright 2025 The Kubernetes Authors.
+
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+	http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package scope
+
+import (
+	"context"
+	"testing"
+
+	corev1 "k8s.io/api/core/v1"
+	"k8s.io/apimachinery/pkg/runtime"
+	"k8s.io/client-go/kubernetes/scheme"
+	"sigs.k8s.io/controller-runtime/pkg/client/fake"
+)
+
+const (
+	testNamespace = "test-ns"
+	testCloudName = "mycloud"
+	testRegion    = "RegionOne"
+)
+
+var (
+	testCloudsYAML = []byte(`clouds:
+  mycloud:
+    auth:
+      auth_url: https://keystone.example.com/
+      application_credential_id: id
+      application_credential_secret: secret
+    region_name: RegionOne
+    interface: public
+    identity_api_version: 3
+    auth_type: v3applicationcredential
+`)
+	testCACert = []byte("-----BEGIN CERTIFICATE-----\nMIIB...\n-----END CERTIFICATE-----\n")
+)
+
+// buildCoreScheme creates a runtime scheme with core Kubernetes API types for testing.
+func buildCoreScheme(t *testing.T) *runtime.Scheme {
+	t.Helper()
+	sch := runtime.NewScheme()
+	if err := scheme.AddToScheme(sch); err != nil {
+		t.Fatalf("failed to add core scheme: %v", err)
+	}
+	return sch
+}
+
+// createTestSecret creates a test Secret in the test namespace with the given name and data.
+func createTestSecret(name string, data map[string][]byte) *corev1.Secret {
+	secret := &corev1.Secret{}
+	secret.Namespace = testNamespace
+	secret.Name = name
+	secret.Data = data
+	return secret
+}
+
+// TestGetCloudFromSecret_SuccessWithCACert tests successful cloud retrieval when CA certificate is present.
+func TestGetCloudFromSecret_SuccessWithCACert(t *testing.T) {
+	t.Parallel()
+	ctx := context.Background()
+
+	secretName := "os-cred"
+	secret := createTestSecret(secretName, map[string][]byte{
+		CloudsSecretKey: testCloudsYAML,
+		CASecretKey:     testCACert,
+	})
+
+	c := fake.NewClientBuilder().WithScheme(buildCoreScheme(t)).WithObjects(secret).Build()
+
+	cloud, gotCACert, err := getCloudFromSecret(ctx, c, testNamespace, secretName, testCloudName)
+	if err != nil {
+		t.Fatalf("getCloudFromSecret returned error: %v", err)
+	}
+	if cloud.RegionName != testRegion {
+		t.Fatalf("expected %s region, got %q", testRegion, cloud.RegionName)
+	}
+	if len(gotCACert) == 0 {
+		t.Fatalf("expected non-empty caCert")
+	}
+}
+
+// TestGetCloudFromSecret_SuccessWithoutCACert tests successful cloud retrieval when CA certificate is not present.
+func TestGetCloudFromSecret_SuccessWithoutCACert(t *testing.T) {
+	t.Parallel()
+	ctx := context.Background()
+
+	secretName := "os-cred-no-ca" //nolint:gosec
+	secret := createTestSecret(secretName, map[string][]byte{
+		CloudsSecretKey: testCloudsYAML,
+	})
+
+	c := fake.NewClientBuilder().WithScheme(buildCoreScheme(t)).WithObjects(secret).Build()
+
+	cloud, gotCACert, err := getCloudFromSecret(ctx, c, testNamespace, secretName, testCloudName)
+	if err != nil {
+		t.Fatalf("getCloudFromSecret returned error: %v", err)
+	}
+	if cloud.RegionName != testRegion {
+		t.Fatalf("expected %s region, got %q", testRegion, cloud.RegionName)
+	}
+	if gotCACert != nil {
+		t.Fatalf("expected nil caCert when not present, got %d bytes", len(gotCACert))
+	}
+}
+
+// TestGetCloudFromSecret_MissingSecret tests error handling when the secret does not exist.
+func TestGetCloudFromSecret_MissingSecret(t *testing.T) {
+	t.Parallel()
+	ctx := context.Background()
+
+	c := fake.NewClientBuilder().WithScheme(buildCoreScheme(t)).Build()
+
+	_, _, err := getCloudFromSecret(ctx, c, testNamespace, "missing", testCloudName)
+	if err == nil {
+		t.Fatalf("expected error for missing secret, got nil")
+	}
+}
+
+// TestGetCloudFromSecret_MissingCloudsKey tests error handling when the clouds.yaml key is missing from the secret.
+func TestGetCloudFromSecret_MissingCloudsKey(t *testing.T) {
+	t.Parallel()
+	ctx := context.Background()
+
+	secretName := "no-clouds"
+	secret := createTestSecret(secretName, map[string][]byte{
+		// intentionally no CloudsSecretKey
+		"other": []byte("x"),
+	})
+
+	c := fake.NewClientBuilder().WithScheme(buildCoreScheme(t)).WithObjects(secret).Build()
+
+	_, _, err := getCloudFromSecret(ctx, c, testNamespace, secretName, testCloudName)
+	if err == nil {
+		t.Fatalf("expected error for missing clouds.yaml key, got nil")
+	}
+}
+
+// TestGetCloudFromSecret_EmptyCloudName tests error handling when cloudName is empty.
+func TestGetCloudFromSecret_EmptyCloudName(t *testing.T) {
+	t.Parallel()
+	ctx := context.Background()
+
+	secretName := "any"
+	secret := createTestSecret(secretName, map[string][]byte{
+		CloudsSecretKey: []byte("clouds: {}\n"),
+	})
+
+	c := fake.NewClientBuilder().WithScheme(buildCoreScheme(t)).WithObjects(secret).Build()
+
+	_, _, err := getCloudFromSecret(ctx, c, testNamespace, secretName, "")
+	if err == nil {
+		t.Fatalf("expected error when cloudName is empty, got nil")
+	}
+}
+
+// TestGetCloudFromSecret_InvalidCloudName tests behavior when cloudName does not exist in clouds.yaml.
+func TestGetCloudFromSecret_InvalidCloudName(t *testing.T) {
+	t.Parallel()
+	ctx := context.Background()
+
+	secretName := "cred"
+	secret := createTestSecret(secretName, map[string][]byte{
+		CloudsSecretKey: testCloudsYAML,
+	})
+
+	c := fake.NewClientBuilder().WithScheme(buildCoreScheme(t)).WithObjects(secret).Build()
+
+	cloud, ca, err := getCloudFromSecret(ctx, c, testNamespace, secretName, "missing-cloud")
+	if err != nil {
+		t.Fatalf("expected no error for unknown cloudName (returned zero-value), got: %v", err)
+	}
+	if ca != nil {
+		t.Fatalf("expected nil caCert for missing key, got %d bytes", len(ca))
+	}
+	if cloud.RegionName != "" || cloud.AuthInfo != nil {
+		t.Fatalf("expected zero-value cloud for unknown cloudName, got RegionName=%q AuthInfo-nil=%v", cloud.RegionName, cloud.AuthInfo == nil)
+	}
+}
diff --git a/test/e2e/data/ccm/cloud-controller-manager.yaml b/test/e2e/data/ccm/cloud-controller-manager.yaml
index 90ef0035..7fb50d48 100644
--- a/test/e2e/data/ccm/cloud-controller-manager.yaml
+++ b/test/e2e/data/ccm/cloud-controller-manager.yaml
@@ -1,5 +1,5 @@
 # From: https://raw.githubusercontent.com/kubernetes/cloud-provider-openstack/master/manifests/controller-manager/openstack-cloud-controller-manager-ds.yaml
----
+# NOTE! We modify the node-selector to have empty value (""). This matches what kubeadm does.
 apiVersion: v1
 kind: ServiceAccount
 metadata:
@@ -26,64 +26,67 @@ spec:
     spec:
       nodeSelector:
         node-role.kubernetes.io/control-plane: ""
-      # we need user root to read the cloud.conf from the host
       securityContext:
-        runAsUser: 0
+        runAsUser: 1001
       tolerations:
-        - key: node.cloudprovider.kubernetes.io/uninitialized
-          value: "true"
-          effect: NoSchedule
-        - key: node-role.kubernetes.io/master
-          effect: NoSchedule
-        - key: node-role.kubernetes.io/control-plane
-          effect: NoSchedule
+      - key: "CriticalAddonsOnly"
+        operator: "Equal"
+        value: "true"
+        effect: "NoExecute"
+      - key: node.cloudprovider.kubernetes.io/uninitialized
+        value: "true"
+        effect: NoSchedule
+      - key: node-role.kubernetes.io/master
+        effect: NoSchedule
+      - key: node-role.kubernetes.io/control-plane
+        effect: NoSchedule
       serviceAccountName: cloud-controller-manager
       containers:
-        - name: openstack-cloud-controller-manager
-          image: >-
-            registry.k8s.io/provider-os/openstack-cloud-controller-manager:v1.32.0
-          args:
-            - /bin/openstack-cloud-controller-manager
-            - --v=1
-            - --cluster-name=$(CLUSTER_NAME)
-            - --cloud-config=$(CLOUD_CONFIG)
-            - --cloud-provider=openstack
-            - --use-service-account-credentials=false
-            - --bind-address=127.0.0.1
-          volumeMounts:
-            - mountPath: /etc/kubernetes
-              name: k8s
-              readOnly: true
-            - mountPath: /etc/kubernetes/pki
-              name: k8s-certs
-              readOnly: true
-            - mountPath: /etc/ssl/certs
-              name: ca-certs
-              readOnly: true
-          resources:
-            requests:
-              cpu: 200m
-          env:
-            - name: CLOUD_CONFIG
-              value: /etc/kubernetes/cloud.conf
-            - name: CLUSTER_NAME
-              value: kubernetes
-      hostNetwork: true
-      volumes:
-        - hostPath:
-            path: /etc/kubernetes
-            type: DirectoryOrCreate
-          name: k8s
-        - hostPath:
-            path: /etc/kubernetes/pki
-            type: DirectoryOrCreate
+      - name: openstack-cloud-controller-manager
+        image: registry.k8s.io/provider-os/openstack-cloud-controller-manager:v1.33.0
+        args:
+        - /bin/openstack-cloud-controller-manager
+        - --v=1
+        - --cluster-name=$(CLUSTER_NAME)
+        - --cloud-config=$(CLOUD_CONFIG)
+        - --cloud-provider=openstack
+        - --use-service-account-credentials=false
+        - --bind-address=127.0.0.1
+        volumeMounts:
+        - mountPath: /etc/kubernetes/pki
           name: k8s-certs
-        - hostPath:
-            path: /etc/ssl/certs
-            type: DirectoryOrCreate
+          readOnly: true
+        - mountPath: /etc/ssl/certs
           name: ca-certs
+          readOnly: true
+        - mountPath: /etc/config
+          name: cloud-config-volume
+          readOnly: true
+        resources:
+          requests:
+            cpu: 200m
+        env:
+        - name: CLOUD_CONFIG
+          value: /etc/config/cloud.conf
+        - name: CLUSTER_NAME
+          value: kubernetes
+      dnsPolicy: ClusterFirst
+      hostNetwork: true
+      volumes:
+      - hostPath:
+          path: /etc/kubernetes/pki
+          type: DirectoryOrCreate
+        name: k8s-certs
+      - hostPath:
+          path: /etc/ssl/certs
+          type: DirectoryOrCreate
+        name: ca-certs
+      - name: cloud-config-volume
+        secret:
+          secretName: cloud-config
 ---
 # https://raw.githubusercontent.com/kubernetes/cloud-provider-openstack/refs/heads/master/manifests/controller-manager/cloud-controller-manager-role-bindings.yaml
+# NOTE! We need to "extract" the List or the CRS will fail to apply.
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRoleBinding
 metadata:
@@ -111,6 +114,7 @@ subjects:
   namespace: kube-system
 ---
 # https://raw.githubusercontent.com/kubernetes/cloud-provider-openstack/refs/heads/master/manifests/controller-manager/cloud-controller-manager-roles.yaml
+# NOTE! We need to "extract" the List or the CRS will fail to apply.
 apiVersion: rbac.authorization.k8s.io/v1
 kind: ClusterRole
 metadata:
diff --git a/test/e2e/data/e2e_conf.yaml b/test/e2e/data/e2e_conf.yaml
index ec208285..9914f0eb 100644
--- a/test/e2e/data/e2e_conf.yaml
+++ b/test/e2e/data/e2e_conf.yaml
@@ -170,6 +170,7 @@ providers:
     - sourcePath: "../data/shared/provider/metadata.yaml"
     - sourcePath: "./infrastructure-openstack-no-artifact/cluster-template.yaml"
     - sourcePath: "./infrastructure-openstack-no-artifact/cluster-template-without-lb.yaml"
+    - sourcePath: "./infrastructure-openstack-no-artifact/cluster-template-cluster-identity.yaml"
     replacements:
     - old: gcr.io/k8s-staging-capi-openstack/capi-openstack-controller:dev
       new: gcr.io/k8s-staging-capi-openstack/capi-openstack-controller:e2e
diff --git a/test/e2e/data/kustomize/capi-v1beta1/ccm.yaml b/test/e2e/data/kustomize/capi-v1beta1/ccm.yaml
index 9a0d6276..4f50319c 100644
--- a/test/e2e/data/kustomize/capi-v1beta1/ccm.yaml
+++ b/test/e2e/data/kustomize/capi-v1beta1/ccm.yaml
@@ -4,6 +4,22 @@ metadata:
   name: ccm-${CLUSTER_NAME}-crs-1
 data: ${CCM_RESOURCES}
 ---
+apiVersion: v1
+kind: Secret
+metadata:
+  name: ccm-${CLUSTER_NAME}-crs-1
+type: addons.cluster.x-k8s.io/resource-set
+stringData:
+  cloud-config-secret.yaml: |
+    apiVersion: v1
+    kind: Secret
+    metadata:
+      # This name is referenced in the CCM deployment manifest
+      name: cloud-config
+      namespace: kube-system
+    data:
+      cloud.conf: ${OPENSTACK_CLOUD_PROVIDER_CONF_B64}
+---
 apiVersion: addons.cluster.x-k8s.io/v1beta1
 kind: ClusterResourceSet
 metadata:
@@ -15,4 +31,6 @@ spec:
   resources:
   - kind: ConfigMap
     name: ccm-${CLUSTER_NAME}-crs-1
+  - kind: Secret
+    name: ccm-${CLUSTER_NAME}-crs-1
   strategy: ApplyOnce
diff --git a/test/e2e/data/kustomize/capi-v1beta1/patch-ccm-cloud-config.yaml b/test/e2e/data/kustomize/capi-v1beta1/patch-ccm-cloud-config.yaml
index b88d9eda..f12a04eb 100644
--- a/test/e2e/data/kustomize/capi-v1beta1/patch-ccm-cloud-config.yaml
+++ b/test/e2e/data/kustomize/capi-v1beta1/patch-ccm-cloud-config.yaml
@@ -1,14 +1,6 @@
 - op: add
   path: /spec/kubeadmConfigSpec/files
   value: []
-- op: add
-  path: /spec/kubeadmConfigSpec/files/-
-  value:
-    content: ${OPENSTACK_CLOUD_PROVIDER_CONF_B64}
-    encoding: base64
-    owner: root
-    path: /etc/kubernetes/cloud.conf
-    permissions: "0600"
 - op: add
   path: /spec/kubeadmConfigSpec/files/-
   value:
diff --git a/test/e2e/data/kustomize/cluster-identity/kustomization.yaml b/test/e2e/data/kustomize/cluster-identity/kustomization.yaml
new file mode 100644
index 00000000..276d952a
--- /dev/null
+++ b/test/e2e/data/kustomize/cluster-identity/kustomization.yaml
@@ -0,0 +1,13 @@
+apiVersion: kustomize.config.k8s.io/v1beta1
+kind: Kustomization
+
+resources:
+- ../default
+- openstackclusteridentity.yaml
+
+patches:
+- path: patch-openstackcluster-identityref.yaml
+  target:
+    kind: OpenStackCluster
+    name: \${CLUSTER_NAME}
+
diff --git a/test/e2e/data/kustomize/cluster-identity/openstackclusteridentity.yaml b/test/e2e/data/kustomize/cluster-identity/openstackclusteridentity.yaml
new file mode 100644
index 00000000..273249c7
--- /dev/null
+++ b/test/e2e/data/kustomize/cluster-identity/openstackclusteridentity.yaml
@@ -0,0 +1,9 @@
+apiVersion: infrastructure.cluster.x-k8s.io/v1alpha1
+kind: OpenStackClusterIdentity
+metadata:
+  name: ${CLUSTER_NAME}-cluster-identity
+spec:
+  secretRef:
+    name: ${CLUSTER_NAME}-cloud-config
+    namespace: ${NAMESPACE}
+
diff --git a/test/e2e/data/kustomize/cluster-identity/patch-openstackcluster-identityref.yaml b/test/e2e/data/kustomize/cluster-identity/patch-openstackcluster-identityref.yaml
new file mode 100644
index 00000000..eaa43cda
--- /dev/null
+++ b/test/e2e/data/kustomize/cluster-identity/patch-openstackcluster-identityref.yaml
@@ -0,0 +1,8 @@
+---
+- op: replace
+  path: /spec/identityRef
+  value:
+    type: ClusterIdentity
+    name: ${CLUSTER_NAME}-cluster-identity
+    cloudName: ${OPENSTACK_CLOUD}
+
diff --git a/test/e2e/data/kustomize/components/cluster-resource-sets/ccm.yaml b/test/e2e/data/kustomize/components/cluster-resource-sets/ccm.yaml
index f567cb5a..3ae3644d 100644
--- a/test/e2e/data/kustomize/components/cluster-resource-sets/ccm.yaml
+++ b/test/e2e/data/kustomize/components/cluster-resource-sets/ccm.yaml
@@ -4,6 +4,22 @@ metadata:
   name: ccm-${CLUSTER_NAME}-crs-1
 data: ${CCM_RESOURCES}
 ---
+apiVersion: v1
+kind: Secret
+metadata:
+  name: ccm-${CLUSTER_NAME}-crs-1
+type: addons.cluster.x-k8s.io/resource-set
+stringData:
+  cloud-config-secret.yaml: |
+    apiVersion: v1
+    kind: Secret
+    metadata:
+      # This name is referenced in the CCM deployment manifest
+      name: cloud-config
+      namespace: kube-system
+    data:
+      cloud.conf: ${OPENSTACK_CLOUD_PROVIDER_CONF_B64}
+---
 apiVersion: addons.cluster.x-k8s.io/v1beta2
 kind: ClusterResourceSet
 metadata:
@@ -15,4 +31,6 @@ spec:
   resources:
   - kind: ConfigMap
     name: ccm-${CLUSTER_NAME}-crs-1
+  - kind: Secret
+    name: ccm-${CLUSTER_NAME}-crs-1
   strategy: ApplyOnce
diff --git a/test/e2e/data/kustomize/components/cluster-resource-sets/patch-ccm-cloud-config.yaml b/test/e2e/data/kustomize/components/cluster-resource-sets/patch-ccm-cloud-config.yaml
index b88d9eda..f12a04eb 100644
--- a/test/e2e/data/kustomize/components/cluster-resource-sets/patch-ccm-cloud-config.yaml
+++ b/test/e2e/data/kustomize/components/cluster-resource-sets/patch-ccm-cloud-config.yaml
@@ -1,14 +1,6 @@
 - op: add
   path: /spec/kubeadmConfigSpec/files
   value: []
-- op: add
-  path: /spec/kubeadmConfigSpec/files/-
-  value:
-    content: ${OPENSTACK_CLOUD_PROVIDER_CONF_B64}
-    encoding: base64
-    owner: root
-    path: /etc/kubernetes/cloud.conf
-    permissions: "0600"
 - op: add
   path: /spec/kubeadmConfigSpec/files/-
   value:
diff --git a/test/e2e/shared/defaults.go b/test/e2e/shared/defaults.go
index ab57acd1..e14af489 100644
--- a/test/e2e/shared/defaults.go
+++ b/test/e2e/shared/defaults.go
@@ -63,6 +63,7 @@ const (
 	FlavorFlatcarSysext        = "flatcar-sysext"
 	FlavorHealthMonitor        = "health-monitor"
 	FlavorCapiV1Beta1          = "capi-v1beta1"
+	FlavorClusterIdentity      = "cluster-identity"
 )
 
 // DefaultScheme returns the default scheme to use for testing.
diff --git a/test/e2e/suites/apivalidations/filters_test.go b/test/e2e/suites/apivalidations/filters_test.go
index 9814b5f8..7ca4fc73 100644
--- a/test/e2e/suites/apivalidations/filters_test.go
+++ b/test/e2e/suites/apivalidations/filters_test.go
@@ -49,6 +49,11 @@ var _ = Describe("Filter API validations", func() {
 		cluster = &infrav1.OpenStackCluster{}
 		cluster.Namespace = namespace.Name
 		cluster.GenerateName = clusterNamePrefix
+		// Provide a minimal valid IdentityRef by default so cluster create succeeds
+		cluster.Spec.IdentityRef = infrav1.OpenStackIdentityReference{
+			Name:      "creds",
+			CloudName: "openstack",
+		}
 	})
 
 	DescribeTable("Allow valid neutron filter tags", func(tags []infrav1.FilterByNeutronTags) {
diff --git a/test/e2e/suites/apivalidations/openstackcluster_test.go b/test/e2e/suites/apivalidations/openstackcluster_test.go
index 6d4bf186..f78401b4 100644
--- a/test/e2e/suites/apivalidations/openstackcluster_test.go
+++ b/test/e2e/suites/apivalidations/openstackcluster_test.go
@@ -47,6 +47,11 @@ var _ = Describe("OpenStackCluster API validations", func() {
 			cluster = &infrav1.OpenStackCluster{}
 			cluster.Namespace = namespace.Name
 			cluster.GenerateName = clusterNamePrefix
+			// Provide a minimal valid IdentityRef by default so success-case tests can create the cluster
+			cluster.Spec.IdentityRef = infrav1.OpenStackIdentityReference{
+				Name:      "creds",
+				CloudName: "openstack",
+			}
 		})
 
 		It("should allow the smallest permissible cluster spec", func() {
@@ -235,5 +240,118 @@ var _ = Describe("OpenStackCluster API validations", func() {
 			u := unstructuredClusterWithAPIPort(math.MaxUint16)
 			Expect(createObj(u)).To(Succeed(), "OpenStackCluster creation should succeed")
 		})
+
+		// OpenStackIdentityReference validation tests
+		It("should reject when identityRef.name is missing", func() {
+			cluster.Spec.IdentityRef = infrav1.OpenStackIdentityReference{
+				CloudName: "openstack",
+				// Name missing
+			}
+			Expect(createObj(cluster)).NotTo(Succeed(), "OpenStackCluster creation should fail when identityRef.name is missing")
+		})
+
+		It("should reject when identityRef.cloudName is missing", func() {
+			cluster.Spec.IdentityRef = infrav1.OpenStackIdentityReference{
+				Name: "creds",
+				// CloudName missing
+			}
+			Expect(createObj(cluster)).NotTo(Succeed(), "OpenStackCluster creation should fail when identityRef.cloudName is missing")
+		})
+
+		It("should default identityRef.type to Secret when omitted", func() {
+			cluster.Spec.IdentityRef = infrav1.OpenStackIdentityReference{
+				Name:      "creds",
+				CloudName: "openstack",
+				// Type omitted -> should default to Secret
+			}
+			Expect(createObj(cluster)).To(Succeed(), "OpenStackCluster creation should succeed")
+
+			fetched := &infrav1.OpenStackCluster{}
+			Expect(k8sClient.Get(ctx, types.NamespacedName{Name: cluster.Name, Namespace: cluster.Namespace}, fetched)).To(Succeed(), "OpenStackCluster fetch should succeed")
+			Expect(fetched.Spec.IdentityRef.Type).To(Equal("Secret"), "identityRef.type should default to Secret")
+		})
+
+		It("should reject updates that modify identityRef.region (immutable)", func() {
+			cluster.Spec.IdentityRef = infrav1.OpenStackIdentityReference{
+				Name:      "creds",
+				CloudName: "openstack",
+				Region:    "RegionOne",
+			}
+			Expect(createObj(cluster)).To(Succeed(), "OpenStackCluster creation should succeed")
+
+			// Attempt to change region
+			cluster.Spec.IdentityRef.Region = "RegionTwo"
+			Expect(k8sClient.Update(ctx, cluster)).NotTo(Succeed(), "Updating identityRef.region should fail")
+		})
+
+		It("should reject updates that set identityRef.region when previously unset", func() {
+			cluster.Spec.IdentityRef = infrav1.OpenStackIdentityReference{
+				Name:      "creds",
+				CloudName: "openstack",
+				// Region omitted initially
+			}
+			Expect(createObj(cluster)).To(Succeed(), "OpenStackCluster creation should succeed")
+
+			// Attempt to set region after creation
+			cluster.Spec.IdentityRef.Region = "RegionOne"
+			Expect(k8sClient.Update(ctx, cluster)).NotTo(Succeed(), "Setting identityRef.region post-creation should fail")
+		})
+
+		It("should accept identityRef.type=ClusterIdentity with required fields", func() {
+			cluster.Spec.IdentityRef = infrav1.OpenStackIdentityReference{
+				Type:      "ClusterIdentity",
+				Name:      "prod-id",
+				CloudName: "openstack",
+			}
+			Expect(createObj(cluster)).To(Succeed(), "OpenStackCluster creation should succeed for ClusterIdentity type")
+		})
+
+		// Edge case tests
+
+		It("should reject when identityRef is completely missing", func() {
+			// Explicitly clear identityRef to simulate missing values
+			cluster.Spec.IdentityRef = infrav1.OpenStackIdentityReference{}
+			Expect(createObj(cluster)).NotTo(Succeed(), "OpenStackCluster creation should fail when identityRef is missing")
+		})
+
+		It("should reject when identityRef.name is empty string", func() {
+			cluster.Spec.IdentityRef = infrav1.OpenStackIdentityReference{
+				Name:      "", // Empty string
+				CloudName: "openstack",
+			}
+			Expect(createObj(cluster)).NotTo(Succeed(), "OpenStackCluster creation should fail when identityRef.name is empty")
+		})
+
+		It("should reject when identityRef.cloudName is empty string", func() {
+			cluster.Spec.IdentityRef = infrav1.OpenStackIdentityReference{
+				Name:      "creds",
+				CloudName: "", // Empty string
+			}
+			Expect(createObj(cluster)).NotTo(Succeed(), "OpenStackCluster creation should fail when identityRef.cloudName is empty")
+		})
+
+		It("should reject invalid identityRef.type value", func() {
+			// Need to use unstructured since Go types won't allow invalid enum
+			obj, err := runtime.DefaultUnstructuredConverter.ToUnstructured(cluster)
+			Expect(err).NotTo(HaveOccurred(), "converting cluster to unstructured")
+
+			u := &unstructured.Unstructured{}
+			u.Object = obj
+			u.SetGroupVersionKind(schema.GroupVersionKind{
+				Group:   infrav1.SchemeGroupVersion.Group,
+				Version: infrav1.SchemeGroupVersion.Version,
+				Kind:    "OpenStackCluster",
+			})
+
+			// Set invalid type
+			spec := obj["spec"].(map[string]any)
+			spec["identityRef"] = map[string]any{
+				"type":      "InvalidType",
+				"name":      "creds",
+				"cloudName": "openstack",
+			}
+
+			Expect(createObj(u)).NotTo(Succeed(), "OpenStackCluster creation should fail with invalid identityRef.type")
+		})
 	})
 })
diff --git a/test/e2e/suites/e2e/e2e_test.go b/test/e2e/suites/e2e/e2e_test.go
index 6186fb59..3fbdd486 100644
--- a/test/e2e/suites/e2e/e2e_test.go
+++ b/test/e2e/suites/e2e/e2e_test.go
@@ -331,6 +331,33 @@ var _ = Describe("e2e tests [PR-Blocking]", func() {
 		})
 	})
 
+	Describe("Workload cluster (cluster-identity)", func() {
+		It("should be creatable and deletable", func(ctx context.Context) {
+			shared.Logf("Creating a cluster with ClusterIdentity")
+			clusterName := fmt.Sprintf("cluster-%s", namespace.Name)
+			configCluster := defaultConfigCluster(clusterName, namespace.Name)
+			configCluster.ControlPlaneMachineCount = ptr.To(int64(1))
+			configCluster.WorkerMachineCount = ptr.To(int64(1))
+			configCluster.Flavor = shared.FlavorClusterIdentity
+			createCluster(ctx, configCluster, clusterResources)
+
+			md := clusterResources.MachineDeployments
+			workerMachines := framework.GetMachinesByMachineDeployments(ctx, framework.GetMachinesByMachineDeploymentsInput{
+				Lister:            e2eCtx.Environment.BootstrapClusterProxy.GetClient(),
+				ClusterName:       clusterName,
+				Namespace:         namespace.Name,
+				MachineDeployment: *md[0],
+			})
+			controlPlaneMachines := framework.GetControlPlaneMachinesByCluster(ctx, framework.GetControlPlaneMachinesByClusterInput{
+				Lister:      e2eCtx.Environment.BootstrapClusterProxy.GetClient(),
+				ClusterName: clusterName,
+				Namespace:   namespace.Name,
+			})
+			Expect(workerMachines).To(HaveLen(int(*configCluster.WorkerMachineCount)))
+			Expect(controlPlaneMachines).To(HaveLen(int(*configCluster.ControlPlaneMachineCount)))
+		})
+	})
+
 	Describe("Workload cluster (no bastion)", func() {
 		It("should be creatable and deletable", func(ctx context.Context) {
 			shared.Logf("Creating a cluster")