Skip to content

Commit fc5dc4d

Browse files
author
Paulo Gomes
authored
Merge pull request #738 from somtochiama/sas-key-azure-blob
Add Support for SAS keys in Azure Blob
2 parents c63f362 + 106d3fc commit fc5dc4d

File tree

6 files changed

+236
-0
lines changed

6 files changed

+236
-0
lines changed

docs/spec/v1beta2/buckets.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -295,6 +295,7 @@ sets of `.data` fields:
295295
- `clientId` for authenticating using a Managed Identity.
296296
- `accountKey` for authenticating using a
297297
[Shared Key](https://pkg.go.dev/github.com/Azure/azure-sdk-for-go/sdk/storage/azblob#SharedKeyCredential).
298+
- `sasKey` for authenticating using a [SAS Token](https://docs.microsoft.com/en-us/azure/storage/common/storage-sas-overview)
298299

299300
For any Managed Identity and/or Azure Active Directory authentication method,
300301
the base URL can be configured using `.data.authorityHost`. If not supplied,
@@ -504,6 +505,41 @@ spec:
504505
endpoint: https://testfluxsas.blob.core.windows.net
505506
```
506507

508+
##### Azure Blob SAS Token example
509+
510+
```yaml
511+
---
512+
apiVersion: source.toolkit.fluxcd.io/v1beta2
513+
kind: Bucket
514+
metadata:
515+
name: azure-sas-token
516+
namespace: default
517+
spec:
518+
interval: 5m0s
519+
provider: azure
520+
bucketName: <bucket-name>
521+
endpoint: https://<account-name>.blob.core.windows.net
522+
secretRef:
523+
name: azure-key
524+
---
525+
apiVersion: v1
526+
kind: Secret
527+
metadata:
528+
name: azure-key
529+
namespace: default
530+
type: Opaque
531+
data:
532+
sasKey: <base64>
533+
```
534+
535+
The sasKey only contains the SAS token e.g `?sv=2020-08-0&ss=bfqt&srt=co&sp=rwdlacupitfx&se=2022-05-26T21:55:35Z&st=2022-05...`.
536+
The leading question mark is optional.
537+
The query values from the `sasKey` data field in the Secrets gets merged with the ones in the `spec.endpoint` of the `Bucket`.
538+
If the same key is present in the both of them, the value in the `sasKey` takes precedence.
539+
540+
Note that the Azure SAS Token has an expiry date and it should be updated before it expires so that Flux can
541+
continue to access Azure Storage.
542+
507543
#### GCP
508544

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

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ require (
3737
github.com/fluxcd/pkg/gitutil v0.1.0
3838
github.com/fluxcd/pkg/helmtestserver v0.7.4
3939
github.com/fluxcd/pkg/lockedfile v0.1.0
40+
github.com/fluxcd/pkg/masktoken v0.0.1
4041
github.com/fluxcd/pkg/oci v0.3.0
4142
github.com/fluxcd/pkg/runtime v0.16.2
4243
github.com/fluxcd/pkg/ssh v0.5.0

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,8 @@ github.com/fluxcd/pkg/helmtestserver v0.7.4 h1:/Xj2+XLz7wr38MI3uPYvVAsZB9wQOq6rp
399399
github.com/fluxcd/pkg/helmtestserver v0.7.4/go.mod h1:aL5V4o8wUOMqeHMfjbVHS057E3ejzHMRVMqEbsK9FUQ=
400400
github.com/fluxcd/pkg/lockedfile v0.1.0 h1:YsYFAkd6wawMCcD74ikadAKXA4s2sukdxrn7w8RB5eo=
401401
github.com/fluxcd/pkg/lockedfile v0.1.0/go.mod h1:EJLan8t9MiOcgTs8+puDjbE6I/KAfHbdvIy9VUgIjm8=
402+
github.com/fluxcd/pkg/masktoken v0.0.1 h1:egWR/ibTzf4L3PxE8TauKO1srD1Ye/aalgQRQuKKRdU=
403+
github.com/fluxcd/pkg/masktoken v0.0.1/go.mod h1:sQmMtX4s5RwdGlByJazzNasWFFgBdmtNcgeZcGBI72Y=
402404
github.com/fluxcd/pkg/oci v0.3.0 h1:GFn6JZeg5fV2K4vsQ0s5lJFid6qrpA4RybLXL+7qUbQ=
403405
github.com/fluxcd/pkg/oci v0.3.0/go.mod h1:c1pj9E/G5927gSa6ooACAyZe+HwjgmPk9johL7oXDHw=
404406
github.com/fluxcd/pkg/runtime v0.16.2 h1:CexfMmJK+r12sHTvKWyAax0pcPomjd6VnaHXcxjUrRY=

pkg/azure/blob.go

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import (
3535
corev1 "k8s.io/api/core/v1"
3636
ctrl "sigs.k8s.io/controller-runtime"
3737

38+
"github.com/fluxcd/pkg/masktoken"
3839
sourcev1 "github.com/fluxcd/source-controller/api/v1beta2"
3940
)
4041

@@ -53,6 +54,7 @@ const (
5354
clientCertificateSendChainField = "clientCertificateSendChain"
5455
authorityHostField = "authorityHost"
5556
accountKeyField = "accountKey"
57+
sasKeyField = "sasKey"
5658
)
5759

5860
// BlobClient is a minimal Azure Blob client for fetching objects.
@@ -105,6 +107,14 @@ func NewClient(obj *sourcev1.Bucket, secret *corev1.Secret) (c *BlobClient, err
105107
c.ServiceClient, err = azblob.NewServiceClientWithSharedKey(obj.Spec.Endpoint, cred, &azblob.ClientOptions{})
106108
return
107109
}
110+
111+
var fullPath string
112+
if fullPath, err = sasTokenFromSecret(obj.Spec.Endpoint, secret); err != nil {
113+
return
114+
}
115+
116+
c.ServiceClient, err = azblob.NewServiceClientWithNoCredential(fullPath, &azblob.ClientOptions{})
117+
return
108118
}
109119

110120
// Compose token chain based on environment.
@@ -149,6 +159,9 @@ func ValidateSecret(secret *corev1.Secret) error {
149159
if _, hasAccountKey := secret.Data[accountKeyField]; hasAccountKey {
150160
valid = true
151161
}
162+
if _, hasSasKey := secret.Data[sasKeyField]; hasSasKey {
163+
valid = true
164+
}
152165
if _, hasAuthorityHost := secret.Data[authorityHostField]; hasAuthorityHost {
153166
valid = true
154167
}
@@ -355,6 +368,41 @@ func sharedCredentialFromSecret(endpoint string, secret *corev1.Secret) (*azblob
355368
return nil, nil
356369
}
357370

371+
// sasTokenFromSecret retrieves the SAS Token from the `sasKey`. It returns an empty string if the Secret
372+
// does not contain a valid set of credentials.
373+
func sasTokenFromSecret(ep string, secret *corev1.Secret) (string, error) {
374+
if sasKey, hasSASKey := secret.Data[sasKeyField]; hasSASKey {
375+
queryString := strings.TrimPrefix(string(sasKey), "?")
376+
values, err := url.ParseQuery(queryString)
377+
if err != nil {
378+
maskedErrorString, maskErr := masktoken.MaskTokenFromString(err.Error(), string(sasKey))
379+
if maskErr != nil {
380+
return "", fmt.Errorf("error redacting token from error message: %s", maskErr)
381+
}
382+
return "", fmt.Errorf("unable to parse SAS token: %s", maskedErrorString)
383+
}
384+
385+
epURL, err := url.Parse(ep)
386+
if err != nil {
387+
return "", fmt.Errorf("unable to parse endpoint URL: %s", err)
388+
}
389+
390+
//merge the query values in the endpoint with the token
391+
epValues := epURL.Query()
392+
for key, val := range epValues {
393+
if !values.Has(key) {
394+
for _, str := range val {
395+
values.Add(key, str)
396+
}
397+
}
398+
}
399+
400+
epURL.RawQuery = values.Encode()
401+
return epURL.String(), nil
402+
}
403+
return "", nil
404+
}
405+
358406
// chainCredentialWithSecret tries to create a set of tokens, and returns an
359407
// azidentity.ChainedTokenCredential if at least one of the following tokens was
360408
// successfully created:

pkg/azure/blob_integration_test.go

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,67 @@ func TestBlobClient_FGetObject(t *testing.T) {
163163
g.Expect(f).To(Equal([]byte(testFileData)))
164164
}
165165

166+
func TestBlobClientSASKey_FGetObject(t *testing.T) {
167+
g := NewWithT(t)
168+
169+
tempDir := t.TempDir()
170+
171+
// create a client with the shared key
172+
client, err := NewClient(testBucket.DeepCopy(), testSecret.DeepCopy())
173+
g.Expect(err).ToNot(HaveOccurred())
174+
g.Expect(client).ToNot(BeNil())
175+
176+
g.Expect(client.CanGetAccountSASToken()).To(BeTrue())
177+
178+
// Generate test container name.
179+
testContainer := generateString(testContainerGenerateName)
180+
181+
// Create test container.
182+
ctx, timeout := context.WithTimeout(context.Background(), testTimeout)
183+
defer timeout()
184+
g.Expect(createContainer(ctx, client, testContainer)).To(Succeed())
185+
t.Cleanup(func() {
186+
g.Expect(deleteContainer(context.Background(), client, testContainer)).To(Succeed())
187+
})
188+
189+
// Create test blob.
190+
ctx, timeout = context.WithTimeout(context.Background(), testTimeout)
191+
defer timeout()
192+
g.Expect(createBlob(ctx, client, testContainer, testFile, testFileData))
193+
194+
localPath := filepath.Join(tempDir, testFile)
195+
196+
// use the shared key client to create a SAS key for the account
197+
sasKey, err := client.GetSASToken(azblob.AccountSASResourceTypes{Object: true, Container: true},
198+
azblob.AccountSASPermissions{List: true, Read: true},
199+
azblob.AccountSASServices{Blob: true},
200+
time.Now(),
201+
time.Now().Add(48*time.Hour))
202+
g.Expect(err).ToNot(HaveOccurred())
203+
g.Expect(sasKey).ToNot(BeEmpty())
204+
205+
// the sdk returns the full SAS url e.g test.blob.core.windows.net/?<actual-sas-token>
206+
sasKey = strings.TrimPrefix(sasKey, testBucket.Spec.Endpoint+"/")
207+
testSASKeySecret := corev1.Secret{
208+
Data: map[string][]byte{
209+
sasKeyField: []byte(sasKey),
210+
},
211+
}
212+
213+
sasKeyClient, err := NewClient(testBucket.DeepCopy(), testSASKeySecret.DeepCopy())
214+
g.Expect(err).ToNot(HaveOccurred())
215+
216+
// Test if blob exists using sasKey.
217+
ctx, timeout = context.WithTimeout(context.Background(), testTimeout)
218+
defer timeout()
219+
_, err = sasKeyClient.FGetObject(ctx, testContainer, testFile, localPath)
220+
221+
g.Expect(err).ToNot(HaveOccurred())
222+
g.Expect(localPath).To(BeARegularFile())
223+
f, _ := os.ReadFile(localPath)
224+
g.Expect(f).To(Equal([]byte(testFileData)))
225+
}
226+
166227
func TestBlobClient_FGetObject_NotFoundErr(t *testing.T) {
167228
g := NewWithT(t)
168229

pkg/azure/blob_test.go

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import (
2525
"errors"
2626
"fmt"
2727
"math/big"
28+
"net/url"
2829
"testing"
2930

3031
"github.com/Azure/azure-sdk-for-go/sdk/azcore"
@@ -68,6 +69,14 @@ func TestValidateSecret(t *testing.T) {
6869
},
6970
},
7071
},
72+
{
73+
name: "valid SAS Key Secret",
74+
secret: &corev1.Secret{
75+
Data: map[string][]byte{
76+
sasKeyField: []byte("?spr=<some-sas-url"),
77+
},
78+
},
79+
},
7180
{
7281
name: "valid SharedKey Secret",
7382
secret: &corev1.Secret{
@@ -292,6 +301,85 @@ func Test_sharedCredentialFromSecret(t *testing.T) {
292301
}
293302
}
294303

304+
func Test_sasTokenFromSecret(t *testing.T) {
305+
tests := []struct {
306+
name string
307+
endpoint string
308+
secret *corev1.Secret
309+
want string
310+
wantErr bool
311+
}{
312+
{
313+
name: "Valid SAS Token",
314+
endpoint: "https://accountName.blob.windows.net",
315+
secret: &corev1.Secret{
316+
Data: map[string][]byte{
317+
sasKeyField: []byte("?sv=2020-08-0&ss=bfqt&srt=co&sp=rwdlacupitfx&se=2022-05-26T21:55:35Z&st=2022-05-26T13:55:35Z&spr=https&sig=JlHT"),
318+
},
319+
},
320+
want: "https://accountName.blob.windows.net?sv=2020-08-0&ss=bfqt&srt=co&sp=rwdlacupitfx&se=2022-05-26T21:55:35Z&st=2022-05-26T13:55:35Z&spr=https&sig=JlHT",
321+
},
322+
{
323+
name: "Valid SAS Token without leading question mark",
324+
endpoint: "https://accountName.blob.windows.net",
325+
secret: &corev1.Secret{
326+
Data: map[string][]byte{
327+
sasKeyField: []byte("sv=2020-08-04&ss=bfqt&srt=co&sp=rwdl&se=2022-05-26T21:55:35Z&st=2022-05-26&spr=https&sig=JlHT"),
328+
},
329+
},
330+
want: "https://accountName.blob.windows.net?sv=2020-08-04&ss=bfqt&srt=co&sp=rwdl&se=2022-05-26T21:55:35Z&st=2022-05-26&spr=https&sig=JlHT",
331+
},
332+
{
333+
name: "endpoint with query values",
334+
endpoint: "https://accountName.blob.windows.net?sv=2020-08-04",
335+
secret: &corev1.Secret{
336+
Data: map[string][]byte{
337+
sasKeyField: []byte("ss=bfqt&srt=co&sp=rwdl&se=2022-05-26T21:55:35Z&st=2022-05-26&spr=https&sig=JlHT"),
338+
},
339+
},
340+
want: "https://accountName.blob.windows.net?sv=2020-08-04&ss=bfqt&srt=co&sp=rwdl&se=2022-05-26T21:55:35Z&st=2022-05-26&spr=https&sig=JlHT",
341+
},
342+
{
343+
name: "conflicting query values in token",
344+
endpoint: "https://accountName.blob.windows.net?sv=2020-08-04&ss=abcde",
345+
secret: &corev1.Secret{
346+
Data: map[string][]byte{
347+
sasKeyField: []byte("sv=2019-07-06&ss=bfqt&srt=co&sp=rwdl&se=2022-05-26T21:55:35Z&st=2022-05-26&spr=https&sig=JlHT"),
348+
},
349+
},
350+
want: "https://accountName.blob.windows.net?sv=2019-07-06&ss=bfqt&srt=co&sp=rwdl&se=2022-05-26T21:55:35Z&st=2022-05-26&spr=https&sig=JlHT",
351+
},
352+
{
353+
name: "invalid sas token",
354+
secret: &corev1.Secret{
355+
Data: map[string][]byte{
356+
sasKeyField: []byte("%##sssvecrpt"),
357+
},
358+
},
359+
wantErr: true,
360+
},
361+
}
362+
for _, tt := range tests {
363+
t.Run(tt.name, func(t *testing.T) {
364+
g := NewWithT(t)
365+
366+
_, err := url.ParseQuery("")
367+
got, err := sasTokenFromSecret(tt.endpoint, tt.secret)
368+
g.Expect(err != nil).To(Equal(tt.wantErr))
369+
if tt.want != "" {
370+
ttVaules, err := url.Parse(tt.want)
371+
g.Expect(err).To(BeNil())
372+
373+
gotValues, err := url.Parse(got)
374+
g.Expect(err).To(BeNil())
375+
g.Expect(gotValues.Query()).To(Equal(ttVaules.Query()))
376+
return
377+
}
378+
g.Expect(got).To(Equal(""))
379+
})
380+
}
381+
}
382+
295383
func Test_chainCredentialWithSecret(t *testing.T) {
296384
g := NewWithT(t)
297385

0 commit comments

Comments
 (0)