Add WIF support for AWS Auth (#26507)

* Add wif support

* update cli + add stubs

* revert cli changes + add changelog

* update with suggestions
This commit is contained in:
Milena Zlaticanin
2024-05-09 16:14:09 -07:00
committed by GitHub
parent 3150c321cb
commit bdc16c396b
4 changed files with 173 additions and 12 deletions

View File

@@ -6,6 +6,8 @@ package awsauth
import (
"context"
"fmt"
"strconv"
"time"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/aws/credentials/stscreds"
@@ -14,7 +16,10 @@ import (
"github.com/aws/aws-sdk-go/service/iam"
"github.com/aws/aws-sdk-go/service/sts"
cleanhttp "github.com/hashicorp/go-cleanhttp"
"github.com/hashicorp/go-hclog"
"github.com/hashicorp/go-secure-stdlib/awsutil"
"github.com/hashicorp/vault/helper/namespace"
"github.com/hashicorp/vault/sdk/helper/pluginutil"
"github.com/hashicorp/vault/sdk/logical"
)
@@ -58,6 +63,26 @@ func (b *backend) getRawClientConfig(ctx context.Context, s logical.Storage, reg
credsConfig.AccessKey = config.AccessKey
credsConfig.SecretKey = config.SecretKey
maxRetries = config.MaxRetries
if config.IdentityTokenAudience != "" {
ns, err := namespace.FromContext(ctx)
if err != nil {
return nil, fmt.Errorf("failed to get namespace from context: %w", err)
}
fetcher := &PluginIdentityTokenFetcher{
sys: b.System(),
logger: b.Logger(),
ns: ns,
audience: config.IdentityTokenAudience,
ttl: config.IdentityTokenTTL,
}
sessionSuffix := strconv.FormatInt(time.Now().UnixNano(), 10)
credsConfig.RoleSessionName = fmt.Sprintf("vault-aws-auth-%s", sessionSuffix)
credsConfig.WebIdentityTokenFetcher = fetcher
credsConfig.RoleARN = config.RoleARN
}
}
credsConfig.HTTPClient = cleanhttp.DefaultClient()
@@ -302,3 +327,36 @@ func (b *backend) clientIAM(ctx context.Context, s logical.Storage, region, acco
}
return b.IAMClientsMap[region][stsRole], nil
}
// PluginIdentityTokenFetcher fetches plugin identity tokens from Vault. It is provided
// to the AWS SDK client to keep assumed role credentials refreshed through expiration.
// When the client's STS credentials expire, it will use this interface to fetch a new
// plugin identity token and exchange it for new STS credentials.
type PluginIdentityTokenFetcher struct {
sys logical.SystemView
logger hclog.Logger
audience string
ns *namespace.Namespace
ttl time.Duration
}
var _ stscreds.TokenFetcher = (*PluginIdentityTokenFetcher)(nil)
func (f PluginIdentityTokenFetcher) FetchToken(ctx aws.Context) ([]byte, error) {
nsCtx := namespace.ContextWithNamespace(ctx, f.ns)
resp, err := f.sys.GenerateIdentityToken(nsCtx, &pluginutil.IdentityTokenRequest{
Audience: f.audience,
TTL: f.ttl,
})
if err != nil {
return nil, fmt.Errorf("failed to generate plugin identity token: %w", err)
}
f.logger.Info("fetched new plugin identity token")
if resp.TTL < f.ttl {
f.logger.Debug("generated plugin identity token has shorter TTL than requested",
"requested", f.ttl, "actual", resp.TTL)
}
return []byte(resp.Token.Token()), nil
}

View File

@@ -14,11 +14,13 @@ import (
"github.com/aws/aws-sdk-go/aws"
"github.com/hashicorp/go-secure-stdlib/strutil"
"github.com/hashicorp/vault/sdk/framework"
"github.com/hashicorp/vault/sdk/helper/pluginidentityutil"
"github.com/hashicorp/vault/sdk/helper/pluginutil"
"github.com/hashicorp/vault/sdk/logical"
)
func (b *backend) pathConfigClient() *framework.Path {
return &framework.Path{
p := &framework.Path{
Pattern: "config/client$",
DisplayAttrs: &framework.DisplayAttributes{
@@ -85,6 +87,12 @@ func (b *backend) pathConfigClient() *framework.Path {
Default: aws.UseServiceDefaultRetries,
Description: "Maximum number of retries for recoverable exceptions of AWS APIs",
},
"role_arn": {
Type: framework.TypeString,
Default: "",
Description: "Role ARN to assume for plugin identity token federation",
},
},
ExistenceCheck: b.pathConfigClientExistenceCheck,
@@ -121,6 +129,9 @@ func (b *backend) pathConfigClient() *framework.Path {
HelpSynopsis: pathConfigClientHelpSyn,
HelpDescription: pathConfigClientHelpDesc,
}
pluginidentityutil.AddPluginIdentityTokenFields(p.Fields)
return p
}
// Establishes dichotomy of request operation between CreateOperation and UpdateOperation.
@@ -168,18 +179,22 @@ func (b *backend) pathConfigClientRead(ctx context.Context, req *logical.Request
return nil, nil
}
configData := map[string]interface{}{
"access_key": clientConfig.AccessKey,
"endpoint": clientConfig.Endpoint,
"iam_endpoint": clientConfig.IAMEndpoint,
"sts_endpoint": clientConfig.STSEndpoint,
"sts_region": clientConfig.STSRegion,
"use_sts_region_from_client": clientConfig.UseSTSRegionFromClient,
"iam_server_id_header_value": clientConfig.IAMServerIdHeaderValue,
"max_retries": clientConfig.MaxRetries,
"allowed_sts_header_values": clientConfig.AllowedSTSHeaderValues,
"role_arn": clientConfig.RoleARN,
}
clientConfig.PopulatePluginIdentityTokenData(configData)
return &logical.Response{
Data: map[string]interface{}{
"access_key": clientConfig.AccessKey,
"endpoint": clientConfig.Endpoint,
"iam_endpoint": clientConfig.IAMEndpoint,
"sts_endpoint": clientConfig.STSEndpoint,
"sts_region": clientConfig.STSRegion,
"use_sts_region_from_client": clientConfig.UseSTSRegionFromClient,
"iam_server_id_header_value": clientConfig.IAMServerIdHeaderValue,
"max_retries": clientConfig.MaxRetries,
"allowed_sts_header_values": clientConfig.AllowedSTSHeaderValues,
},
Data: configData,
}, nil
}
@@ -334,6 +349,41 @@ func (b *backend) pathConfigClientCreateUpdate(ctx context.Context, req *logical
configEntry.MaxRetries = data.Get("max_retries").(int)
}
roleArnStr, ok := data.GetOk("role_arn")
if ok {
if configEntry.RoleARN != roleArnStr.(string) {
changedCreds = true
configEntry.RoleARN = roleArnStr.(string)
}
} else if req.Operation == logical.CreateOperation {
configEntry.RoleARN = data.Get("role_arn").(string)
}
if err := configEntry.ParsePluginIdentityTokenFields(data); err != nil {
return logical.ErrorResponse(err.Error()), nil
}
// handle mutual exclusivity
if configEntry.IdentityTokenAudience != "" && configEntry.AccessKey != "" {
return logical.ErrorResponse("only one of 'access_key' or 'identity_token_audience' can be set"), nil
}
if configEntry.IdentityTokenAudience != "" && configEntry.RoleARN == "" {
return logical.ErrorResponse("role_arn must be set when identity_token_audience is set"), nil
}
if configEntry.IdentityTokenAudience != "" {
_, err := b.System().GenerateIdentityToken(ctx, &pluginutil.IdentityTokenRequest{
Audience: configEntry.IdentityTokenAudience,
})
if err != nil {
if errors.Is(err, pluginidentityutil.ErrPluginWorkloadIdentityUnsupported) {
return logical.ErrorResponse(err.Error()), nil
}
return nil, err
}
}
// Since this endpoint supports both create operation and update operation,
// the error checks for access_key and secret_key not being set are not present.
// This allows calling this endpoint multiple times to provide the values.
@@ -373,6 +423,8 @@ func (b *backend) configClientToEntry(conf *clientConfig) (*logical.StorageEntry
// Struct to hold 'aws_access_key' and 'aws_secret_key' that are required to
// interact with the AWS EC2 API.
type clientConfig struct {
pluginidentityutil.PluginIdentityTokenParams
AccessKey string `json:"access_key"`
SecretKey string `json:"secret_key"`
Endpoint string `json:"endpoint"`
@@ -383,6 +435,7 @@ type clientConfig struct {
IAMServerIdHeaderValue string `json:"iam_server_id_header_value"`
AllowedSTSHeaderValues []string `json:"allowed_sts_header_values"`
MaxRetries int `json:"max_retries"`
RoleARN string `json:"role_arn"`
}
func (c *clientConfig) validateAllowedSTSHeaderValues(headers http.Header) error {

View File

@@ -7,7 +7,10 @@ import (
"context"
"testing"
"github.com/hashicorp/vault/sdk/helper/pluginidentityutil"
"github.com/hashicorp/vault/sdk/helper/pluginutil"
"github.com/hashicorp/vault/sdk/logical"
"github.com/stretchr/testify/assert"
)
func TestBackend_pathConfigClient(t *testing.T) {
@@ -129,3 +132,47 @@ func TestBackend_pathConfigClient(t *testing.T) {
data["sts_region"], resp.Data["sts_region"])
}
}
// TestBackend_PathConfigClient_PluginIdentityToken tests that configuration
// of plugin WIF returns an immediate error.
func TestBackend_PathConfigClient_PluginIdentityToken(t *testing.T) {
config := logical.TestBackendConfig()
config.StorageView = &logical.InmemStorage{}
config.System = &testSystemView{}
b, err := Backend(config)
if err != nil {
t.Fatal(err)
}
err = b.Setup(context.Background(), config)
if err != nil {
t.Fatal(err)
}
configData := map[string]interface{}{
"identity_token_ttl": int64(10),
"identity_token_audience": "test-aud",
"role_arn": "test-role-arn",
}
configReq := &logical.Request{
Operation: logical.UpdateOperation,
Storage: config.StorageView,
Path: "config/client",
Data: configData,
}
resp, err := b.HandleRequest(context.Background(), configReq)
assert.NoError(t, err)
assert.NotNil(t, resp)
assert.ErrorContains(t, resp.Error(), pluginidentityutil.ErrPluginWorkloadIdentityUnsupported.Error())
}
type testSystemView struct {
logical.StaticSystemView
}
func (d testSystemView) GenerateIdentityToken(_ context.Context, _ *pluginutil.IdentityTokenRequest) (*pluginutil.IdentityTokenResponse, error) {
return nil, pluginidentityutil.ErrPluginWorkloadIdentityUnsupported
}

3
changelog/26507.txt Normal file
View File

@@ -0,0 +1,3 @@
```release-note:feature
**Plugin Identity Tokens**: Adds secret-less configuration of AWS auth engine using web identity federation.
```