Repository: kubernetes-sigs/cluster-api-provider-azure
Pull request #5421: Add new authentication type UserAssignedIdentityCredentials
Author: login: bryan-cox
Labels: name: kind/feature
description: Categorizes issue or PR as related to a new feature.
name: cncf-cla: yes
description: Indicates the PR's author has signed the CNCF CLA.
name: size/L
description: Denotes a PR that changes 100-499 lines, ignoring generated files.
name: release-note
description: Denotes a PR that will be considered when it comes time to generate release notes.
name: approved
description: Indicates a PR has been approved by an approver from all required OWNERS files.
name: lgtm
description: "Looks good to me", indicates that a PR is ready to be merged.
Created: 2025-02-10T17:42:52Z | Merged: 2025-03-10T16:57:47Z

**What type of PR is this?**
/kind feature

**What this PR does / why we need it**:
This pull request adds the capability to authenticate with Azure using a new authentication type available only to 1st party Microsoft applications, UserAssignedIdentityCredentials. More information on this new authentication type can be found here - https://github.com/Azure/msi-dataplane/blob/main/pkg/dataplane/reloadCredentials.go#L60.

**Which issue(s) this PR fixes** *(optional, in `fixes #<issue number>(, fixes #<issue_number>, ...)` format, will close the issue(s) when PR gets merged)*:
N/A

**Special notes for your reviewer**:
<!-- Refer to https://github.com/kubernetes-sigs/cluster-api-provider-azure/blob/main/docs/book/src/developers/releasing.md#release-support for more information about which changes are eligible for backport -->


**TODOs**:

- [ ] squashed commits
- [x] includes documentation
- [ ] adds unit tests
- [ ] cherry-pick candidate

**Release note**:
```release-note
Adds a new authentication type for 1st party Microsoft applications - UserAssignedIdentityCredentials
```



Diff:
diff --git a/api/v1beta1/azureclusteridentity_types.go b/api/v1beta1/azureclusteridentity_types.go
index fae9ecbd9ab..62ab41f0bd7 100644
--- a/api/v1beta1/azureclusteridentity_types.go
+++ b/api/v1beta1/azureclusteridentity_types.go
@@ -44,7 +44,7 @@ type AllowedNamespaces struct {
 // AzureClusterIdentitySpec defines the parameters that are used to create an AzureIdentity.
 type AzureClusterIdentitySpec struct {
 	// Type is the type of Azure Identity used.
-	// ServicePrincipal, ServicePrincipalCertificate, UserAssignedMSI, ManualServicePrincipal or WorkloadIdentity.
+	// ServicePrincipal, ServicePrincipalCertificate, UserAssignedMSI, ManualServicePrincipal, UserAssignedIdentityCredential, or WorkloadIdentity.
 	Type IdentityType `json:"type"`
 	// ResourceID is the Azure resource ID for the User Assigned MSI resource.
 	// Only applicable when type is UserAssignedMSI.
@@ -62,6 +62,16 @@ type AzureClusterIdentitySpec struct {
 	// CertPath is the path where certificates exist. When set, it takes precedence over ClientSecret for types that use certs like ServicePrincipalCertificate.
 	// +optional
 	CertPath string `json:"certPath,omitempty"`
+	// UserAssignedIdentityCredentialsPath is the path where an existing JSON file exists containing the JSON format of
+	// a UserAssignedIdentityCredentials struct.
+	// See the msi-dataplane for more details on UserAssignedIdentityCredentials - https://github.com/Azure/msi-dataplane/blob/main/pkg/dataplane/internal/client/models.go#L125
+	// +optional
+	UserAssignedIdentityCredentialsPath string `json:"userAssignedIdentityCredentialsPath,omitempty"`
+	// UserAssignedIdentityCredentialsCloudType is used with UserAssignedIdentityCredentialsPath to specify the Cloud
+	// type. Can only be one of the following values: public, china, or usgovernment
+	// If a value is not specified, defaults to public
+	// +optional
+	UserAssignedIdentityCredentialsCloudType string `json:"userAssignedIdentityCredentialsCloudType,omitempty"`
 	// TenantID is the service principal primary tenant id.
 	TenantID string `json:"tenantID"`
 	// AllowedNamespaces is used to identify the namespaces the clusters are allowed to use the identity from.
diff --git a/api/v1beta1/azureclusteridentity_validation.go b/api/v1beta1/azureclusteridentity_validation.go
index c679052d5f6..51b1842e542 100644
--- a/api/v1beta1/azureclusteridentity_validation.go
+++ b/api/v1beta1/azureclusteridentity_validation.go
@@ -17,6 +17,8 @@ limitations under the License.
 package v1beta1
 
 import (
+	"fmt"
+
 	apierrors "k8s.io/apimachinery/pkg/api/errors"
 	"k8s.io/apimachinery/pkg/util/validation/field"
 	"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
@@ -27,6 +29,10 @@ func (c *AzureClusterIdentity) validateClusterIdentity() (admission.Warnings, er
 	if c.Spec.Type != UserAssignedMSI && c.Spec.ResourceID != "" {
 		allErrs = append(allErrs, field.Forbidden(field.NewPath("spec", "resourceID"), c.Spec.ResourceID))
 	}
+	if c.Spec.Type != UserAssignedIdentityCredential && (c.Spec.UserAssignedIdentityCredentialsPath != "" || c.Spec.UserAssignedIdentityCredentialsCloudType != "") {
+		allErrs = append(allErrs, field.Forbidden(field.NewPath("spec", "userAssignedIdentityCredentialsPath"), fmt.Sprintf("%s can only be set when AzureClusterIdentity is of type UserAssignedIdentityCredential", c.Spec.UserAssignedIdentityCredentialsPath)))
+		allErrs = append(allErrs, field.Forbidden(field.NewPath("spec", "userAssignedIdentityCredentialsCloudType"), fmt.Sprintf("%s can only be set when AzureClusterIdentity is of type UserAssignedIdentityCredential ", c.Spec.UserAssignedIdentityCredentialsCloudType)))
+	}
 	if len(allErrs) == 0 {
 		return nil, nil
 	}
diff --git a/api/v1beta1/types.go b/api/v1beta1/types.go
index 1dc1d82d48b..0b975e5476f 100644
--- a/api/v1beta1/types.go
+++ b/api/v1beta1/types.go
@@ -570,7 +570,7 @@ type UserAssignedIdentity struct {
 }
 
 // IdentityType represents different types of identities.
-// +kubebuilder:validation:Enum=ServicePrincipal;UserAssignedMSI;ManualServicePrincipal;ServicePrincipalCertificate;WorkloadIdentity
+// +kubebuilder:validation:Enum=ServicePrincipal;UserAssignedMSI;ManualServicePrincipal;ServicePrincipalCertificate;WorkloadIdentity;UserAssignedIdentityCredential
 type IdentityType string
 
 const (
@@ -588,6 +588,9 @@ const (
 
 	// WorkloadIdentity represents a WorkloadIdentity.
 	WorkloadIdentity IdentityType = "WorkloadIdentity"
+
+	// UserAssignedIdentityCredential represents a UserAssignedIdentityCredential.
+	UserAssignedIdentityCredential IdentityType = "UserAssignedIdentityCredential"
 )
 
 // OSDisk defines the operating system disk for a VM.
diff --git a/azure/credential_cache.go b/azure/credential_cache.go
index f35fa4c0a19..e47f5323edb 100644
--- a/azure/credential_cache.go
+++ b/azure/credential_cache.go
@@ -17,10 +17,13 @@ limitations under the License.
 package azure
 
 import (
+	"context"
 	"sync"
 
 	"github.com/Azure/azure-sdk-for-go/sdk/azcore"
 	"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
+	"github.com/Azure/msi-dataplane/pkg/dataplane"
+	"github.com/go-logr/logr"
 )
 
 type credentialCache struct {
@@ -34,6 +37,7 @@ type credentialFactory interface {
 	newClientCertificateCredential(tenantID string, clientID string, clientCertificate []byte, clientCertificatePassword []byte, opts *azidentity.ClientCertificateCredentialOptions) (azcore.TokenCredential, error)
 	newManagedIdentityCredential(opts *azidentity.ManagedIdentityCredentialOptions) (azcore.TokenCredential, error)
 	newWorkloadIdentityCredential(opts *azidentity.WorkloadIdentityCredentialOptions) (azcore.TokenCredential, error)
+	newUserAssignedManagedIdentityCredentials(ctx context.Context, credsPath string, opts azcore.ClientOptions, logger *logr.Logger) (azcore.TokenCredential, error)
 }
 
 // CredentialType represents the auth mechanism in use.
@@ -48,6 +52,8 @@ const (
 	CredentialTypeManagedIdentity
 	// CredentialTypeWorkloadIdentity is for Workload Identity.
 	CredentialTypeWorkloadIdentity
+	// CredentialTypeUserAssignedManagedIdentity is for User Assigned Managed Identity Credentials.
+	CredentialTypeUserAssignedManagedIdentity
 )
 
 type credentialCacheKey struct {
@@ -125,6 +131,18 @@ func (c *credentialCache) GetOrStoreWorkloadIdentity(opts *azidentity.WorkloadId
 	)
 }
 
+func (c *credentialCache) GetOrStoreUserAssignedManagedIdentityCredentials(ctx context.Context, credsPath string, opts azcore.ClientOptions, logger *logr.Logger) (azcore.TokenCredential, error) {
+	return c.getOrStore(
+		credentialCacheKey{
+			authorityHost:  opts.Cloud.ActiveDirectoryAuthorityHost,
+			credentialType: CredentialTypeUserAssignedManagedIdentity,
+		},
+		func() (azcore.TokenCredential, error) {
+			return c.credFactory.newUserAssignedManagedIdentityCredentials(ctx, credsPath, opts, logger)
+		},
+	)
+}
+
 func (c *credentialCache) getOrStore(key credentialCacheKey, newCredFunc func() (azcore.TokenCredential, error)) (azcore.TokenCredential, error) {
 	c.mut.Lock()
 	defer c.mut.Unlock()
@@ -160,3 +178,7 @@ func (azureCredentialFactory) newManagedIdentityCredential(opts *azidentity.Mana
 func (azureCredentialFactory) newWorkloadIdentityCredential(opts *azidentity.WorkloadIdentityCredentialOptions) (azcore.TokenCredential, error) {
 	return azidentity.NewWorkloadIdentityCredential(opts)
 }
+
+func (azureCredentialFactory) newUserAssignedManagedIdentityCredentials(ctx context.Context, credsPath string, opts azcore.ClientOptions, logger *logr.Logger) (azcore.TokenCredential, error) {
+	return dataplane.NewUserAssignedIdentityCredential(ctx, credsPath, dataplane.WithClientOpts(opts), dataplane.WithLogger(logger))
+}
diff --git a/azure/interfaces.go b/azure/interfaces.go
index 68b8175ce0a..009670ad422 100644
--- a/azure/interfaces.go
+++ b/azure/interfaces.go
@@ -23,6 +23,7 @@ import (
 	"github.com/Azure/azure-sdk-for-go/sdk/azcore"
 	"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
 	"github.com/Azure/azure-service-operator/v2/pkg/genruntime"
+	"github.com/go-logr/logr"
 	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1"
 	"sigs.k8s.io/controller-runtime/pkg/client"
@@ -172,4 +173,5 @@ type CredentialCache interface {
 	GetOrStoreClientCert(tenantID, clientID string, cert, certPassword []byte, opts *azidentity.ClientCertificateCredentialOptions) (azcore.TokenCredential, error)
 	GetOrStoreManagedIdentity(opts *azidentity.ManagedIdentityCredentialOptions) (azcore.TokenCredential, error)
 	GetOrStoreWorkloadIdentity(opts *azidentity.WorkloadIdentityCredentialOptions) (azcore.TokenCredential, error)
+	GetOrStoreUserAssignedManagedIdentityCredentials(ctx context.Context, credsPath string, opts azcore.ClientOptions, logger *logr.Logger) (azcore.TokenCredential, error)
 }
diff --git a/azure/mock_azure/azure_mock.go b/azure/mock_azure/azure_mock.go
index 7fa6c63bad3..2e1542f0db9 100644
--- a/azure/mock_azure/azure_mock.go
+++ b/azure/mock_azure/azure_mock.go
@@ -33,6 +33,7 @@ import (
 	azcore "github.com/Azure/azure-sdk-for-go/sdk/azcore"
 	azidentity "github.com/Azure/azure-sdk-for-go/sdk/azidentity"
 	genruntime "github.com/Azure/azure-service-operator/v2/pkg/genruntime"
+	logr "github.com/go-logr/logr"
 	gomock "go.uber.org/mock/gomock"
 	v1 "k8s.io/apimachinery/pkg/apis/meta/v1"
 	v1beta1 "sigs.k8s.io/cluster-api-provider-azure/api/v1beta1"
@@ -2360,6 +2361,21 @@ func (mr *MockCredentialCacheMockRecorder) GetOrStoreManagedIdentity(opts any) *
 	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrStoreManagedIdentity", reflect.TypeOf((*MockCredentialCache)(nil).GetOrStoreManagedIdentity), opts)
 }
 
+// GetOrStoreUserAssignedManagedIdentityCredentials mocks base method.
+func (m *MockCredentialCache) GetOrStoreUserAssignedManagedIdentityCredentials(ctx context.Context, credsPath string, opts azcore.ClientOptions, logger *logr.Logger) (azcore.TokenCredential, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "GetOrStoreUserAssignedManagedIdentityCredentials", ctx, credsPath, opts, logger)
+	ret0, _ := ret[0].(azcore.TokenCredential)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// GetOrStoreUserAssignedManagedIdentityCredentials indicates an expected call of GetOrStoreUserAssignedManagedIdentityCredentials.
+func (mr *MockCredentialCacheMockRecorder) GetOrStoreUserAssignedManagedIdentityCredentials(ctx, credsPath, opts, logger any) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetOrStoreUserAssignedManagedIdentityCredentials", reflect.TypeOf((*MockCredentialCache)(nil).GetOrStoreUserAssignedManagedIdentityCredentials), ctx, credsPath, opts, logger)
+}
+
 // GetOrStoreWorkloadIdentity mocks base method.
 func (m *MockCredentialCache) GetOrStoreWorkloadIdentity(opts *azidentity.WorkloadIdentityCredentialOptions) (azcore.TokenCredential, error) {
 	m.ctrl.T.Helper()
diff --git a/azure/scope/identity.go b/azure/scope/identity.go
index 180b1c1fe81..5df61a59e5f 100644
--- a/azure/scope/identity.go
+++ b/azure/scope/identity.go
@@ -20,6 +20,7 @@ import (
 	"context"
 	"os"
 	"reflect"
+	"strings"
 
 	"github.com/Azure/azure-sdk-for-go/sdk/azcore"
 	"github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud"
@@ -159,6 +160,17 @@ func (p *AzureCredentialsProvider) GetTokenCredential(ctx context.Context, resou
 		}
 		cred, authErr = p.cache.GetOrStoreManagedIdentity(&options)
 
+	case infrav1.UserAssignedIdentityCredential:
+		cloudType := parseCloudType(p.Identity.Spec.UserAssignedIdentityCredentialsCloudType)
+		clientOptions := azcore.ClientOptions{
+			TracingProvider: tracingProvider,
+			Cloud:           cloudType,
+		}
+		// Using context.Background() here as something is cancelling the context prematurely in the ctx passed into the
+		// function. This authentication method is long lived and has built in capabilities to rotate the certificates
+		// when they change.
+		cred, authErr = p.cache.GetOrStoreUserAssignedManagedIdentityCredentials(context.Background(), p.Identity.Spec.UserAssignedIdentityCredentialsPath, clientOptions, &log)
+
 	default:
 		return nil, errors.Errorf("identity type %s not supported", p.Identity.Spec.Type)
 	}
@@ -259,3 +271,17 @@ func IsClusterNamespaceAllowed(ctx context.Context, k8sClient client.Client, all
 
 	return false
 }
+
+func parseCloudType(cloudType string) cloud.Configuration {
+	cloudType = strings.ToUpper(cloudType)
+	switch cloudType {
+	case "PUBLIC":
+		return cloud.AzurePublic
+	case "CHINA":
+		return cloud.AzureChina
+	case "USGOVERNMENT":
+		return cloud.AzureGovernment
+	default:
+		return cloud.AzurePublic
+	}
+}
diff --git a/azure/scope/identity_test.go b/azure/scope/identity_test.go
index 9cb9a66c8fe..4d125f4b07d 100644
--- a/azure/scope/identity_test.go
+++ b/azure/scope/identity_test.go
@@ -22,6 +22,7 @@ import (
 	"reflect"
 	"testing"
 
+	"github.com/Azure/azure-sdk-for-go/sdk/azcore"
 	"github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud"
 	"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
 	. "github.com/onsi/gomega"
@@ -419,6 +420,45 @@ func TestGetTokenCredential(t *testing.T) {
 				}))
 			},
 		},
+		{
+			name: "UserAssignedIdentityCredential",
+			cluster: &infrav1.AzureCluster{
+				Spec: infrav1.AzureClusterSpec{
+					AzureClusterClassSpec: infrav1.AzureClusterClassSpec{
+						IdentityRef: &corev1.ObjectReference{
+							Kind: infrav1.AzureClusterIdentityKind,
+						},
+					},
+				},
+			},
+			identity: &infrav1.AzureClusterIdentity{
+				Spec: infrav1.AzureClusterIdentitySpec{
+					Type:                                     infrav1.UserAssignedIdentityCredential,
+					UserAssignedIdentityCredentialsPath:      "../../test/setup/credentials.json",
+					UserAssignedIdentityCredentialsCloudType: "public",
+				},
+			},
+			cacheExpect: func(cache *mock_azure.MockCredentialCache) {
+				ctx := context.Background()
+				credsPath := "../../test/setup/credentials.json" //nolint:gosec
+				clientOptions := azcore.ClientOptions{
+					Cloud: cloud.Configuration{
+						ActiveDirectoryAuthorityHost: "https://login.microsoftonline.com/",
+						Services: map[cloud.ServiceName]cloud.ServiceConfiguration{
+							cloud.ResourceManager: {
+								Audience: "https://management.core.windows.net/",
+								Endpoint: "https://management.azure.com",
+							},
+						},
+					},
+				}
+				cache.EXPECT().GetOrStoreUserAssignedManagedIdentityCredentials(ctx, credsPath, gomock.Cond(func(opts azcore.ClientOptions) bool {
+					return opts.Cloud.ActiveDirectoryAuthorityHost == clientOptions.Cloud.ActiveDirectoryAuthorityHost &&
+						opts.Cloud.Services[cloud.ResourceManager].Audience == clientOptions.Cloud.Services[cloud.ResourceManager].Audience &&
+						opts.Cloud.Services[cloud.ResourceManager].Endpoint == clientOptions.Cloud.Services[cloud.ResourceManager].Endpoint
+				}), gomock.Any())
+			},
+		},
 	}
 
 	scheme := runtime.NewScheme()
@@ -448,3 +488,45 @@ func TestGetTokenCredential(t *testing.T) {
 		})
 	}
 }
+
+func TestParseCloudType(t *testing.T) {
+	tests := []struct {
+		name     string
+		input    string
+		expected cloud.Configuration
+	}{
+		{
+			name:     "when the input is public, expect AzurePublic",
+			input:    "public",
+			expected: cloud.AzurePublic,
+		},
+		{
+			name:     "when the input is China, expect AzureChina",
+			input:    "china",
+			expected: cloud.AzureChina,
+		},
+		{
+			name:     "when the input is usgovernment, expect AzureGovernment",
+			input:    "usgovernment",
+			expected: cloud.AzureGovernment,
+		},
+		{
+			name:     "when the input is empty, expect AzurePublic",
+			input:    "", // Test case for default value
+			expected: cloud.AzurePublic,
+		},
+		{
+			name:     "when the input is PUBLIC, expect AzurePublic",
+			input:    "PUBLIC", // Test case for uppercased input
+			expected: cloud.AzurePublic,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			t.Parallel()
+			g := NewWithT(t)
+			g.Expect(parseCloudType(tt.input)).To(Equal(tt.expected))
+		})
+	}
+}
diff --git a/config/crd/bases/infrastructure.cluster.x-k8s.io_azureclusteridentities.yaml b/config/crd/bases/infrastructure.cluster.x-k8s.io_azureclusteridentities.yaml
index 91d39c5588c..184b220f679 100644
--- a/config/crd/bases/infrastructure.cluster.x-k8s.io_azureclusteridentities.yaml
+++ b/config/crd/bases/infrastructure.cluster.x-k8s.io_azureclusteridentities.yaml
@@ -159,13 +159,26 @@ spec:
               type:
                 description: |-
                   Type is the type of Azure Identity used.
-                  ServicePrincipal, ServicePrincipalCertificate, UserAssignedMSI, ManualServicePrincipal or WorkloadIdentity.
+                  ServicePrincipal, ServicePrincipalCertificate, UserAssignedMSI, ManualServicePrincipal, UserAssignedIdentityCredential, or WorkloadIdentity.
                 enum:
                 - ServicePrincipal
                 - UserAssignedMSI
                 - ManualServicePrincipal
                 - ServicePrincipalCertificate
                 - WorkloadIdentity
+                - UserAssignedIdentityCredential
+                type: string
+              userAssignedIdentityCredentialsCloudType:
+                description: |-
+                  UserAssignedIdentityCredentialsCloudType is used with UserAssignedIdentityCredentialsPath to specify the Cloud
+                  type. Can only be one of the following values: public, china, or usgovernment
+                  If a value is not specified, defaults to public
+                type: string
+              userAssignedIdentityCredentialsPath:
+                description: |-
+                  UserAssignedIdentityCredentialsPath is the path where an existing JSON file exists containing the JSON format of
+                  a UserAssignedIdentityCredentials struct.
+                  See the msi-dataplane for more details on UserAssignedIdentityCredentials - https://github.com/Azure/msi-dataplane/blob/main/pkg/dataplane/internal/client/models.go#L125
                 type: string
             required:
             - clientID
diff --git a/docs/book/src/topics/identities.md b/docs/book/src/topics/identities.md
index befc763f505..205dfbb678f 100644
--- a/docs/book/src/topics/identities.md
+++ b/docs/book/src/topics/identities.md
@@ -182,6 +182,67 @@ spec:
 
 When using a user-assigned managed identity to create the workload cluster, a VM identity should also be assigned to each control plane machine in the workload cluster for Azure Cloud Provider to use. See [here](../self-managed/vm-identity.md#managed-identities) for more information.
 
+## User-Assigned Identity Credentials
+
+<aside class="note">
+
+<h1> Note </h1>
+
+This option is only available for 1st party Microsoft applications who have access to the msi data-plane.
+
+</aside>
+
+#### General
+This authentication type is similar to user assigned managed identity authentication combined with client certificate
+authentication. As a 1st party Microsoft application, one has access to pull a user assigned managed identity's backing
+certificate information from the MSI data plane. Using this data, a user can authenticate to Azure Cloud.
+
+#### Prerequisites
+A JSON file with information from the user assigned managed identity. It should be in this format:
+```json
+        {
+            "client_id": "0998...",
+            "client_secret": "MIIKUA...",
+            "client_secret_url": "https://control...",
+            "tenant_id": "93b...",
+            "object_id": "ae...",
+            "resource_id": "/subscriptions/...",
+            "authentication_endpoint": "https://login.microsoftonline.com/",
+            "mtls_authentication_endpoint": "https://login.microsoftonline.com/",
+            "not_before": "2025-02-07T13:29:00Z",
+            "not_after": "2025-05-08T13:29:00Z",
+            "renew_after": "2025-03-25T13:29:00Z",
+            "cannot_renew_after": "2025-08-06T13:29:00Z"
+        }
+```
+
+Note, the client secret should be a base64 encoded certificate.
+
+The steps to get this information from the MSI data plane are as follows:
+1. Make an unauthenticated GET or POST (no Authorization request headers) on the x-ms-identity-url received from ARM to get the token authority and, on older api versions, resource.
+2. Get an Access Token from Azure AD using your Resource Provider applicationId and Certificate. The applicationId should match the one you added to your manifest. The response should give you an access token.
+3. Perform a GET or POST to MSI on the same URL from earlier to get the Credentials using this bearer token.
+
+#### Creating the AzureClusterIdentity
+
+The corresponding values should be used to create an `AzureClusterIdentity` resource:
+
+```yaml
+apiVersion: infrastructure.cluster.x-k8s.io/v1beta1
+kind: AzureClusterIdentity
+metadata:
+  name: example-identity
+  namespace: default
+spec:
+  type: UserAssignedIdentityCredential
+  tenantID: <azure-tenant-id>
+  clientID: <client-id-of-user-assigned-identity>
+  userAssignedIdentityCredentialsPath: <path-to-JSON-file-with-mi-certifcate-information>
+  userAssignedIdentityCredentialsCloudType: "public"
+  allowedNamespaces:
+    list:
+    - <cluster-namespace>
+```
 
 ## Azure Host Identity
 
diff --git a/go.mod b/go.mod
index 422afee293e..9d3c0af36bc 100644
--- a/go.mod
+++ b/go.mod
@@ -21,6 +21,7 @@ require (
 	github.com/Azure/azure-service-operator/v2 v2.11.0
 	github.com/Azure/go-autorest/autorest v0.11.30
 	github.com/Azure/go-autorest/autorest/azure/auth v0.5.13
+	github.com/Azure/msi-dataplane v0.4.0
 	github.com/asaskevich/govalidator/v11 v11.0.2-0.20250122183457-e11347878e23
 	github.com/blang/semver v3.5.1+incompatible
 	github.com/go-logr/logr v1.4.2
@@ -71,7 +72,7 @@ require (
 	github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.4.0 // indirect
 	github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.6.0 // indirect
 	github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
-	github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
+	github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
 	github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
 	github.com/fxamacker/cbor/v2 v2.7.0 // indirect
 	github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
@@ -131,7 +132,7 @@ require (
 	github.com/evanphx/json-patch/v5 v5.9.11
 	github.com/fatih/camelcase v1.0.0 // indirect
 	github.com/felixge/httpsnoop v1.0.4 // indirect
-	github.com/fsnotify/fsnotify v1.7.0 // indirect
+	github.com/fsnotify/fsnotify v1.8.0 // indirect
 	github.com/go-errors/errors v1.4.2 // indirect
 	github.com/go-logr/stdr v1.2.2 // indirect
 	github.com/go-openapi/jsonpointer v0.21.0 // indirect
diff --git a/go.sum b/go.sum
index f226c755408..622723321d0 100644
--- a/go.sum
+++ b/go.sum
@@ -117,6 +117,8 @@ github.com/Azure/go-autorest/logger v0.2.1 h1:IG7i4p/mDa2Ce4TRyAO8IHnVhAVF3RFU+Z
 github.com/Azure/go-autorest/logger v0.2.1/go.mod h1:T9E3cAhj2VqvPOtCYAvby9aBXkZmbF5NWuPV8+WeEW8=
 github.com/Azure/go-autorest/tracing v0.6.0 h1:TYi4+3m5t6K48TGI9AUdb+IzbnSxvnvUMfuitfgcfuo=
 github.com/Azure/go-autorest/tracing v0.6.0/go.mod h1:+vhtPC754Xsa23ID7GlGsrdKBpUA79WCAKPPZVC2DeU=
+github.com/Azure/msi-dataplane v0.4.0 h1:lofdhX74IgMEvIe6cw8tdwr3zfZkdk77TXetFv0/By8=
+github.com/Azure/msi-dataplane v0.4.0/go.mod h1:y+euhWbc8/wgVM1hyJLQf4DnByegnwxAcDV0OKNM9+k=
 github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1 h1:WJTmL004Abzc5wDB5VtZG2PJk5ndYDgVacGqfirKxjM=
 github.com/AzureAD/microsoft-authentication-extensions-for-go/cache v0.1.1/go.mod h1:tCcJZ0uHAmvjsVYzEFivsRTN00oz5BEsRgQHu5JZ9WE=
 github.com/AzureAD/microsoft-authentication-library-for-go v1.3.3 h1:H5xDQaE3XowWfhZRUpnfC+rGZMEVoSiji+b+/HFAPU4=
@@ -140,8 +142,8 @@ github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8 h1:wPbRQzjjwF
 github.com/ProtonMail/go-crypto v0.0.0-20230217124315-7d5c6f04bbb8/go.mod h1:I0gYDMZ6Z5GRU7l58bNFSkPTFN6Yl12dsUlAZ8xy98g=
 github.com/adrg/xdg v0.5.3 h1:xRnxJXne7+oWDatRhR1JLnvuccuIeCoBu2rtuLqQB78=
 github.com/adrg/xdg v0.5.3/go.mod h1:nlTsY+NNiCBGCK2tpm09vRqfVzrc2fLmXGpBLF0zlTQ=
-github.com/antlr4-go/antlr/v4 v4.13.0 h1:lxCg3LAv+EUK6t1i0y1V6/SLeUi0eKEKdhQAlS8TVTI=
-github.com/antlr4-go/antlr/v4 v4.13.0/go.mod h1:pfChB/xh/Unjila75QW7+VU4TSnWnnk9UTnmpPaOR2g=
+github.com/antlr4-go/antlr/v4 v4.13.1 h1:SqQKkuVZ+zWkMMNkjy5FZe5mr5WURWnlpmOuzYWrPrQ=
+github.com/antlr4-go/antlr/v4 v4.13.1/go.mod h1:GKmUxMtwp6ZgGwZSva4eWPC5mS6vUAmOABFgjdkM7Nw=
 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5 h1:0CwZNZbxp69SHPdPJAN/hZIm0C4OItdklCFmMRWYpio=
 github.com/armon/go-socks5 v0.0.0-20160902184237-e75332964ef5/go.mod h1:wHh0iHkYZB8zMSxRWpUBQtwG5a7fFgvEO+odwuTv2gs=
 github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 h1:DklsrG3dyBCFEj5IhUbnKptjxatkF07cF2ak3yi77so=
@@ -221,8 +223,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2
 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
 github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
 github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
-github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
-github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
+github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
+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/go-errors/errors v1.4.2 h1:J6MZopCL4uSllY1OfXM374weqZFFItUbrImctkmUxIA=
diff --git a/test/setup/credentials.json b/test/setup/credentials.json
new file mode 100644
index 00000000000..cc360469646
--- /dev/null
+++ b/test/setup/credentials.json
@@ -0,0 +1,14 @@
+{
+  "client_id": "fake-client-id",
+  "client_secret": "LS0tLS1CRUdJTiBQUklWQVRFIEtFWS0tLS0tCk1JSUV2UUlCQURBTkJna3Foa2lHOXcwQkFRRUZBQVNDQktjd2dnU2pBZ0VBQW9JQkFRRGpyZEVyOVAwVGFVRVMKZHNwRTZjeW8yMk5VOHloUnJiWWxWOVZIMnZXdm5Qc1RoWGN4aG5kK2NVcWRORUJzd2h3Z0ZsVVFjZy9lU1Z4dwpyciszbmgrYkZUWldQY1krMUxRWXhmcEtHc3JDWFFmQjgyTERKSVpEWDRnSFlyV2YzWjI3MmpYTjFYZUZBS3RpCndES2dEWFh1UEg3cjVsSDd2QzNSWGVBZmZxTHdRSmhaZitOb0hOdHY5TUg5SWRVa1FmbURGWnRJL0NRekNyYjYKK3ZPUzZFbVVEL1EyRk5IQnpneENndUdxZ055QmNRYnhKOVFuZytaaklGdWhHWVhKbHN5UlV0ZXh5elRSNS92MApWTks4VXNaZ1JCRmhYcXJCdi9Sb0NDRyt4VkpZdG1kMFFzcnZOekRxRzZRbmpVQjIxelZYcXpLRWtXMmdSdGpYCmN3NHZZUWVoQWdNQkFBRUNnZ0VBUzZ4dGpnMG5Bb2trMGpTK1pPcEtsa01aQUZhemEzWnZ5SGlwa0hEejRQTXQKdGw3UmI1b1FaR3ZXVDJyYkVPcnhleTdCQmk3TEhHaEl1OEV4UXAvaFJHUG9CQUVUUDdYbHlDZ2hXUGtQdEV0RQpkVS9tWHhMb04wTnN6SHVmLzJzaTdwbUg4WXFHWjZRQjB0Z3IyMnV0NjBtYksrQUpGc0VFZjRhU3BCVXNwZXBKCjI4MDBzUUhzcVBFNkw2a1lrZloyR1JSWTFWOXZVcllFT0RLWnBXek1oTjNVQTluQUtIOVBCNnh2UDJPZHlNTmgKaEtnbVVVTU5JRnR3cjhwWmxKbjYwY2YwVXJXcmM1Q3ZxUUx1YUdZbHpEZ1VRR1Y0SkVWanFtOUY2bE1mRVBVdwplTjcwTVZlMXBjTGVMcTJyR0NWV1UzZ2FraC9IdkpxbFIvc2E1NDZIZ3dLQmdRRHlmMXZreVg0dzVzYm9pNkRKCmNsNWRNVUx0TU1ScEIxT2FNRlZPSmpJOWdaSjhtQ2RSanFYZFlvNWFTMktJcXhpZTh0R0c5K1NvaHhEQVdsNHQKbFNVdERzRTQ0ZlNtSUxxQzV6SWF3TlJRbm5rdjBYOEx3bVl1MFFkN1lBakpNbExUV3lEUnNqRDlYUnE0bnNSKwptSlZ3cnQ4NWlTcFM1VUZ5cnlFelBiRmowd0tCZ1FEd1d6cmFlTjBFY2NmMWlJWW1Rc1l5K3lNRUFsSE5SNXlpCmdQWHVBaFN5YnYySlJlUmhkVWIzOWhMci9Mdkt3MFplWGlMV1htWVVHcGJ5elB5WEltMHMrUEwzTFdsNjVHVEYKbCtjZlY1d2ZBZERrazZyQWRFUEVFMnB4Tjg1Q2h5YVBZUG9ZcjBvaG1WOTdWUWNZYzVGcVkrajF0TTZSMVJEdAovZldCU2E4aU93S0JnUUNwYTFkdFdXVERqNGdxVWRyc3d1MndtRWtVNDd4bFVJd1ZMbTE2NHU2NHovemk5WDZLCjJXbUNhV2ZoSjhmWWlnanlpOXpkT2ZYVDFFRmMwZ1g0UExvelo1cVJQalFwbUxZVjNLYkIwRFRGZW1KYWlUZ0UKcERXMXdhNURnUTNDVzFsSWR1TlAvZm1DR2ZrZ1FUUXc2ak9GL1hiUmdNWkVFZzJPclZJNXRZRm9wd0tCZ0VSOQppcWpFdGg1VkdlakNqWStMaVpUdmNVdnNLVWs0dGM2c3R1ZXFtaUU2ZFc3UGhzT3F1cDFmOW9aZWoxaTVDbTFMCm45dThMSlJmKzFHV3pnZDNIT3NxeVhsYjdHbkRlVi9BNkhCSzg4YjJLb05uL01rNG1ETGdZWDEvckh2U3JVOUEKRUNSR2x2WTZFVFpBeFhQWFFzR3hWS25uYXRHdGlGUjVBS05senMwUEFvR0FhNStYK0RVcUdoOWFFNUlEM3dydgpqa2p4UTJLTEZKQ05TcThmOUdTdXZwdmdYc3RIaDZ3S29NNnZNd0lTaGpnWHVVUkg4VWI0dWhSc1dueE1pbGRGCjdFRStRYVdVOWpuQ20ySFFZQXJmWHJBV3c2REJ1ZGlTa0JxZ0tjNkhqREh1bjVmWGxZVW84VWVzTk1RT3JnN2IKYnlkUVo1LzRWLzFvU1dQRVRrN2pTcjA9Ci0tLS0tRU5EIFBSSVZBVEUgS0VZLS0tLS0KLS0tLS1CRUdJTiBDRVJUSUZJQ0FURS0tLS0tCk1JSURDVENDQWZHZ0F3SUJBZ0lVRlNudEVuK1R2NkhNMnhKUmVFQ0pwSmNDN2lVd0RRWUpLb1pJaHZjTkFRRUwKQlFBd0ZERVNNQkFHQTFVRUF3d0piRzlqWVd4b2IzTjBNQjRYRFRJME1ERXdPREU1TlRReE5Gb1hEVE0wTURFdwpOVEU1TlRReE5Gb3dGREVTTUJBR0ExVUVBd3dKYkc5allXeG9iM04wTUlJQklqQU5CZ2txaGtpRzl3MEJBUUVGCkFBT0NBUThBTUlJQkNnS0NBUUVBNDYzUksvVDlFMmxCRW5iS1JPbk1xTnRqVlBNb1VhMjJKVmZWUjlyMXI1ejcKRTRWM01ZWjNmbkZLblRSQWJNSWNJQlpWRUhJUDNrbGNjSzYvdDU0Zm14VTJWajNHUHRTMEdNWDZTaHJLd2wwSAp3Zk5pd3lTR1ExK0lCMksxbjkyZHU5bzF6ZFYzaFFDcllzQXlvQTExN2p4KzYrWlIrN3d0MFYzZ0gzNmk4RUNZCldYL2phQnpiYi9UQi9TSFZKRUg1Z3hXYlNQd2tNd3EyK3Zyemt1aEpsQS8wTmhUUndjNE1Rb0xocW9EY2dYRUcKOFNmVUo0UG1ZeUJib1JtRnlaYk1rVkxYc2NzMDBlZjc5RlRTdkZMR1lFUVJZVjZxd2IvMGFBZ2h2c1ZTV0xabgpkRUxLN3pjdzZodWtKNDFBZHRjMVY2c3loSkZ0b0ViWTEzTU9MMkVIb1FJREFRQUJvMU13VVRBZEJnTlZIUTRFCkZnUVVmcnkvS0R0YW13TWxSUXNGUGJCaHpkdjJVNWN3SHdZRFZSMGpCQmd3Rm9BVWZyeS9LRHRhbXdNbFJRc0YKUGJCaHpkdjJVNWN3RHdZRFZSMFRBUUgvQkFVd0F3RUIvekFOQmdrcWhraUc5dzBCQVFzRkFBT0NBUUVBeVlzdApWdmV3S1JScHVZUldjNFhHNlduWXBoVWR5WkxNb0lscTBzeVoxYWo2WWJxb0s5Tk1IQVlFbkN2U292NnpJWk9hCnRyaHVVY2Y5R0Z6NWUwaUoyeklsRGMzMTJJd3N2NDF4aUMvYnMxNmtFbjhZZi9TdWpFWGFzajd2bUEzSHJGV2YKd1pUSC95Rkw1YXpvL2YrbEExUTI4WXdxRnBIbWxlMHkwTzUzVXRoNHAwdG13bG51K0NyTzlmSHAza1RsYjdmRAo2bXFmazlOcnQ4dE9DNGFIWURvcXRZVWdaaHg1OHhzSE1PVGV0S2VSbHA4SE1GOW9ST3RyaXo0blltNkloVHdvCjVrMUExM1MzQmpheGtaQ3lQWENnWHNzdVhhZ05MYXNycjVRcStWZ2RiL25EaFZlaFY4K1o0SjBZbnp5OU1ac0UKSDFOMU5mTXRzQStQRXF0UFhBPT0KLS0tLS1FTkQgQ0VSVElGSUNBVEUtLS0tLQ==",
+  "client_secret_url": "https://example.com",
+  "tenant_id": "fake-tenant-id",
+  "object_id": "fake-object-id",
+  "resource_id": "/subscriptions/example",
+  "authentication_endpoint": "https://login.microsoftonline.com/",
+  "mtls_authentication_endpoint": "https://login.microsoftonline.com/",
+  "not_before": "2025-02-07T13:29:00Z",
+  "not_after": "2025-05-08T13:29:00Z",
+  "renew_after": "2025-03-25T13:29:00Z",
+  "cannot_renew_after": "2025-08-06T13:29:00Z"
+}


Reviews:
user: login: willie-yao
body: Thanks for your work on this! Just a few comments regarding webhook validation and unit testing.
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-26T21:48:31Z
user: login: bryan-cox
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-27T17:57:56Z
user: login: bryan-cox
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-27T18:00:09Z
user: login: bryan-cox
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-02-27T19:09:00Z
user: login: nawazkh
body: Sharing some of my thoughts.. 
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-06T20:05:15Z
user: login: bryan-cox
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-06T20:10:27Z
user: login: nawazkh
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-06T22:30:24Z
user: login: bryan-cox
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-03-07T10:44:19Z

Review comments:
diff_hunk: @@ -62,6 +62,14 @@ type AzureClusterIdentitySpec struct {
 	// CertPath is the path where certificates exist. When set, it takes precedence over ClientSecret for types that use certs like ServicePrincipalCertificate.
 	// +optional
 	CertPath string `json:"certPath,omitempty"`
+	// UserAssignedIdentityCredentialsPath is the path where an existing JSON file exists containing the JSON format of
+	// a UserAssignedIdentityCredentials struct.
+	// See the msi-dataplane for more details on UserAssignedIdentityCredentials - https://github.com/Azure/msi-dataplane/blob/63fb37d3a1aaac130120624674df795d2e088083/pkg/dataplane/internal/generated_client.go#L156C6-L156C37
path: api/v1beta1/azureclusteridentity_types.go
user: login: willie-yao
body: nit: Are you able to shorten this link here to `main` or another branch rather than the full sha?
created_at: 2025-02-26T21:16:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -159,6 +161,13 @@ func (p *AzureCredentialsProvider) GetTokenCredential(ctx context.Context, resou
 		}
 		cred, authErr = p.cache.GetOrStoreManagedIdentity(&options)
 
+	case infrav1.UserAssignedIdentityCredential:
+		cloudType := parseCloudType(p.Identity.Spec.UserAssignedIdentityCredentialsCloudType)
+		cred, authErr = dataplane.NewUserAssignedIdentityCredential(ctx, p.Identity.Spec.UserAssignedIdentityCredentialsPath, dataplane.WithClientOpts(azcore.ClientOptions{Cloud: cloudType}))
+		if authErr != nil {
+			return nil, authErr
+		}
+
path: azure/scope/identity.go
user: login: willie-yao
body: Are you able to add some test cases to `TestGetTokenCredential` to cover this new flow?
created_at: 2025-02-26T21:36:26Z
author_association: CONTRIBUTOR
diff_hunk: @@ -62,6 +62,14 @@ type AzureClusterIdentitySpec struct {
 	// CertPath is the path where certificates exist. When set, it takes precedence over ClientSecret for types that use certs like ServicePrincipalCertificate.
 	// +optional
 	CertPath string `json:"certPath,omitempty"`
+	// UserAssignedIdentityCredentialsPath is the path where an existing JSON file exists containing the JSON format of
+	// a UserAssignedIdentityCredentials struct.
+	// See the msi-dataplane for more details on UserAssignedIdentityCredentials - https://github.com/Azure/msi-dataplane/blob/63fb37d3a1aaac130120624674df795d2e088083/pkg/dataplane/internal/generated_client.go#L156C6-L156C37
+	UserAssignedIdentityCredentialsPath string `json:"userAssignedIdentityCredentialsPath,omitempty"`
+	// UserAssignedIdentityCredentialsCloudType is used with UserAssignedIdentityCredentialsPath to specify the Cloud
+	// type. Can only be one of the following values: public, china, or usgovernment
+	// If a value is not specified, defaults to AzurePublicCloud
+	UserAssignedIdentityCredentialsCloudType string `json:"userAssignedIdentityCredentialsCloudType,omitempty"`
path: api/v1beta1/azureclusteridentity_types.go
user: login: willie-yao
body: For these new types, can you add some defaulting and validating webhooks to `azureclusteridentity_webhook.go` and `azureclusteridentity_validation.go` respectively? For example, these should not be set if IdentityType is not `UserAssignedIdentityCredential`. Some logic also needs to exist to set the default for `UserAssignedIdentityCredentialsCloudType` depending on IdentityType`
created_at: 2025-02-26T21:45:13Z
author_association: CONTRIBUTOR
path: api/v1beta1/azureclusteridentity_types.go
user: login: willie-yao
body: Comment on line 47 needs to be updated to include the new IdentityType
created_at: 2025-02-26T21:46:13Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,8 +1,8 @@
 module sigs.k8s.io/cluster-api-provider-azure
 
-go 1.22.7
+go 1.23.0
path: go.mod
user: login: willie-yao
body: Can this go bump be its own PR that we merge before this one?
created_at: 2025-02-26T21:47:40Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,8 +1,8 @@
 module sigs.k8s.io/cluster-api-provider-azure
 
-go 1.22.7
+go 1.23.0
path: go.mod
user: login: bryan-cox
body: This comment is OBE based on - https://github.com/kubernetes-sigs/cluster-api-provider-azure/pull/5421#issuecomment-2688701473
created_at: 2025-02-27T17:57:56Z
author_association: CONTRIBUTOR
diff_hunk: @@ -62,6 +62,14 @@ type AzureClusterIdentitySpec struct {
 	// CertPath is the path where certificates exist. When set, it takes precedence over ClientSecret for types that use certs like ServicePrincipalCertificate.
 	// +optional
 	CertPath string `json:"certPath,omitempty"`
+	// UserAssignedIdentityCredentialsPath is the path where an existing JSON file exists containing the JSON format of
+	// a UserAssignedIdentityCredentials struct.
+	// See the msi-dataplane for more details on UserAssignedIdentityCredentials - https://github.com/Azure/msi-dataplane/blob/63fb37d3a1aaac130120624674df795d2e088083/pkg/dataplane/internal/generated_client.go#L156C6-L156C37
path: api/v1beta1/azureclusteridentity_types.go
user: login: bryan-cox
body: This actually worked out because they moved the place where this was defined 😅 

created_at: 2025-02-27T18:00:09Z
author_association: CONTRIBUTOR
diff_hunk: @@ -62,6 +62,14 @@ type AzureClusterIdentitySpec struct {
 	// CertPath is the path where certificates exist. When set, it takes precedence over ClientSecret for types that use certs like ServicePrincipalCertificate.
 	// +optional
 	CertPath string `json:"certPath,omitempty"`
+	// UserAssignedIdentityCredentialsPath is the path where an existing JSON file exists containing the JSON format of
+	// a UserAssignedIdentityCredentials struct.
+	// See the msi-dataplane for more details on UserAssignedIdentityCredentials - https://github.com/Azure/msi-dataplane/blob/63fb37d3a1aaac130120624674df795d2e088083/pkg/dataplane/internal/generated_client.go#L156C6-L156C37
+	UserAssignedIdentityCredentialsPath string `json:"userAssignedIdentityCredentialsPath,omitempty"`
+	// UserAssignedIdentityCredentialsCloudType is used with UserAssignedIdentityCredentialsPath to specify the Cloud
+	// type. Can only be one of the following values: public, china, or usgovernment
+	// If a value is not specified, defaults to AzurePublicCloud
+	UserAssignedIdentityCredentialsCloudType string `json:"userAssignedIdentityCredentialsCloudType,omitempty"`
path: api/v1beta1/azureclusteridentity_types.go
user: login: bryan-cox
body: Hey @willie-yao I updated `azureclusteridentity_validation.go`. I don't understand what is needed for 'azureclusteridentity_webhook.go' as I didn't see any defaulting for other identity types, but I could be missing it.
created_at: 2025-02-27T19:09:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -558,7 +558,7 @@ func validatePrivateDNSZoneName(privateDNSZoneName string, controlPlaneEnabled b
 }
 
 // validatePrivateDNSZoneResourceGroup validates the PrivateDNSZoneResourceGroup.
-// A private DNS Zone's resource group is valid as long as privateDNSZoneName is provided with the private dns resource group name
+// A private DNS Zone's resource group is valid as long as privateDNSZoneName is provided with the private dns resource group name.
path: api/v1beta1/azurecluster_validation.go
user: login: nawazkh
body: This should be fixed in master, not sure if you need a rebase ? 
created_at: 2025-03-05T21:39:04Z
author_association: MEMBER
diff_hunk: @@ -27,6 +27,10 @@ func (c *AzureClusterIdentity) validateClusterIdentity() (admission.Warnings, er
 	if c.Spec.Type != UserAssignedMSI && c.Spec.ResourceID != "" {
 		allErrs = append(allErrs, field.Forbidden(field.NewPath("spec", "resourceID"), c.Spec.ResourceID))
 	}
+	if c.Spec.Type != UserAssignedIdentityCredential && (c.Spec.UserAssignedIdentityCredentialsPath != "" || c.Spec.UserAssignedIdentityCredentialsCloudType != "") {
+		allErrs = append(allErrs, field.Forbidden(field.NewPath("spec", "userAssignedIdentityCredentialsPath"), c.Spec.UserAssignedIdentityCredentialsPath))
path: api/v1beta1/azureclusteridentity_validation.go
user: login: nawazkh
body: ```suggestion
		allErrs = append(allErrs, field.Forbidden(field.NewPath("spec", "userAssignedIdentityCredentialsPath"), fmt.Sprintf("%s can only be set when AzureClusterIdentity is of type UserAssignedIdentityCredential", c.Spec.UserAssignedIdentityCredentialsPath)))
```
created_at: 2025-03-05T21:50:52Z
author_association: MEMBER
diff_hunk: @@ -27,6 +27,10 @@ func (c *AzureClusterIdentity) validateClusterIdentity() (admission.Warnings, er
 	if c.Spec.Type != UserAssignedMSI && c.Spec.ResourceID != "" {
 		allErrs = append(allErrs, field.Forbidden(field.NewPath("spec", "resourceID"), c.Spec.ResourceID))
 	}
+	if c.Spec.Type != UserAssignedIdentityCredential && (c.Spec.UserAssignedIdentityCredentialsPath != "" || c.Spec.UserAssignedIdentityCredentialsCloudType != "") {
+		allErrs = append(allErrs, field.Forbidden(field.NewPath("spec", "userAssignedIdentityCredentialsPath"), c.Spec.UserAssignedIdentityCredentialsPath))
+		allErrs = append(allErrs, field.Forbidden(field.NewPath("spec", "userAssignedIdentityCredentialsCloudType"), c.Spec.UserAssignedIdentityCredentialsCloudType))
path: api/v1beta1/azureclusteridentity_validation.go
user: login: nawazkh
body: ```suggestion
		allErrs = append(allErrs, field.Forbidden(field.NewPath("spec", "userAssignedIdentityCredentialsCloudType"), fmt.Sprintf("%s can only be set when AzureClusterIdentity is of type UserAssignedIdentityCredential ", c.Spec.UserAssignedIdentityCredentialsCloudType)))
```
created_at: 2025-03-05T21:52:49Z
author_association: MEMBER
diff_hunk: @@ -27,6 +27,10 @@ func (c *AzureClusterIdentity) validateClusterIdentity() (admission.Warnings, er
 	if c.Spec.Type != UserAssignedMSI && c.Spec.ResourceID != "" {
 		allErrs = append(allErrs, field.Forbidden(field.NewPath("spec", "resourceID"), c.Spec.ResourceID))
 	}
+	if c.Spec.Type != UserAssignedIdentityCredential && (c.Spec.UserAssignedIdentityCredentialsPath != "" || c.Spec.UserAssignedIdentityCredentialsCloudType != "") {
+		allErrs = append(allErrs, field.Forbidden(field.NewPath("spec", "userAssignedIdentityCredentialsPath"), c.Spec.UserAssignedIdentityCredentialsPath))
+		allErrs = append(allErrs, field.Forbidden(field.NewPath("spec", "userAssignedIdentityCredentialsCloudType"), c.Spec.UserAssignedIdentityCredentialsCloudType))
path: api/v1beta1/azureclusteridentity_validation.go
user: login: nawazkh
body: Idea here is to improve the user facing error messages 
created_at: 2025-03-05T21:54:30Z
author_association: MEMBER
diff_hunk: @@ -259,3 +268,17 @@ func IsClusterNamespaceAllowed(ctx context.Context, k8sClient client.Client, all
 
 	return false
 }
+
+func parseCloudType(cloudType string) cloud.Configuration {
+	cloudType = strings.ToUpper(cloudType)
path: azure/scope/identity.go
user: login: nawazkh
body: > `strings.ToUpper(...)`

Ha! Interesting choice, I use `strings.ToLower(...)` in practice. 
Just curious, any special reason on using `strings.ToUpper(...)` ? 
created_at: 2025-03-05T21:57:43Z
author_association: MEMBER
diff_hunk: @@ -62,6 +62,14 @@ type AzureClusterIdentitySpec struct {
 	// CertPath is the path where certificates exist. When set, it takes precedence over ClientSecret for types that use certs like ServicePrincipalCertificate.
 	// +optional
 	CertPath string `json:"certPath,omitempty"`
+	// UserAssignedIdentityCredentialsPath is the path where an existing JSON file exists containing the JSON format of
+	// a UserAssignedIdentityCredentials struct.
+	// See the msi-dataplane for more details on UserAssignedIdentityCredentials - https://github.com/Azure/msi-dataplane/blob/main/pkg/dataplane/internal/client/models.go#L125
+	UserAssignedIdentityCredentialsPath string `json:"userAssignedIdentityCredentialsPath,omitempty"`
+	// UserAssignedIdentityCredentialsCloudType is used with UserAssignedIdentityCredentialsPath to specify the Cloud
+	// type. Can only be one of the following values: public, china, or usgovernment
+	// If a value is not specified, defaults to public
+	UserAssignedIdentityCredentialsCloudType string `json:"userAssignedIdentityCredentialsCloudType,omitempty"`
path: api/v1beta1/azureclusteridentity_types.go
user: login: nawazkh
body: Should we adding `// +optional` tag to the fields ? 
```suggestion
	// UserAssignedIdentityCredentialsPath is the path where an existing JSON file exists containing the JSON format of
	// a UserAssignedIdentityCredentials struct.
	// See the msi-dataplane for more details on UserAssignedIdentityCredentials - https://github.com/Azure/msi-dataplane/blob/main/pkg/dataplane/internal/client/models.go#L125
	// +optional
	UserAssignedIdentityCredentialsPath string `json:"userAssignedIdentityCredentialsPath,omitempty"`
	// UserAssignedIdentityCredentialsCloudType is used with UserAssignedIdentityCredentialsPath to specify the Cloud
	// type. Can only be one of the following values: public, china, or usgovernment
	// If a value is not specified, defaults to public
	// +optional
	UserAssignedIdentityCredentialsCloudType string `json:"userAssignedIdentityCredentialsCloudType,omitempty"`
```
created_at: 2025-03-05T23:10:21Z
author_association: MEMBER
diff_hunk: @@ -259,3 +268,17 @@ func IsClusterNamespaceAllowed(ctx context.Context, k8sClient client.Client, all
 
 	return false
 }
+
+func parseCloudType(cloudType string) cloud.Configuration {
+	cloudType = strings.ToUpper(cloudType)
path: azure/scope/identity.go
user: login: bryan-cox
body: I brought this along from working on this in the cloud provider repo. They were using ToUpper.
created_at: 2025-03-06T20:10:27Z
author_association: CONTRIBUTOR
diff_hunk: @@ -159,6 +160,14 @@ func (p *AzureCredentialsProvider) GetTokenCredential(ctx context.Context, resou
 		}
 		cred, authErr = p.cache.GetOrStoreManagedIdentity(&options)
 
+	case infrav1.UserAssignedIdentityCredential:
+		cloudType := parseCloudType(p.Identity.Spec.UserAssignedIdentityCredentialsCloudType)
+		clientOptions := azcore.ClientOptions{
+			TracingProvider: tracingProvider,
+			Cloud:           cloudType,
+		}
+		cred, authErr = p.cache.GetOrStoreUserAssignedManagedIdentityCredentials(context.Background(), p.Identity.Spec.UserAssignedIdentityCredentialsPath, clientOptions, &log)
path: azure/scope/identity.go
user: login: nawazkh
body: Non-blocking comment: 
Could we please add a Note here highlighting the reason for choosing `context.Background()` instead of `context.WithValues` or other choices ? A reminder for us to be able to fix the context deadline exceeded logs we've have been seeing..
created_at: 2025-03-06T22:30:24Z
author_association: MEMBER
diff_hunk: @@ -159,6 +160,14 @@ func (p *AzureCredentialsProvider) GetTokenCredential(ctx context.Context, resou
 		}
 		cred, authErr = p.cache.GetOrStoreManagedIdentity(&options)
 
+	case infrav1.UserAssignedIdentityCredential:
+		cloudType := parseCloudType(p.Identity.Spec.UserAssignedIdentityCredentialsCloudType)
+		clientOptions := azcore.ClientOptions{
+			TracingProvider: tracingProvider,
+			Cloud:           cloudType,
+		}
+		cred, authErr = p.cache.GetOrStoreUserAssignedManagedIdentityCredentials(context.Background(), p.Identity.Spec.UserAssignedIdentityCredentialsPath, clientOptions, &log)
path: azure/scope/identity.go
user: login: bryan-cox
body: I updated and added a comment
created_at: 2025-03-07T10:44:19Z
author_association: CONTRIBUTOR

PR comments:
user: login: k8s-ci-robot
created_at: 2025-02-10T17:42:55Z
body: Skipping CI for Draft Pull Request.
If you want CI signal for your change, please convert it to an actual PR.
You can still manually trigger a test run with `/test all`
author_association: CONTRIBUTOR
user: login: codecov[bot]
created_at: 2025-02-10T17:53:19Z
body: ## [Codecov](https://app.codecov.io/gh/kubernetes-sigs/cluster-api-provider-azure/pull/5421?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubernetes-sigs) Report
Attention: Patch coverage is `66.66667%` with `15 lines` in your changes missing coverage. Please review.
> Project coverage is 52.58%. Comparing base [(`a5f0307`)](https://app.codecov.io/gh/kubernetes-sigs/cluster-api-provider-azure/commit/a5f0307e843835df9669675fe3be1dd89142d7b0?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubernetes-sigs) to head [(`b51f5eb`)](https://app.codecov.io/gh/kubernetes-sigs/cluster-api-provider-azure/commit/b51f5eb77c9f02434e003030975082ae66b05c10?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubernetes-sigs).
> Report is 3 commits behind head on main.

| [Files with missing lines](https://app.codecov.io/gh/kubernetes-sigs/cluster-api-provider-azure/pull/5421?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubernetes-sigs) | Patch % | Lines |
|---|---|---|
| [azure/credential\_cache.go](https://app.codecov.io/gh/kubernetes-sigs/cluster-api-provider-azure/pull/5421?src=pr&el=tree&filepath=azure%2Fcredential_cache.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubernetes-sigs#diff-YXp1cmUvY3JlZGVudGlhbF9jYWNoZS5nbw==) | 0.00% | [11 Missing :warning: ](https://app.codecov.io/gh/kubernetes-sigs/cluster-api-provider-azure/pull/5421?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubernetes-sigs) |
| [api/v1beta1/azureclusteridentity\_validation.go](https://app.codecov.io/gh/kubernetes-sigs/cluster-api-provider-azure/pull/5421?src=pr&el=tree&filepath=api%2Fv1beta1%2Fazureclusteridentity_validation.go&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubernetes-sigs#diff-YXBpL3YxYmV0YTEvYXp1cmVjbHVzdGVyaWRlbnRpdHlfdmFsaWRhdGlvbi5nbw==) | 0.00% | [3 Missing and 1 partial :warning: ](https://app.codecov.io/gh/kubernetes-sigs/cluster-api-provider-azure/pull/5421?src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubernetes-sigs) |

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main    #5421      +/-   ##
==========================================
+ Coverage   52.56%   52.58%   +0.02%     
==========================================
  Files         272      272              
  Lines       29423    29468      +45     
==========================================
+ Hits        15465    15495      +30     
- Misses      13151    13165      +14     
- Partials      807      808       +1     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/kubernetes-sigs/cluster-api-provider-azure/pull/5421?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubernetes-sigs).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=kubernetes-sigs).

<details><summary>🚀 New features to boost your workflow: </summary>

- ❄ [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: bryan-cox
created_at: 2025-02-10T19:42:52Z
body: /test all
author_association: CONTRIBUTOR
user: login: bryan-cox
created_at: 2025-02-13T13:23:00Z
body: /test all
author_association: CONTRIBUTOR
user: login: bryan-cox
created_at: 2025-02-13T14:59:01Z
body: /test pull-cluster-api-provider-azure-e2e-aks
author_association: CONTRIBUTOR
user: login: willie-yao
created_at: 2025-02-26T21:11:07Z
body: /retest
author_association: CONTRIBUTOR
user: login: willie-yao
created_at: 2025-02-26T21:49:14Z
body: Also, it looks like the netlify jobs are failing with `internal error: package "k8s.io/apimachinery/pkg/fields" without types was imported from "sigs.k8s.io/controller-runtime/pkg/internal/field/selector"`

https://app.netlify.com/sites/kubernetes-sigs-cluster-api-provider-azure/deploys/67bf477902eca200082ebf2f
author_association: CONTRIBUTOR
user: login: bryan-cox
created_at: 2025-02-27T17:57:35Z
body: During the CAPZ SIG meeting today, we discussed what to do about the Golang bump being brought in when I included the ASO code needed for this new authentication type. The project I'm on isn't using ASO so I don't believe I need this newly added ASO code and this new authentication type is only for 1st party Microsoft applications. I don't believe many other projects will be using this new authentication type soon.

Given these reasons, I am planning to:
- remove the ASO parts so the Golang bump issue will be eliminated
- test this PR without the ASO part in my project to make sure its working as expected
- will introduce the ASO part later when CAPZ has bumped up to Golang v1.23

If the ASO part is indeed needed now, I'll reach back out.
author_association: CONTRIBUTOR
user: login: nawazkh
created_at: 2025-03-06T22:23:19Z
body: /test ls
author_association: MEMBER
user: login: k8s-ci-robot
created_at: 2025-03-06T22:23:22Z
body: @nawazkh: The specified target(s) for `/test` were not found.
The following commands are available to trigger required jobs:
```
/test pull-cluster-api-provider-azure-apiversion-upgrade
```
```
/test pull-cluster-api-provider-azure-build
```
```
/test pull-cluster-api-provider-azure-ci-entrypoint
```
```
/test pull-cluster-api-provider-azure-e2e
```
```
/test pull-cluster-api-provider-azure-e2e-aks
```
```
/test pull-cluster-api-provider-azure-test
```
```
/test pull-cluster-api-provider-azure-verify
```

The following commands are available to trigger optional jobs:
```
/test pull-cluster-api-provider-azure-apidiff
```
```
/test pull-cluster-api-provider-azure-apiserver-ilb
```
```
/test pull-cluster-api-provider-azure-capi-e2e
```
```
/test pull-cluster-api-provider-azure-conformance
```
```
/test pull-cluster-api-provider-azure-conformance-custom-builds
```
```
/test pull-cluster-api-provider-azure-conformance-dual-stack-with-ci-artifacts
```
```
/test pull-cluster-api-provider-azure-conformance-ipv6-with-ci-artifacts
```
```
/test pull-cluster-api-provider-azure-conformance-with-ci-artifacts
```
```
/test pull-cluster-api-provider-azure-conformance-with-ci-artifacts-dra
```
```
/test pull-cluster-api-provider-azure-e2e-optional
```
```
/test pull-cluster-api-provider-azure-e2e-workload-upgrade
```
```
/test pull-cluster-api-provider-azure-load-test-custom-builds
```
```
/test pull-cluster-api-provider-azure-windows-custom-builds
```
```
/test pull-cluster-api-provider-azure-windows-with-ci-artifacts
```

Use `/test all` to run the following jobs that were automatically triggered:
```
pull-cluster-api-provider-azure-apidiff
```
```
pull-cluster-api-provider-azure-apiversion-upgrade
```
```
pull-cluster-api-provider-azure-build
```
```
pull-cluster-api-provider-azure-capi-e2e
```
```
pull-cluster-api-provider-azure-ci-entrypoint
```
```
pull-cluster-api-provider-azure-e2e
```
```
pull-cluster-api-provider-azure-e2e-aks
```
```
pull-cluster-api-provider-azure-test
```
```
pull-cluster-api-provider-azure-verify
```



<details>

In response to [this](https://github.com/kubernetes-sigs/cluster-api-provider-azure/pull/5421#issuecomment-2705075230):

>/test ls


Instructions for interacting with me using PR comments are available [here](https://git.k8s.io/community/contributors/guide/pull-requests.md).  If you have questions or suggestions related to my behavior, please file an issue against the [kubernetes-sigs/prow](https://github.com/kubernetes-sigs/prow/issues/new?title=Prow%20issue:) repository.
</details>
author_association: CONTRIBUTOR
user: login: nawazkh
created_at: 2025-03-06T22:23:48Z
body: /test pull-cluster-api-provider-azure-e2e-optional
author_association: MEMBER
user: login: nawazkh
created_at: 2025-03-06T22:31:56Z
body: Thank you for putting this together! 
/lgtm
author_association: MEMBER
user: login: k8s-ci-robot
created_at: 2025-03-06T22:32:02Z
body: LGTM label has been added.  <details>Git tree hash: 42b42043afee4e6d6bfd007c9e269529bfa48a3b</details>
author_association: CONTRIBUTOR
user: login: bryan-cox
created_at: 2025-03-07T13:27:42Z
body: /retest-required
author_association: CONTRIBUTOR
user: login: bryan-cox
created_at: 2025-03-07T15:45:53Z
body: /retest-required
author_association: CONTRIBUTOR
user: login: nawazkh
created_at: 2025-03-07T19:37:16Z
body: /retest
author_association: MEMBER
user: login: bryan-cox
created_at: 2025-03-10T13:50:23Z
body: @nawazkh can I get another tag on this one please?
author_association: CONTRIBUTOR
user: login: nawazkh
created_at: 2025-03-10T16:56:47Z
body: /lgtm
/approve
author_association: MEMBER
user: login: k8s-ci-robot
created_at: 2025-03-10T16:56:53Z
body: LGTM label has been added.  <details>Git tree hash: 2231b0e6809f85e2750efe57bf089a36c71dbd53</details>
author_association: CONTRIBUTOR
user: login: k8s-ci-robot
created_at: 2025-03-10T16:56:55Z
body: [APPROVALNOTIFIER] This PR is **APPROVED**

This pull-request has been approved by: *<a href="https://github.com/kubernetes-sigs/cluster-api-provider-azure/pull/5421#issuecomment-2711244706" title="Approved">nawazkh</a>*

The full list of commands accepted by this bot can be found [here](https://go.k8s.io/bot-commands?repo=kubernetes-sigs%2Fcluster-api-provider-azure).

The pull request process is described [here](https://git.k8s.io/community/contributors/guide/owners.md#the-code-review-process)

<details >
Needs approval from an approver in each of these files:

- ~~[OWNERS](https://github.com/kubernetes-sigs/cluster-api-provider-azure/blob/main/OWNERS)~~ [nawazkh]

Approvers can indicate their approval by writing `/approve` in a comment
Approvers can cancel approval by writing `/approve cancel` in a comment
</details>
<!-- META={"approvers":[]} -->
author_association: CONTRIBUTOR