Skip to content

Commit 3e0213c

Browse files
committed
[RFC-0010] Add multi-tenant workload identity support for AWS Bucket
Signed-off-by: cappyzawa <[email protected]>
1 parent bd6d090 commit 3e0213c

File tree

7 files changed

+149
-46
lines changed

7 files changed

+149
-46
lines changed

api/v1/bucket_types.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ const (
3333
// BucketProviderGeneric for any S3 API compatible storage Bucket.
3434
BucketProviderGeneric string = "generic"
3535
// BucketProviderAmazon for an AWS S3 object storage Bucket.
36-
// Provides support for retrieving credentials from the AWS EC2 service.
36+
// Provides support for retrieving credentials from the AWS EC2 service
37+
// and workload identity authentication.
3738
BucketProviderAmazon string = "aws"
3839
// BucketProviderGoogle for a Google Cloud Storage Bucket.
3940
// Provides support for authentication using a workload identity.
@@ -51,7 +52,7 @@ const (
5152
// +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"
5253
// +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"
5354
// +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"
54-
// +kubebuilder:validation:XValidation:rule="self.provider == 'gcp' || !has(self.serviceAccountName)", message="ServiceAccountName is only supported for the 'gcp' Bucket 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"
5556
// +kubebuilder:validation:XValidation:rule="!has(self.secretRef) || !has(self.serviceAccountName)", message="cannot set both .spec.secretRef and .spec.serviceAccountName"
5657
type BucketSpec struct {
5758
// Provider of the object storage bucket.
@@ -96,7 +97,8 @@ type BucketSpec struct {
9697
SecretRef *meta.LocalObjectReference `json:"secretRef,omitempty"`
9798

9899
// ServiceAccountName is the name of the Kubernetes ServiceAccount used to authenticate
99-
// the bucket. For more information about workload identity:
100+
// the bucket. This field is only supported for the 'gcp' and 'aws' providers.
101+
// For more information about workload identity:
100102
// https://fluxcd.io/flux/components/source/buckets/#workload-identity
101103
// +optional
102104
ServiceAccountName string `json:"serviceAccountName,omitempty"`

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

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,8 @@ spec:
145145
serviceAccountName:
146146
description: |-
147147
ServiceAccountName is the name of the Kubernetes ServiceAccount used to authenticate
148-
the bucket. For more information about workload identity:
148+
the bucket. This field is only supported for the 'gcp' and 'aws' providers.
149+
For more information about workload identity:
149150
https://fluxcd.io/flux/components/source/buckets/#workload-identity
150151
type: string
151152
sts:
@@ -238,8 +239,9 @@ spec:
238239
rule: '!has(self.sts) || self.sts.provider != ''aws'' || !has(self.sts.secretRef)'
239240
- message: spec.sts.certSecretRef is not required for the 'aws' STS provider
240241
rule: '!has(self.sts) || self.sts.provider != ''aws'' || !has(self.sts.certSecretRef)'
241-
- message: ServiceAccountName is only supported for the 'gcp' Bucket provider
242-
rule: self.provider == 'gcp' || !has(self.serviceAccountName)
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)
243245
- message: cannot set both .spec.secretRef and .spec.serviceAccountName
244246
rule: '!has(self.secretRef) || !has(self.serviceAccountName)'
245247
status:

docs/api/v1/source.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -190,7 +190,8 @@ string
190190
<td>
191191
<em>(Optional)</em>
192192
<p>ServiceAccountName is the name of the Kubernetes ServiceAccount used to authenticate
193-
the bucket. For more information about workload identity:
193+
the bucket. This field is only supported for the &lsquo;gcp&rsquo; and &lsquo;aws&rsquo; providers.
194+
For more information about workload identity:
194195
<a href="https://fluxcd.io/flux/components/source/buckets/#workload-identity">https://fluxcd.io/flux/components/source/buckets/#workload-identity</a></p>
195196
</td>
196197
</tr>
@@ -1646,7 +1647,8 @@ string
16461647
<td>
16471648
<em>(Optional)</em>
16481649
<p>ServiceAccountName is the name of the Kubernetes ServiceAccount used to authenticate
1649-
the bucket. For more information about workload identity:
1650+
the bucket. This field is only supported for the &lsquo;gcp&rsquo; and &lsquo;aws&rsquo; providers.
1651+
For more information about workload identity:
16501652
<a href="https://fluxcd.io/flux/components/source/buckets/#workload-identity">https://fluxcd.io/flux/components/source/buckets/#workload-identity</a></p>
16511653
</td>
16521654
</tr>

docs/spec/v1/buckets.md

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,55 @@ data:
273273
secretkey: <BASE64>
274274
```
275275

276+
##### AWS Controller-Level Workload Identity example
277+
278+
```yaml
279+
---
280+
apiVersion: source.toolkit.fluxcd.io/v1
281+
kind: Bucket
282+
metadata:
283+
name: aws-controller-level-workload-identity
284+
namespace: default
285+
spec:
286+
interval: 5m0s
287+
provider: aws
288+
bucketName: podinfo
289+
endpoint: s3.amazonaws.com
290+
region: us-east-1
291+
timeout: 30s
292+
```
293+
294+
##### AWS Object-Level Workload Identity example
295+
296+
**Note:** To use Object-Level Workload Identity (`.spec.serviceAccountName` with
297+
cloud providers), the controller feature gate `ObjectLevelWorkloadIdentity` must
298+
be enabled.
299+
300+
```yaml
301+
---
302+
apiVersion: source.toolkit.fluxcd.io/v1
303+
kind: Bucket
304+
metadata:
305+
name: aws-object-level-workload-identity
306+
namespace: default
307+
spec:
308+
interval: 5m0s
309+
provider: aws
310+
bucketName: podinfo
311+
endpoint: s3.amazonaws.com
312+
region: us-east-1
313+
serviceAccountName: aws-workload-identity-sa
314+
timeout: 30s
315+
---
316+
apiVersion: v1
317+
kind: ServiceAccount
318+
metadata:
319+
name: aws-workload-identity-sa
320+
namespace: default
321+
annotations:
322+
eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/flux-bucket-role
323+
```
324+
276325
#### Azure
277326

278327
When a Bucket's `.spec.provider` is set to `azure`, the source-controller will

internal/bucket/minio/minio.go

Lines changed: 37 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,9 @@ import (
3030
"github.com/minio/minio-go/v7/pkg/s3utils"
3131
corev1 "k8s.io/api/core/v1"
3232

33+
"github.com/fluxcd/pkg/auth"
34+
awsauth "github.com/fluxcd/pkg/auth/aws"
35+
3336
sourcev1 "github.com/fluxcd/source-controller/api/v1"
3437
)
3538

@@ -46,6 +49,7 @@ type options struct {
4649
tlsConfig *tls.Config
4750
stsTLSConfig *tls.Config
4851
proxyURL *url.URL
52+
authOpts []auth.Option
4953
}
5054

5155
// Option is a function that configures the Minio client.
@@ -86,8 +90,15 @@ func WithSTSTLSConfig(tlsConfig *tls.Config) Option {
8690
}
8791
}
8892

93+
// WithAuth sets the auth options for workload identity authentication.
94+
func WithAuth(authOpts ...auth.Option) Option {
95+
return func(o *options) {
96+
o.authOpts = authOpts
97+
}
98+
}
99+
89100
// NewClient creates a new Minio storage client.
90-
func NewClient(bucket *sourcev1.Bucket, opts ...Option) (*MinioClient, error) {
101+
func NewClient(ctx context.Context, bucket *sourcev1.Bucket, opts ...Option) (*MinioClient, error) {
91102
var o options
92103
for _, opt := range opts {
93104
opt(&o)
@@ -105,7 +116,11 @@ func NewClient(bucket *sourcev1.Bucket, opts ...Option) (*MinioClient, error) {
105116
case o.secret != nil:
106117
minioOpts.Creds = newCredsFromSecret(o.secret)
107118
case bucketProvider == sourcev1.BucketProviderAmazon:
108-
minioOpts.Creds = newAWSCreds(bucket, o.proxyURL)
119+
creds, err := newAWSCreds(ctx, &o)
120+
if err != nil {
121+
return nil, err
122+
}
123+
minioOpts.Creds = creds
109124
case bucketProvider == sourcev1.BucketProviderGeneric:
110125
minioOpts.Creds = newGenericCreds(bucket, &o)
111126
}
@@ -159,23 +174,27 @@ func newCredsFromSecret(secret *corev1.Secret) *credentials.Credentials {
159174
}
160175

161176
// newAWSCreds creates a new Minio credentials object for `aws` bucket provider.
162-
func newAWSCreds(bucket *sourcev1.Bucket, proxyURL *url.URL) *credentials.Credentials {
163-
stsEndpoint := ""
164-
if sts := bucket.Spec.STS; sts != nil {
165-
stsEndpoint = sts.Endpoint
166-
}
167-
168-
creds := credentials.NewIAM(stsEndpoint)
169-
if proxyURL != nil {
170-
transport := http.DefaultTransport.(*http.Transport).Clone()
171-
transport.Proxy = http.ProxyURL(proxyURL)
172-
client := &http.Client{Transport: transport}
173-
creds = credentials.New(&credentials.IAM{
174-
Client: client,
175-
Endpoint: stsEndpoint,
176-
})
177+
//
178+
// This function is only called when Secret authentication is not available.
179+
//
180+
// Uses AWS SDK's config.LoadDefaultConfig() which supports:
181+
// - Workload Identity (IRSA/EKS Pod Identity)
182+
// - EC2 instance profiles
183+
// - Environment variables
184+
// - Shared credentials files
185+
// - All other AWS SDK authentication methods
186+
func newAWSCreds(ctx context.Context, o *options) (*credentials.Credentials, error) {
187+
awsCredsProvider := awsauth.NewCredentialsProvider(ctx, o.authOpts...)
188+
awsCreds, err := awsCredsProvider.Retrieve(ctx)
189+
if err != nil {
190+
return nil, fmt.Errorf("AWS authentication failed: %w", err)
177191
}
178-
return creds
192+
193+
return credentials.NewStaticV4(
194+
awsCreds.AccessKeyID,
195+
awsCreds.SecretAccessKey,
196+
awsCreds.SessionToken,
197+
), nil
179198
}
180199

181200
// newGenericCreds creates a new Minio credentials object for the `generic` bucket provider.

internal/bucket/minio/minio_test.go

Lines changed: 38 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,8 @@ var (
7676
testServerCert string
7777
// testServerKey is the path to the server key used to start the Minio and STS servers.
7878
testServerKey string
79+
// ctx is the common context used in tests.
80+
ctx context.Context
7981
)
8082

8183
var (
@@ -126,6 +128,9 @@ var (
126128
)
127129

128130
func TestMain(m *testing.M) {
131+
// Initialize common test context
132+
ctx = context.Background()
133+
129134
// Uses a sensible default on Windows (TCP/HTTP) and Linux/MacOS (socket)
130135
pool, err := dockertest.NewPool("")
131136
if err != nil {
@@ -173,7 +178,7 @@ func TestMain(m *testing.M) {
173178
testMinioAddress = fmt.Sprintf("127.0.0.1:%v", resource.GetPort("9000/tcp"))
174179

175180
// Construct a Minio client using the address of the Minio server.
176-
testMinioClient, err = NewClient(bucketStub(bucket, testMinioAddress),
181+
testMinioClient, err = NewClient(ctx, bucketStub(bucket, testMinioAddress),
177182
WithSecret(secret.DeepCopy()),
178183
WithTLSConfig(testTLSConfig))
179184
if err != nil {
@@ -197,7 +202,6 @@ func TestMain(m *testing.M) {
197202
log.Fatalf("could not connect to docker: %s", err)
198203
}
199204

200-
ctx := context.Background()
201205
createBucket(ctx)
202206
addObjectToBucket(ctx)
203207
run := m.Run()
@@ -208,43 +212,62 @@ func TestMain(m *testing.M) {
208212
}
209213

210214
func TestNewClient(t *testing.T) {
211-
minioClient, err := NewClient(bucketStub(bucket, testMinioAddress),
215+
minioClient, err := NewClient(ctx, bucketStub(bucket, testMinioAddress),
212216
WithSecret(secret.DeepCopy()),
213217
WithTLSConfig(testTLSConfig))
214218
assert.NilError(t, err)
215219
assert.Assert(t, minioClient != nil)
216220
}
217221

218222
func TestNewClientEmptySecret(t *testing.T) {
219-
minioClient, err := NewClient(bucketStub(bucket, testMinioAddress),
223+
minioClient, err := NewClient(ctx, bucketStub(bucket, testMinioAddress),
220224
WithSecret(emptySecret.DeepCopy()),
221225
WithTLSConfig(testTLSConfig))
222226
assert.NilError(t, err)
223227
assert.Assert(t, minioClient != nil)
224228
}
225229

226-
func TestNewClientAwsProvider(t *testing.T) {
227-
minioClient, err := NewClient(bucketStub(bucketAwsProvider, testMinioAddress))
228-
assert.NilError(t, err)
229-
assert.Assert(t, minioClient != nil)
230+
func TestNewClientAWSProvider(t *testing.T) {
231+
t.Run("with secret", func(t *testing.T) {
232+
validSecret := corev1.Secret{
233+
ObjectMeta: v1.ObjectMeta{
234+
Name: "valid-secret",
235+
Namespace: "default",
236+
},
237+
Data: map[string][]byte{
238+
"accesskey": []byte(testMinioRootUser),
239+
"secretkey": []byte(testMinioRootPassword),
240+
},
241+
Type: "Opaque",
242+
}
243+
244+
bucket := bucketStub(bucketAwsProvider, testMinioAddress)
245+
minioClient, err := NewClient(ctx, bucket, WithSecret(&validSecret))
246+
assert.NilError(t, err)
247+
assert.Assert(t, minioClient != nil)
248+
})
249+
250+
t.Run("without secret", func(t *testing.T) {
251+
bucket := bucketStub(bucketAwsProvider, testMinioAddress)
252+
minioClient, err := NewClient(ctx, bucket)
253+
assert.ErrorContains(t, err, "AWS authentication failed")
254+
assert.Assert(t, minioClient == nil)
255+
})
230256
}
231257

232258
func TestBucketExists(t *testing.T) {
233-
ctx := context.Background()
234259
exists, err := testMinioClient.BucketExists(ctx, bucketName)
235260
assert.NilError(t, err)
236261
assert.Assert(t, exists)
237262
}
238263

239264
func TestBucketNotExists(t *testing.T) {
240-
ctx := context.Background()
241265
exists, err := testMinioClient.BucketExists(ctx, "notexistsbucket")
242266
assert.NilError(t, err)
243267
assert.Assert(t, !exists)
244268
}
245269

246270
func TestFGetObject(t *testing.T) {
247-
ctx := context.Background()
248271
tempDir := t.TempDir()
249272
path := filepath.Join(tempDir, sourceignore.IgnoreFile)
250273
_, err := testMinioClient.FGetObject(ctx, bucketName, objectName, path)
@@ -286,7 +309,7 @@ func TestNewClientAndFGetObjectWithSTSEndpoint(t *testing.T) {
286309
Handler: awsSTSHandler,
287310
}
288311
go awsSTSServer.Serve(awsSTSListener)
289-
defer awsSTSServer.Shutdown(context.Background())
312+
defer awsSTSServer.Shutdown(ctx)
290313

291314
// start a mock LDAP STS server
292315
ldapSTSListener, ldapSTSAddr, ldapSTSPort := testlistener.New(t)
@@ -313,7 +336,7 @@ func TestNewClientAndFGetObjectWithSTSEndpoint(t *testing.T) {
313336
Handler: ldapSTSHandler,
314337
}
315338
go ldapSTSServer.ServeTLS(ldapSTSListener, testServerCert, testServerKey)
316-
defer ldapSTSServer.Shutdown(context.Background())
339+
defer ldapSTSServer.Shutdown(ctx)
317340

318341
// start proxy
319342
proxyAddr, proxyPort := testproxy.New(t)
@@ -448,11 +471,10 @@ func TestNewClientAndFGetObjectWithSTSEndpoint(t *testing.T) {
448471
opts := tt.opts
449472
opts = append(opts, WithTLSConfig(testTLSConfig))
450473

451-
minioClient, err := NewClient(bucket, opts...)
474+
minioClient, err := NewClient(ctx, bucket, opts...)
452475
assert.NilError(t, err)
453476
assert.Assert(t, minioClient != nil)
454477

455-
ctx := context.Background()
456478
path := filepath.Join(t.TempDir(), sourceignore.IgnoreFile)
457479
_, err = minioClient.FGetObject(ctx, bucketName, objectName, path)
458480
if tt.err != "" {
@@ -487,13 +509,12 @@ func TestNewClientAndFGetObjectWithProxy(t *testing.T) {
487509
// run test
488510
for _, tt := range tests {
489511
t.Run(tt.name, func(t *testing.T) {
490-
minioClient, err := NewClient(bucketStub(bucket, testMinioAddress),
512+
minioClient, err := NewClient(ctx, bucketStub(bucket, testMinioAddress),
491513
WithSecret(secret.DeepCopy()),
492514
WithTLSConfig(testTLSConfig),
493515
WithProxyURL(tt.proxyURL))
494516
assert.NilError(t, err)
495517
assert.Assert(t, minioClient != nil)
496-
ctx := context.Background()
497518
tempDir := t.TempDir()
498519
path := filepath.Join(tempDir, sourceignore.IgnoreFile)
499520
_, err = minioClient.FGetObject(ctx, bucketName, objectName, path)
@@ -507,7 +528,6 @@ func TestNewClientAndFGetObjectWithProxy(t *testing.T) {
507528
}
508529

509530
func TestFGetObjectNotExists(t *testing.T) {
510-
ctx := context.Background()
511531
tempDir := t.TempDir()
512532
badKey := "invalid.txt"
513533
path := filepath.Join(tempDir, badKey)
@@ -530,7 +550,6 @@ func TestVisitObjects(t *testing.T) {
530550
}
531551

532552
func TestVisitObjectsErr(t *testing.T) {
533-
ctx := context.Background()
534553
badBucketName := "bad-bucket"
535554
err := testMinioClient.VisitObjects(ctx, badBucketName, prefix, func(string, string) error {
536555
return nil

0 commit comments

Comments
 (0)