Skip to content

Commit

Permalink
Add support for Managed Identity auth for physical/Azure (#10189)
Browse files Browse the repository at this point in the history
* Add support for Managed Identity auth for physical/Azure

Obtain OAuth token from IMDS to allow for access to Azure Blob with
short-lived dynamic credentials

Fix #7322

* add tests & update docs/dependencies
  • Loading branch information
sfc-gh-jelsesiy authored and calvn committed Oct 28, 2020
1 parent abdb5d1 commit 0c9e517
Show file tree
Hide file tree
Showing 170 changed files with 7,633 additions and 8,218 deletions.
9 changes: 5 additions & 4 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,9 @@ require (
cloud.google.com/go v0.56.0
cloud.google.com/go/spanner v1.5.1
cloud.google.com/go/storage v1.6.0
github.com/Azure/azure-storage-blob-go v0.10.0
github.com/Azure/go-autorest/autorest v0.11.0
github.com/Azure/azure-storage-blob-go v0.11.0
github.com/Azure/go-autorest/autorest v0.11.10
github.com/Azure/go-autorest/autorest/adal v0.9.5
github.com/NYTimes/gziphandler v1.1.1
github.com/SAP/go-hdb v0.14.1
github.com/Sectorbob/mlab-ns2 v0.0.0-20171030222938-d3aa0c295a8a
Expand Down Expand Up @@ -146,8 +147,8 @@ require (
go.etcd.io/etcd v0.5.0-alpha.5.0.20200425165423-262c93980547
go.mongodb.org/mongo-driver v1.4.2
go.uber.org/atomic v1.6.0
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9
golang.org/x/net v0.0.0-20200602114024-627f9648deb9
golang.org/x/crypto v0.0.0-20201002170205-7f63de1d35b0
golang.org/x/net v0.0.0-20200625001655-4c5254603344
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d
golang.org/x/sys v0.0.0-20200625212154-ddb9806d33ae // indirect
golang.org/x/tools v0.0.0-20200521155704-91d71f6c2f04
Expand Down
169 changes: 141 additions & 28 deletions go.sum

Large diffs are not rendered by default.

77 changes: 68 additions & 9 deletions physical/azure/azure.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ import (
"time"

"github.com/Azure/azure-storage-blob-go/azblob"
"github.com/Azure/go-autorest/autorest/adal"
"github.com/Azure/go-autorest/autorest/azure"
metrics "github.com/armon/go-metrics"
"github.com/armon/go-metrics"
"github.com/hashicorp/errwrap"
log "github.com/hashicorp/go-hclog"
"github.com/hashicorp/vault/sdk/helper/strutil"
Expand All @@ -41,9 +42,11 @@ var _ physical.Backend = (*AzureBackend)(nil)

// NewAzureBackend constructs an Azure backend using a pre-existing
// bucket. Credentials can be provided to the backend, sourced
// from the environment, AWS credential files or by IAM role.
// from the environment, via HCL or by using managed identities.
func NewAzureBackend(conf map[string]string, logger log.Logger) (physical.Backend, error) {
name := os.Getenv("AZURE_BLOB_CONTAINER")
useMSI := false

if name == "" {
name = conf["container"]
if name == "" {
Expand All @@ -63,7 +66,8 @@ func NewAzureBackend(conf map[string]string, logger log.Logger) (physical.Backen
if accountKey == "" {
accountKey = conf["accountKey"]
if accountKey == "" {
return nil, fmt.Errorf("'accountKey' must be set")
logger.Info("accountKey not set, using managed identity auth")
useMSI = true
}
}

Expand Down Expand Up @@ -99,9 +103,39 @@ func NewAzureBackend(conf map[string]string, logger log.Logger) (physical.Backen
}
}

credential, err := azblob.NewSharedKeyCredential(accountName, accountKey)
if err != nil {
return nil, errwrap.Wrapf("failed to create Azure client: {{err}}", err)
var credential azblob.Credential
if useMSI {
authToken, err := getAuthTokenFromIMDS(environment.ResourceIdentifiers.Storage)
if err != nil {
errorMsg := fmt.Sprintf("failed to obtain auth token from IMDS %q: {{err}}",
environmentName)
return nil, errwrap.Wrapf(errorMsg, err)
}

credential = azblob.NewTokenCredential(authToken.OAuthToken(), func(c azblob.TokenCredential) time.Duration {
err = authToken.Refresh()
if err != nil {
logger.Error("couldn't refresh token credential", "error", err)
return 0
}

expIn, err := authToken.Token().ExpiresIn.Int64()
if err != nil {
logger.Error("couldn't retrieve jwt claim for 'expiresIn' from refreshed token", "error", err)
return 0
}

logger.Debug("token refreshed, new token expires in", "access_token_expiry", expIn)
c.SetToken(authToken.OAuthToken())

// tokens are valid for 23h59m (86399s) by default, refresh after ~21h
return time.Duration(int(float64(expIn)*0.9)) * time.Second
})
} else {
credential, err = azblob.NewSharedKeyCredential(accountName, accountKey)
if err != nil {
return nil, errwrap.Wrapf("failed to create Azure client: {{err}}", err)
}
}

URL, err := url.Parse(
Expand Down Expand Up @@ -179,7 +213,7 @@ func (a *AzureBackend) Get(ctx context.Context, key string) (*physical.Entry, er
defer a.permitPool.Release()

blobURL := a.container.NewBlockBlobURL(key)
res, err := blobURL.Download(ctx, 0, 0, azblob.BlobAccessConditions{}, false)
res, err := blobURL.Download(ctx, 0, azblob.CountToEnd, azblob.BlobAccessConditions{}, false)
if err != nil {
var e azblob.StorageError
if errors.As(err, &e) {
Expand All @@ -194,8 +228,8 @@ func (a *AzureBackend) Get(ctx context.Context, key string) (*physical.Entry, er
}

reader := res.Body(azblob.RetryReaderOptions{})

defer reader.Close()

data, err := ioutil.ReadAll(reader)

ent := &physical.Entry{
Expand Down Expand Up @@ -238,7 +272,7 @@ func (a *AzureBackend) List(ctx context.Context, prefix string) ([]string, error
a.permitPool.Acquire()
defer a.permitPool.Release()

keys := []string{}
var keys []string
for marker := (azblob.Marker{}); marker.NotDone(); {
listBlob, err := a.container.ListBlobsFlatSegment(ctx, marker, azblob.ListBlobsSegmentOptions{
Prefix: prefix,
Expand All @@ -265,3 +299,28 @@ func (a *AzureBackend) List(ctx context.Context, prefix string) ([]string, error
sort.Strings(keys)
return keys, nil
}

// getAuthTokenFromIMDS uses the Azure Instance Metadata Service to retrieve a short-lived credential using OAuth
// more info on this https://docs.microsoft.com/en-us/azure/active-directory/managed-identities-azure-resources/overview
func getAuthTokenFromIMDS(resource string) (*adal.ServicePrincipalToken, error) {
msiEndpoint, err := adal.GetMSIVMEndpoint()
if err != nil {
return nil, err
}

spt, err := adal.NewServicePrincipalTokenFromMSI(msiEndpoint, resource)
if err != nil {
return nil, err
}

if err := spt.Refresh(); err != nil {
return nil, err
}

token := spt.Token()
if token.IsZero() {
return nil, err
}

return spt, nil
}
105 changes: 51 additions & 54 deletions physical/azure/azure_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,82 +3,55 @@ package azure
import (
"context"
"fmt"
"net/url"
"net"
"os"
"strconv"
"testing"
"time"

"github.com/Azure/azure-storage-blob-go/azblob"
"github.com/Azure/go-autorest/autorest/azure"
log "github.com/hashicorp/go-hclog"
"github.com/hashicorp/vault/sdk/helper/logging"
"github.com/hashicorp/vault/sdk/physical"
)

func environmentForCleanupClient(name string, armURL string) (azure.Environment, error) {
if armURL != "" {
return azure.EnvironmentFromURL(armURL)
}
if name == "" {
name = "AzurePublicCloud"
}
return azure.EnvironmentFromName(name)
}

func testFixture(t *testing.T) (physical.Backend, func()) {
/// These tests run against an actual azure storage account
/// Authentication options:
/// - Use a static access key via AZURE_ACCOUNT_KEY
/// - Use managed identities (leave AZURE_ACCOUNT_KEY empty)
///
/// To run the tests using managed identities, the following pre-requisites have to be met:
/// 1. Access to the Azure Instance Metadata Service (IMDS) is required (e.g. run it on a Azure VM)
/// 2. A system-assigned oder user-assigned identity attached to the host running the test
/// 3. A role assignment for a storage account with "Storage Blob Data Contributor" permissions

func testFixture(t *testing.T) (*AzureBackend, func()) {
t.Helper()
accountName := os.Getenv("AZURE_ACCOUNT_NAME")
accountKey := os.Getenv("AZURE_ACCOUNT_KEY")
environmentName := os.Getenv("AZURE_ENVIRONMENT")
environmentURL := os.Getenv("AZURE_ARM_ENDPOINT")

ts := time.Now().UnixNano()
name := fmt.Sprintf("vault-test-%d", ts)

cleanupEnvironment, err := environmentForCleanupClient(environmentName, environmentURL)
if err != nil {
t.Fatalf("err: %s", err)
}

credential, err := azblob.NewSharedKeyCredential(accountName, accountKey)
if err != nil {
t.Fatalf("err: %s", err)
}

URL, err := url.Parse(fmt.Sprintf("https://%s.blob.%s/%s", accountName, cleanupEnvironment.StorageEndpointSuffix, name))
if err != nil {
t.Fatalf("err: %s", err)
}

p := azblob.NewPipeline(credential, azblob.PipelineOptions{})

containerURL := azblob.NewContainerURL(*URL, p)
_ = os.Setenv("AZURE_BLOB_CONTAINER", name)

logger := logging.NewVaultLogger(log.Debug)

backend, err := NewAzureBackend(map[string]string{
"container": name,
"accountName": accountName,
"accountKey": accountKey,
"environment": environmentName,
"arm_endpoint": environmentURL,
"container": name,
}, logger)

if err != nil {
t.Fatalf("err: %s", err)
}

return backend, func() {
ctx := context.Background()
blobService, err := containerURL.GetProperties(ctx, azblob.LeaseAccessConditions{})
azBackend := backend.(*AzureBackend)

return azBackend, func() {
blobService, err := azBackend.container.GetProperties(context.Background(), azblob.LeaseAccessConditions{})
if err != nil {
t.Logf("failed to retrieve blob container info: %v", err)
return
}

if blobService.StatusCode() == 200 {
_, err := containerURL.Delete(ctx, azblob.ContainerAccessConditions{})
_, err := azBackend.container.Delete(context.Background(), azblob.ContainerAccessConditions{})
if err != nil {
t.Logf("clean up failed: %v", err)
}
Expand All @@ -87,10 +60,7 @@ func testFixture(t *testing.T) (physical.Backend, func()) {
}

func TestAzureBackend(t *testing.T) {
if os.Getenv("AZURE_ACCOUNT_NAME") == "" ||
os.Getenv("AZURE_ACCOUNT_KEY") == "" {
t.SkipNow()
}
checkTestPreReqs(t)

backend, cleanup := testFixture(t)
defer cleanup()
Expand All @@ -100,10 +70,7 @@ func TestAzureBackend(t *testing.T) {
}

func TestAzureBackend_ListPaging(t *testing.T) {
if os.Getenv("AZURE_ACCOUNT_NAME") == "" ||
os.Getenv("AZURE_ACCOUNT_KEY") == "" {
t.SkipNow()
}
checkTestPreReqs(t)

backend, cleanup := testFixture(t)
defer cleanup()
Expand All @@ -127,3 +94,33 @@ func TestAzureBackend_ListPaging(t *testing.T) {
t.Fatalf("expected %d, got %d", MaxListResults+100, len(results))
}
}

func checkTestPreReqs(t *testing.T) {
t.Helper()

if os.Getenv("AZURE_ACCOUNT_NAME") == "" {
t.SkipNow()
}

accountKey := os.Getenv("AZURE_ACCOUNT_KEY")
if accountKey != "" {
t.Log("using account key provided to authenticate against storage account")
} else {
t.Log("using managed identity to authenticate against storage account")
if !isIMDSReachable(t) {
t.Log("running managed identity test requires access to the Azure IMDS with a valid identity for a storage account attached to it, skipping")
t.SkipNow()
}
}
}

func isIMDSReachable(t *testing.T) bool {
t.Helper()

_, err := net.DialTimeout("tcp", "169.254.169.254:80", time.Second*3)
if err != nil {
return false
}

return true
}
3 changes: 3 additions & 0 deletions vendor/github.com/Azure/azure-pipeline-go/pipeline/error.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 10 additions & 2 deletions vendor/github.com/Azure/azure-storage-blob-go/azblob/highlevel.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit 0c9e517

Please sign in to comment.