Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 32 additions & 4 deletions internal/bucket/azure/blob.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ type options struct {
secret *corev1.Secret
proxyURL *url.URL
withoutCredentials bool
objectLevelIdentity bool
withoutRetries bool
authOpts []auth.Option
}
Expand All @@ -96,6 +97,16 @@ type options struct {
// This is a test-only option useful for testing the client with HTTP
// endpoints (without TLS) alongside all the other options unrelated to
// credentials.
// WithObjectLevelIdentity signals that the Bucket requests object-level workload
// identity, i.e. it sets .spec.serviceAccountName. Without this signal, and without
// controller-level identity configured in the environment, no token credential is
// added to the chain and the bucket is treated as publicly reachable.
func WithObjectLevelIdentity() Option {
return func(o *options) {
o.objectLevelIdentity = true
}
}

func withoutCredentials() Option {
return func(o *options) {
o.withoutCredentials = true
Expand Down Expand Up @@ -202,7 +213,7 @@ func NewClient(ctx context.Context, obj *sourcev1.Bucket, opts ...Option) (c *Bl
// Compose token chain based on environment.
// This functions as a replacement for azidentity.NewDefaultAzureCredential
// to not shell out.
token, err = chainCredentialWithSecret(ctx, o.secret, o.authOpts...)
token, err = chainCredentialWithSecret(ctx, o.secret, o.objectLevelIdentity, o.authOpts...)
if err != nil {
err = fmt.Errorf("failed to create environment credential chain: %w", err)
return nil, err
Expand Down Expand Up @@ -502,7 +513,7 @@ func sasTokenFromSecret(ep string, secret *corev1.Secret) (string, error) {
// - azidentity.ManagedIdentityCredential with defaults.
//
// If no valid token is created, it returns nil.
func chainCredentialWithSecret(ctx context.Context, secret *corev1.Secret, opts ...auth.Option) (azcore.TokenCredential, error) {
func chainCredentialWithSecret(ctx context.Context, secret *corev1.Secret, objectLevelIdentity bool, opts ...auth.Option) (azcore.TokenCredential, error) {
var creds []azcore.TokenCredential

credOpts := &azidentity.EnvironmentCredentialOptions{}
Expand All @@ -515,8 +526,13 @@ func chainCredentialWithSecret(ctx context.Context, secret *corev1.Secret, opts
if token, _ := azidentity.NewEnvironmentCredential(credOpts); token != nil {
creds = append(creds, token)
}
if token := azureauth.NewTokenCredential(ctx, opts...); token != nil {
creds = append(creds, token)
// azureauth.NewTokenCredential never returns nil, so its presence in the chain
// cannot signal that any identity is actually configured. Add it only when an
// identity has been requested, either per-object via .spec.serviceAccountName or
// controller-wide via the environment. Otherwise the chain stays empty and the
// caller falls back to an unauthenticated client, as documented for public buckets.
if objectLevelIdentity || hasControllerLevelIdentity() {
creds = append(creds, azureauth.NewTokenCredential(ctx, opts...))
}

if len(creds) > 0 {
Expand All @@ -526,6 +542,18 @@ func chainCredentialWithSecret(ctx context.Context, secret *corev1.Secret, opts
return nil, nil
}

// hasControllerLevelIdentity reports whether the controller's environment carries an
// Azure identity. These are the variables the workload and managed identity flows in
// fluxcd/pkg/auth read; if none is set there is nothing for a token credential to use.
func hasControllerLevelIdentity() bool {
for _, env := range []string{"AZURE_CLIENT_ID", "AZURE_FEDERATED_TOKEN_FILE"} {
if os.Getenv(env) != "" {
return true
}
}
return false
}

// extractAccountNameFromEndpoint extracts the Azure account name from the
// provided endpoint URL. It parses the endpoint as a URL, and returns the
// first subdomain as the assumed account name.
Expand Down
31 changes: 27 additions & 4 deletions internal/bucket/azure/blob_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -657,11 +657,34 @@ func TestBlobClient_VisitObjects_MissingFields(t *testing.T) {
}

func Test_chainCredentialWithSecret(t *testing.T) {
g := NewWithT(t)
t.Run("no secret and no identity yields no credential", func(t *testing.T) {
g := NewWithT(t)

got, err := chainCredentialWithSecret(t.Context(), nil)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(got).To(BeAssignableToTypeOf(&azidentity.ChainedTokenCredential{}))
// Documented behaviour: "If no chain can be established, the bucket is
// assumed to be publicly reachable." The caller relies on a nil credential
// to build an unauthenticated client.
got, err := chainCredentialWithSecret(t.Context(), nil, false)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(got).To(BeNil())
})

t.Run("object-level identity yields a credential", func(t *testing.T) {
g := NewWithT(t)

got, err := chainCredentialWithSecret(t.Context(), nil, true)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(got).To(BeAssignableToTypeOf(&azidentity.ChainedTokenCredential{}))
})

t.Run("controller-level identity yields a credential", func(t *testing.T) {
g := NewWithT(t)

t.Setenv("AZURE_CLIENT_ID", "00000000-0000-0000-0000-000000000000")

got, err := chainCredentialWithSecret(t.Context(), nil, false)
g.Expect(err).ToNot(HaveOccurred())
g.Expect(got).To(BeAssignableToTypeOf(&azidentity.ChainedTokenCredential{}))
})
}

func Test_extractAccountNameFromEndpoint1(t *testing.T) {
Expand Down
3 changes: 3 additions & 0 deletions internal/controller/bucket_controller.go
Original file line number Diff line number Diff line change
Expand Up @@ -925,6 +925,9 @@ func (r *BucketReconciler) createBucketProvider(ctx context.Context, obj *source
if creds.proxyURL != nil {
opts = append(opts, azure.WithProxyURL(creds.proxyURL))
}
if obj.Spec.ServiceAccountName != "" {
opts = append(opts, azure.WithObjectLevelIdentity())
}
opts = append(opts, azure.WithAuth(authOpts...))
return azure.NewClient(ctx, obj, opts...)

Expand Down