Skip to content

Commit b51f5eb

Browse files
committed
Add new auth type: UserAssignedIdentityCredentials
This commit adds a new authentication type, UserAssignedIdentityCredentials. This allows a 1st party Microsoft application to authenticate using a managed identity's certificate, which is accessed through the MSI data plane. More information on this authentication type can be found here - https://github .com/Azure/msi-dataplane/blob/63fb37d3a1aaac130120624674df795d2e088083 /pkg/dataplane/reloadCredentials.go#L60. Signed-off-by: Bryan Cox <[email protected]>
1 parent cfee1d2 commit b51f5eb

12 files changed

+206
-9
lines changed

api/v1beta1/azureclusteridentity_types.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ type AllowedNamespaces struct {
4444
// AzureClusterIdentitySpec defines the parameters that are used to create an AzureIdentity.
4545
type AzureClusterIdentitySpec struct {
4646
// Type is the type of Azure Identity used.
47-
// ServicePrincipal, ServicePrincipalCertificate, UserAssignedMSI, ManualServicePrincipal or WorkloadIdentity.
47+
// ServicePrincipal, ServicePrincipalCertificate, UserAssignedMSI, ManualServicePrincipal, UserAssignedIdentityCredential, or WorkloadIdentity.
4848
Type IdentityType `json:"type"`
4949
// ResourceID is the Azure resource ID for the User Assigned MSI resource.
5050
// Only applicable when type is UserAssignedMSI.
@@ -62,6 +62,16 @@ type AzureClusterIdentitySpec struct {
6262
// CertPath is the path where certificates exist. When set, it takes precedence over ClientSecret for types that use certs like ServicePrincipalCertificate.
6363
// +optional
6464
CertPath string `json:"certPath,omitempty"`
65+
// UserAssignedIdentityCredentialsPath is the path where an existing JSON file exists containing the JSON format of
66+
// a UserAssignedIdentityCredentials struct.
67+
// See the msi-dataplane for more details on UserAssignedIdentityCredentials - https://github.com/Azure/msi-dataplane/blob/main/pkg/dataplane/internal/client/models.go#L125
68+
// +optional
69+
UserAssignedIdentityCredentialsPath string `json:"userAssignedIdentityCredentialsPath,omitempty"`
70+
// UserAssignedIdentityCredentialsCloudType is used with UserAssignedIdentityCredentialsPath to specify the Cloud
71+
// type. Can only be one of the following values: public, china, or usgovernment
72+
// If a value is not specified, defaults to public
73+
// +optional
74+
UserAssignedIdentityCredentialsCloudType string `json:"userAssignedIdentityCredentialsCloudType,omitempty"`
6575
// TenantID is the service principal primary tenant id.
6676
TenantID string `json:"tenantID"`
6777
// AllowedNamespaces is used to identify the namespaces the clusters are allowed to use the identity from.

api/v1beta1/azureclusteridentity_validation.go

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@ limitations under the License.
1717
package v1beta1
1818

1919
import (
20+
"fmt"
21+
2022
apierrors "k8s.io/apimachinery/pkg/api/errors"
2123
"k8s.io/apimachinery/pkg/util/validation/field"
2224
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
@@ -27,6 +29,10 @@ func (c *AzureClusterIdentity) validateClusterIdentity() (admission.Warnings, er
2729
if c.Spec.Type != UserAssignedMSI && c.Spec.ResourceID != "" {
2830
allErrs = append(allErrs, field.Forbidden(field.NewPath("spec", "resourceID"), c.Spec.ResourceID))
2931
}
32+
if c.Spec.Type != UserAssignedIdentityCredential && (c.Spec.UserAssignedIdentityCredentialsPath != "" || c.Spec.UserAssignedIdentityCredentialsCloudType != "") {
33+
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)))
34+
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)))
35+
}
3036
if len(allErrs) == 0 {
3137
return nil, nil
3238
}

api/v1beta1/types.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -570,7 +570,7 @@ type UserAssignedIdentity struct {
570570
}
571571

572572
// IdentityType represents different types of identities.
573-
// +kubebuilder:validation:Enum=ServicePrincipal;UserAssignedMSI;ManualServicePrincipal;ServicePrincipalCertificate;WorkloadIdentity
573+
// +kubebuilder:validation:Enum=ServicePrincipal;UserAssignedMSI;ManualServicePrincipal;ServicePrincipalCertificate;WorkloadIdentity;UserAssignedIdentityCredential
574574
type IdentityType string
575575

576576
const (
@@ -588,6 +588,9 @@ const (
588588

589589
// WorkloadIdentity represents a WorkloadIdentity.
590590
WorkloadIdentity IdentityType = "WorkloadIdentity"
591+
592+
// UserAssignedIdentityCredential represents a UserAssignedIdentityCredential.
593+
UserAssignedIdentityCredential IdentityType = "UserAssignedIdentityCredential"
591594
)
592595

593596
// OSDisk defines the operating system disk for a VM.

azure/credential_cache.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,13 @@ limitations under the License.
1717
package azure
1818

1919
import (
20+
"context"
2021
"sync"
2122

2223
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
2324
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
25+
"github.com/Azure/msi-dataplane/pkg/dataplane"
26+
"github.com/go-logr/logr"
2427
)
2528

2629
type credentialCache struct {
@@ -34,6 +37,7 @@ type credentialFactory interface {
3437
newClientCertificateCredential(tenantID string, clientID string, clientCertificate []byte, clientCertificatePassword []byte, opts *azidentity.ClientCertificateCredentialOptions) (azcore.TokenCredential, error)
3538
newManagedIdentityCredential(opts *azidentity.ManagedIdentityCredentialOptions) (azcore.TokenCredential, error)
3639
newWorkloadIdentityCredential(opts *azidentity.WorkloadIdentityCredentialOptions) (azcore.TokenCredential, error)
40+
newUserAssignedManagedIdentityCredentials(ctx context.Context, credsPath string, opts azcore.ClientOptions, logger *logr.Logger) (azcore.TokenCredential, error)
3741
}
3842

3943
// CredentialType represents the auth mechanism in use.
@@ -48,6 +52,8 @@ const (
4852
CredentialTypeManagedIdentity
4953
// CredentialTypeWorkloadIdentity is for Workload Identity.
5054
CredentialTypeWorkloadIdentity
55+
// CredentialTypeUserAssignedManagedIdentity is for User Assigned Managed Identity Credentials.
56+
CredentialTypeUserAssignedManagedIdentity
5157
)
5258

5359
type credentialCacheKey struct {
@@ -125,6 +131,18 @@ func (c *credentialCache) GetOrStoreWorkloadIdentity(opts *azidentity.WorkloadId
125131
)
126132
}
127133

134+
func (c *credentialCache) GetOrStoreUserAssignedManagedIdentityCredentials(ctx context.Context, credsPath string, opts azcore.ClientOptions, logger *logr.Logger) (azcore.TokenCredential, error) {
135+
return c.getOrStore(
136+
credentialCacheKey{
137+
authorityHost: opts.Cloud.ActiveDirectoryAuthorityHost,
138+
credentialType: CredentialTypeUserAssignedManagedIdentity,
139+
},
140+
func() (azcore.TokenCredential, error) {
141+
return c.credFactory.newUserAssignedManagedIdentityCredentials(ctx, credsPath, opts, logger)
142+
},
143+
)
144+
}
145+
128146
func (c *credentialCache) getOrStore(key credentialCacheKey, newCredFunc func() (azcore.TokenCredential, error)) (azcore.TokenCredential, error) {
129147
c.mut.Lock()
130148
defer c.mut.Unlock()
@@ -160,3 +178,7 @@ func (azureCredentialFactory) newManagedIdentityCredential(opts *azidentity.Mana
160178
func (azureCredentialFactory) newWorkloadIdentityCredential(opts *azidentity.WorkloadIdentityCredentialOptions) (azcore.TokenCredential, error) {
161179
return azidentity.NewWorkloadIdentityCredential(opts)
162180
}
181+
182+
func (azureCredentialFactory) newUserAssignedManagedIdentityCredentials(ctx context.Context, credsPath string, opts azcore.ClientOptions, logger *logr.Logger) (azcore.TokenCredential, error) {
183+
return dataplane.NewUserAssignedIdentityCredential(ctx, credsPath, dataplane.WithClientOpts(opts), dataplane.WithLogger(logger))
184+
}

azure/interfaces.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
2424
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
2525
"github.com/Azure/azure-service-operator/v2/pkg/genruntime"
26+
"github.com/go-logr/logr"
2627
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
2728
clusterv1 "sigs.k8s.io/cluster-api/api/v1beta1"
2829
"sigs.k8s.io/controller-runtime/pkg/client"
@@ -172,4 +173,5 @@ type CredentialCache interface {
172173
GetOrStoreClientCert(tenantID, clientID string, cert, certPassword []byte, opts *azidentity.ClientCertificateCredentialOptions) (azcore.TokenCredential, error)
173174
GetOrStoreManagedIdentity(opts *azidentity.ManagedIdentityCredentialOptions) (azcore.TokenCredential, error)
174175
GetOrStoreWorkloadIdentity(opts *azidentity.WorkloadIdentityCredentialOptions) (azcore.TokenCredential, error)
176+
GetOrStoreUserAssignedManagedIdentityCredentials(ctx context.Context, credsPath string, opts azcore.ClientOptions, logger *logr.Logger) (azcore.TokenCredential, error)
175177
}

azure/mock_azure/azure_mock.go

Lines changed: 16 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

azure/scope/identity.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import (
2020
"context"
2121
"os"
2222
"reflect"
23+
"strings"
2324

2425
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
2526
"github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud"
@@ -159,6 +160,17 @@ func (p *AzureCredentialsProvider) GetTokenCredential(ctx context.Context, resou
159160
}
160161
cred, authErr = p.cache.GetOrStoreManagedIdentity(&options)
161162

163+
case infrav1.UserAssignedIdentityCredential:
164+
cloudType := parseCloudType(p.Identity.Spec.UserAssignedIdentityCredentialsCloudType)
165+
clientOptions := azcore.ClientOptions{
166+
TracingProvider: tracingProvider,
167+
Cloud: cloudType,
168+
}
169+
// Using context.Background() here as something is cancelling the context prematurely in the ctx passed into the
170+
// function. This authentication method is long lived and has built in capabilities to rotate the certificates
171+
// when they change.
172+
cred, authErr = p.cache.GetOrStoreUserAssignedManagedIdentityCredentials(context.Background(), p.Identity.Spec.UserAssignedIdentityCredentialsPath, clientOptions, &log)
173+
162174
default:
163175
return nil, errors.Errorf("identity type %s not supported", p.Identity.Spec.Type)
164176
}
@@ -259,3 +271,17 @@ func IsClusterNamespaceAllowed(ctx context.Context, k8sClient client.Client, all
259271

260272
return false
261273
}
274+
275+
func parseCloudType(cloudType string) cloud.Configuration {
276+
cloudType = strings.ToUpper(cloudType)
277+
switch cloudType {
278+
case "PUBLIC":
279+
return cloud.AzurePublic
280+
case "CHINA":
281+
return cloud.AzureChina
282+
case "USGOVERNMENT":
283+
return cloud.AzureGovernment
284+
default:
285+
return cloud.AzurePublic
286+
}
287+
}

azure/scope/identity_test.go

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"reflect"
2323
"testing"
2424

25+
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
2526
"github.com/Azure/azure-sdk-for-go/sdk/azcore/cloud"
2627
"github.com/Azure/azure-sdk-for-go/sdk/azidentity"
2728
. "github.com/onsi/gomega"
@@ -419,6 +420,45 @@ func TestGetTokenCredential(t *testing.T) {
419420
}))
420421
},
421422
},
423+
{
424+
name: "UserAssignedIdentityCredential",
425+
cluster: &infrav1.AzureCluster{
426+
Spec: infrav1.AzureClusterSpec{
427+
AzureClusterClassSpec: infrav1.AzureClusterClassSpec{
428+
IdentityRef: &corev1.ObjectReference{
429+
Kind: infrav1.AzureClusterIdentityKind,
430+
},
431+
},
432+
},
433+
},
434+
identity: &infrav1.AzureClusterIdentity{
435+
Spec: infrav1.AzureClusterIdentitySpec{
436+
Type: infrav1.UserAssignedIdentityCredential,
437+
UserAssignedIdentityCredentialsPath: "../../test/setup/credentials.json",
438+
UserAssignedIdentityCredentialsCloudType: "public",
439+
},
440+
},
441+
cacheExpect: func(cache *mock_azure.MockCredentialCache) {
442+
ctx := context.Background()
443+
credsPath := "../../test/setup/credentials.json" //nolint:gosec
444+
clientOptions := azcore.ClientOptions{
445+
Cloud: cloud.Configuration{
446+
ActiveDirectoryAuthorityHost: "https://login.microsoftonline.com/",
447+
Services: map[cloud.ServiceName]cloud.ServiceConfiguration{
448+
cloud.ResourceManager: {
449+
Audience: "https://management.core.windows.net/",
450+
Endpoint: "https://management.azure.com",
451+
},
452+
},
453+
},
454+
}
455+
cache.EXPECT().GetOrStoreUserAssignedManagedIdentityCredentials(ctx, credsPath, gomock.Cond(func(opts azcore.ClientOptions) bool {
456+
return opts.Cloud.ActiveDirectoryAuthorityHost == clientOptions.Cloud.ActiveDirectoryAuthorityHost &&
457+
opts.Cloud.Services[cloud.ResourceManager].Audience == clientOptions.Cloud.Services[cloud.ResourceManager].Audience &&
458+
opts.Cloud.Services[cloud.ResourceManager].Endpoint == clientOptions.Cloud.Services[cloud.ResourceManager].Endpoint
459+
}), gomock.Any())
460+
},
461+
},
422462
}
423463

424464
scheme := runtime.NewScheme()
@@ -448,3 +488,45 @@ func TestGetTokenCredential(t *testing.T) {
448488
})
449489
}
450490
}
491+
492+
func TestParseCloudType(t *testing.T) {
493+
tests := []struct {
494+
name string
495+
input string
496+
expected cloud.Configuration
497+
}{
498+
{
499+
name: "when the input is public, expect AzurePublic",
500+
input: "public",
501+
expected: cloud.AzurePublic,
502+
},
503+
{
504+
name: "when the input is China, expect AzureChina",
505+
input: "china",
506+
expected: cloud.AzureChina,
507+
},
508+
{
509+
name: "when the input is usgovernment, expect AzureGovernment",
510+
input: "usgovernment",
511+
expected: cloud.AzureGovernment,
512+
},
513+
{
514+
name: "when the input is empty, expect AzurePublic",
515+
input: "", // Test case for default value
516+
expected: cloud.AzurePublic,
517+
},
518+
{
519+
name: "when the input is PUBLIC, expect AzurePublic",
520+
input: "PUBLIC", // Test case for uppercased input
521+
expected: cloud.AzurePublic,
522+
},
523+
}
524+
525+
for _, tt := range tests {
526+
t.Run(tt.name, func(t *testing.T) {
527+
t.Parallel()
528+
g := NewWithT(t)
529+
g.Expect(parseCloudType(tt.input)).To(Equal(tt.expected))
530+
})
531+
}
532+
}

config/crd/bases/infrastructure.cluster.x-k8s.io_azureclusteridentities.yaml

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -159,13 +159,26 @@ spec:
159159
type:
160160
description: |-
161161
Type is the type of Azure Identity used.
162-
ServicePrincipal, ServicePrincipalCertificate, UserAssignedMSI, ManualServicePrincipal or WorkloadIdentity.
162+
ServicePrincipal, ServicePrincipalCertificate, UserAssignedMSI, ManualServicePrincipal, UserAssignedIdentityCredential, or WorkloadIdentity.
163163
enum:
164164
- ServicePrincipal
165165
- UserAssignedMSI
166166
- ManualServicePrincipal
167167
- ServicePrincipalCertificate
168168
- WorkloadIdentity
169+
- UserAssignedIdentityCredential
170+
type: string
171+
userAssignedIdentityCredentialsCloudType:
172+
description: |-
173+
UserAssignedIdentityCredentialsCloudType is used with UserAssignedIdentityCredentialsPath to specify the Cloud
174+
type. Can only be one of the following values: public, china, or usgovernment
175+
If a value is not specified, defaults to public
176+
type: string
177+
userAssignedIdentityCredentialsPath:
178+
description: |-
179+
UserAssignedIdentityCredentialsPath is the path where an existing JSON file exists containing the JSON format of
180+
a UserAssignedIdentityCredentials struct.
181+
See the msi-dataplane for more details on UserAssignedIdentityCredentials - https://github.com/Azure/msi-dataplane/blob/main/pkg/dataplane/internal/client/models.go#L125
169182
type: string
170183
required:
171184
- clientID

go.mod

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ require (
2121
github.com/Azure/azure-service-operator/v2 v2.11.0
2222
github.com/Azure/go-autorest/autorest v0.11.30
2323
github.com/Azure/go-autorest/autorest/azure/auth v0.5.13
24+
github.com/Azure/msi-dataplane v0.4.0
2425
github.com/asaskevich/govalidator/v11 v11.0.2-0.20250122183457-e11347878e23
2526
github.com/blang/semver v3.5.1+incompatible
2627
github.com/go-logr/logr v1.4.2
@@ -71,7 +72,7 @@ require (
7172
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/keyvault/armkeyvault v1.4.0 // indirect
7273
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/storage/armstorage v1.6.0 // indirect
7374
github.com/Azure/go-ansiterm v0.0.0-20210617225240-d185dfc1b5a1 // indirect
74-
github.com/antlr4-go/antlr/v4 v4.13.0 // indirect
75+
github.com/antlr4-go/antlr/v4 v4.13.1 // indirect
7576
github.com/asaskevich/govalidator v0.0.0-20230301143203-a9d515a09cc2 // indirect
7677
github.com/fxamacker/cbor/v2 v2.7.0 // indirect
7778
github.com/go-task/slim-sprig/v3 v3.0.0 // indirect
@@ -131,7 +132,7 @@ require (
131132
github.com/evanphx/json-patch/v5 v5.9.11
132133
github.com/fatih/camelcase v1.0.0 // indirect
133134
github.com/felixge/httpsnoop v1.0.4 // indirect
134-
github.com/fsnotify/fsnotify v1.7.0 // indirect
135+
github.com/fsnotify/fsnotify v1.8.0 // indirect
135136
github.com/go-errors/errors v1.4.2 // indirect
136137
github.com/go-logr/stdr v1.2.2 // indirect
137138
github.com/go-openapi/jsonpointer v0.21.0 // indirect

0 commit comments

Comments
 (0)