Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Backport 1.6.0: Add support for Managed Identity auth for physical/Azure (#10189) #10260

Merged
merged 3 commits into from
Oct 29, 2020
Merged
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
12 changes: 6 additions & 6 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 @@ -136,7 +137,7 @@ require (
github.com/samuel/go-zookeeper v0.0.0-20180130194729-c4fab1ac1bec
github.com/sasha-s/go-deadlock v0.2.0
github.com/sethvargo/go-limiter v0.3.0
github.com/shirou/gopsutil v2.20.6-0.20200630091542-01afd763e6c0+incompatible
github.com/shirou/gopsutil v2.20.9+incompatible
github.com/stretchr/testify v1.6.1
github.com/tidwall/pretty v1.0.1 // indirect
github.com/ulikunitz/xz v0.5.7 // indirect
Expand All @@ -146,10 +147,9 @@ 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
google.golang.org/api v0.29.0
google.golang.org/grpc v1.29.1
Expand Down
44 changes: 32 additions & 12 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