Skip to content

Commit f2bb687

Browse files
committed
[RFC-0010] Add multi-tenant workload identity support for Azure Blob Storage
Signed-off-by: Dipti Pai <[email protected]>
1 parent 12b5f6f commit f2bb687

File tree

6 files changed

+44
-97
lines changed

6 files changed

+44
-97
lines changed

api/v1/bucket_types.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ const (
5252
// +kubebuilder:validation:XValidation:rule="self.provider != 'generic' || !has(self.sts) || self.sts.provider == 'ldap'", message="'ldap' is the only supported STS provider for the 'generic' Bucket provider"
5353
// +kubebuilder:validation:XValidation:rule="!has(self.sts) || self.sts.provider != 'aws' || !has(self.sts.secretRef)", message="spec.sts.secretRef is not required for the 'aws' STS provider"
5454
// +kubebuilder:validation:XValidation:rule="!has(self.sts) || self.sts.provider != 'aws' || !has(self.sts.certSecretRef)", message="spec.sts.certSecretRef is not required for the 'aws' STS provider"
55-
// +kubebuilder:validation:XValidation:rule="self.provider == 'gcp' || self.provider == 'aws' || !has(self.serviceAccountName)", message="ServiceAccountName is only supported for the 'gcp' and 'aws' Bucket providers"
55+
// +kubebuilder:validation:XValidation:rule="self.provider != 'generic' || !has(self.serviceAccountName)", message="ServiceAccountName is not supported for the 'generic' Bucket provider"
5656
// +kubebuilder:validation:XValidation:rule="!has(self.secretRef) || !has(self.serviceAccountName)", message="cannot set both .spec.secretRef and .spec.serviceAccountName"
5757
type BucketSpec struct {
5858
// Provider of the object storage bucket.

config/crd/bases/source.toolkit.fluxcd.io_buckets.yaml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -239,9 +239,9 @@ spec:
239239
rule: '!has(self.sts) || self.sts.provider != ''aws'' || !has(self.sts.secretRef)'
240240
- message: spec.sts.certSecretRef is not required for the 'aws' STS provider
241241
rule: '!has(self.sts) || self.sts.provider != ''aws'' || !has(self.sts.certSecretRef)'
242-
- message: ServiceAccountName is only supported for the 'gcp' and 'aws'
243-
Bucket providers
244-
rule: self.provider == 'gcp' || self.provider == 'aws' || !has(self.serviceAccountName)
242+
- message: ServiceAccountName is not supported for the 'generic' Bucket
243+
provider
244+
rule: self.provider != 'generic' || !has(self.serviceAccountName)
245245
- message: cannot set both .spec.secretRef and .spec.serviceAccountName
246246
rule: '!has(self.secretRef) || !has(self.serviceAccountName)'
247247
status:

docs/spec/v1/buckets.md

Lines changed: 21 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -567,83 +567,39 @@ metadata:
567567
spec:
568568
interval: 5m0s
569569
provider: azure
570-
bucketName: testsas
571-
endpoint: https://testfluxsas.blob.core.windows.net
570+
bucketName: testwi
571+
endpoint: https://testfluxwi.blob.core.windows.net
572572
```
573573

574-
##### Deprecated: Managed Identity with AAD Pod Identity
574+
##### Azure Object-Level Workload Identity example
575575

576-
If you are using [aad pod identity](https://azure.github.io/aad-pod-identity/docs),
577-
You need to create an Azure Identity and give it access to Azure Blob Storage.
578-
579-
```sh
580-
export IDENTITY_NAME="blob-access"
581-
582-
az role assignment create --role "Storage Blob Data Reader" \
583-
--assignee-object-id "$(az identity show -n $IDENTITY_NAME -o tsv --query principalId -g $RESOURCE_GROUP)" \
584-
--scope "/subscriptions/<SUBSCRIPTION-ID>/resourceGroups/$RESOURCE_GROUP/providers/Microsoft.Storage/storageAccounts/<account-name>/blobServices/default/containers/<container-name>"
585-
586-
export IDENTITY_CLIENT_ID="$(az identity show -n ${IDENTITY_NAME} -g ${RESOURCE_GROUP} -otsv --query clientId)"
587-
export IDENTITY_RESOURCE_ID="$(az identity show -n ${IDENTITY_NAME} -otsv --query id)"
588-
```
589-
590-
Create an AzureIdentity object that references the identity created above:
576+
**Note:** To use Object-Level Workload Identity (`.spec.serviceAccountName` with
577+
cloud providers), the controller feature gate `ObjectLevelWorkloadIdentity` must
578+
be enabled.
591579

592580
```yaml
593581
---
594-
apiVersion: aadpodidentity.k8s.io/v1
595-
kind: AzureIdentity
596-
metadata:
597-
name: # source-controller label will match this name
598-
namespace: flux-system
599-
spec:
600-
clientID: <IDENTITY_CLIENT_ID>
601-
resourceID: <IDENTITY_RESOURCE_ID>
602-
type: 0 # user-managed identity
603-
```
604-
605-
Create an AzureIdentityBinding object that binds Pods with a specific selector
606-
with the AzureIdentity created:
607-
608-
```yaml
609-
apiVersion: "aadpodidentity.k8s.io/v1"
610-
kind: AzureIdentityBinding
611-
metadata:
612-
name: ${IDENTITY_NAME}-binding
613-
spec:
614-
azureIdentity: ${IDENTITY_NAME}
615-
selector: ${IDENTITY_NAME}
616-
```
617-
618-
Label the source-controller Deployment correctly so that it can match an identity binding:
619-
620-
```yaml
621-
apiVersion: apps/v1
622-
kind: Deployment
623-
metadata:
624-
name: kustomize-controller
625-
namespace: flux-system
626-
spec:
627-
template:
628-
metadata:
629-
labels:
630-
aadpodidbinding: ${IDENTITY_NAME} # match the AzureIdentity name
631-
```
632-
633-
If you have set up aad-pod-identity correctly and labeled the source-controller
634-
Deployment, then you don't need to reference a Secret.
635-
636-
```yaml
637582
apiVersion: source.toolkit.fluxcd.io/v1
638583
kind: Bucket
639584
metadata:
640-
name: azure-bucket
641-
namespace: flux-system
585+
name: azure-object-level-workload-identity
586+
namespace: default
642587
spec:
643588
interval: 5m0s
644589
provider: azure
645-
bucketName: testsas
646-
endpoint: https://testfluxsas.blob.core.windows.net
590+
bucketName: testwi
591+
endpoint: https://testfluxwi.blob.core.windows.net
592+
serviceAccountName: azure-workload-identity-sa
593+
timeout: 30s
594+
---
595+
apiVersion: v1
596+
kind: ServiceAccount
597+
metadata:
598+
name: azure-workload-identity-sa
599+
namespace: default
600+
annotations:
601+
azure.workload.identity/client-id: <AZURE_CLIENT_ID>
602+
azure.workload.identity/tenant-id: <AZURE_TENANT_ID>
647603
```
648604

649605
##### Azure Blob SAS Token example

internal/bucket/azure/blob.go

Lines changed: 14 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ import (
3737
corev1 "k8s.io/api/core/v1"
3838
ctrl "sigs.k8s.io/controller-runtime"
3939

40+
"github.com/fluxcd/pkg/auth"
41+
azureauth "github.com/fluxcd/pkg/auth/azure"
4042
"github.com/fluxcd/pkg/masktoken"
4143

4244
sourcev1 "github.com/fluxcd/source-controller/api/v1"
@@ -87,6 +89,7 @@ type options struct {
8789
proxyURL *url.URL
8890
withoutCredentials bool
8991
withoutRetries bool
92+
authOpts []auth.Option
9093
}
9194

9295
// withoutCredentials forces the BlobClient to not use any credentials.
@@ -107,6 +110,13 @@ func withoutRetries() Option {
107110
}
108111
}
109112

113+
// WithAuth sets the auth options for workload identity authentication.
114+
func WithAuth(authOpts ...auth.Option) Option {
115+
return func(o *options) {
116+
o.authOpts = authOpts
117+
}
118+
}
119+
110120
// NewClient creates a new Azure Blob storage client.
111121
// The credential config on the client is set based on the data from the
112122
// Bucket and Secret. It detects credentials in the Secret in the following
@@ -130,7 +140,7 @@ func withoutRetries() Option {
130140
//
131141
// If no credentials are found, and the azidentity.ChainedTokenCredential can
132142
// not be established. A simple client without credentials is returned.
133-
func NewClient(obj *sourcev1.Bucket, opts ...Option) (c *BlobClient, err error) {
143+
func NewClient(ctx context.Context, obj *sourcev1.Bucket, opts ...Option) (c *BlobClient, err error) {
134144
c = &BlobClient{}
135145

136146
var o options
@@ -192,7 +202,7 @@ func NewClient(obj *sourcev1.Bucket, opts ...Option) (c *BlobClient, err error)
192202
// Compose token chain based on environment.
193203
// This functions as a replacement for azidentity.NewDefaultAzureCredential
194204
// to not shell out.
195-
token, err = chainCredentialWithSecret(o.secret)
205+
token, err = chainCredentialWithSecret(ctx, o.secret, o.authOpts...)
196206
if err != nil {
197207
err = fmt.Errorf("failed to create environment credential chain: %w", err)
198208
return nil, err
@@ -470,7 +480,7 @@ func sasTokenFromSecret(ep string, secret *corev1.Secret) (string, error) {
470480
// - azidentity.ManagedIdentityCredential with defaults.
471481
//
472482
// If no valid token is created, it returns nil.
473-
func chainCredentialWithSecret(secret *corev1.Secret) (azcore.TokenCredential, error) {
483+
func chainCredentialWithSecret(ctx context.Context, secret *corev1.Secret, opts ...auth.Option) (azcore.TokenCredential, error) {
474484
var creds []azcore.TokenCredential
475485

476486
credOpts := &azidentity.EnvironmentCredentialOptions{}
@@ -483,28 +493,7 @@ func chainCredentialWithSecret(secret *corev1.Secret) (azcore.TokenCredential, e
483493
if token, _ := azidentity.NewEnvironmentCredential(credOpts); token != nil {
484494
creds = append(creds, token)
485495
}
486-
if clientID := os.Getenv("AZURE_CLIENT_ID"); clientID != "" {
487-
if file, ok := os.LookupEnv("AZURE_FEDERATED_TOKEN_FILE"); ok {
488-
if _, ok := os.LookupEnv("AZURE_AUTHORITY_HOST"); ok {
489-
if tenantID, ok := os.LookupEnv("AZURE_TENANT_ID"); ok {
490-
if token, _ := azidentity.NewWorkloadIdentityCredential(&azidentity.WorkloadIdentityCredentialOptions{
491-
ClientID: clientID,
492-
TenantID: tenantID,
493-
TokenFilePath: file,
494-
}); token != nil {
495-
creds = append(creds, token)
496-
}
497-
}
498-
}
499-
}
500-
501-
if token, _ := azidentity.NewManagedIdentityCredential(&azidentity.ManagedIdentityCredentialOptions{
502-
ID: azidentity.ClientID(clientID),
503-
}); token != nil {
504-
creds = append(creds, token)
505-
}
506-
}
507-
if token, _ := azidentity.NewManagedIdentityCredential(nil); token != nil {
496+
if token := azureauth.NewTokenCredential(ctx, opts...); token != nil {
508497
creds = append(creds, token)
509498
}
510499

internal/bucket/azure/blob_test.go

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,8 @@ func TestNewClientAndBucketExistsWithProxy(t *testing.T) {
106106
},
107107
}
108108

109-
client, err := NewClient(bucket,
109+
client, err := NewClient(t.Context(),
110+
bucket,
110111
WithProxyURL(tt.proxyURL),
111112
withoutCredentials(),
112113
withoutRetries())
@@ -472,7 +473,7 @@ func Test_sasTokenFromSecret(t *testing.T) {
472473
func Test_chainCredentialWithSecret(t *testing.T) {
473474
g := NewWithT(t)
474475

475-
got, err := chainCredentialWithSecret(nil)
476+
got, err := chainCredentialWithSecret(t.Context(), nil, nil)
476477
g.Expect(err).ToNot(HaveOccurred())
477478
g.Expect(got).To(BeAssignableToTypeOf(&azidentity.ChainedTokenCredential{}))
478479
}

internal/controller/bucket_controller.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -920,7 +920,8 @@ func (r *BucketReconciler) createBucketProvider(ctx context.Context, obj *source
920920
if creds.proxyURL != nil {
921921
opts = append(opts, azure.WithProxyURL(creds.proxyURL))
922922
}
923-
return azure.NewClient(obj, opts...)
923+
opts = append(opts, azure.WithAuth(authOpts...))
924+
return azure.NewClient(ctx, obj, opts...)
924925

925926
default:
926927
if err := minio.ValidateSecret(creds.secret); err != nil {

0 commit comments

Comments
 (0)