diff --git a/builtin/credential/aws/client.go b/builtin/credential/aws/client.go index a1a6a391c323..cf2d39b7d8a4 100644 --- a/builtin/credential/aws/client.go +++ b/builtin/credential/aws/client.go @@ -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 +} diff --git a/builtin/credential/aws/path_config_client.go b/builtin/credential/aws/path_config_client.go index c21237206210..b47cee617ee0 100644 --- a/builtin/credential/aws/path_config_client.go +++ b/builtin/credential/aws/path_config_client.go @@ -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 { diff --git a/builtin/credential/aws/path_config_client_test.go b/builtin/credential/aws/path_config_client_test.go index ed9b98ec5dfd..7d5bd7920251 100644 --- a/builtin/credential/aws/path_config_client_test.go +++ b/builtin/credential/aws/path_config_client_test.go @@ -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 +} diff --git a/changelog/26507.txt b/changelog/26507.txt new file mode 100644 index 000000000000..3f3c8f17a8b1 --- /dev/null +++ b/changelog/26507.txt @@ -0,0 +1,3 @@ +```release-note:feature +**Plugin Identity Tokens**: Adds secret-less configuration of AWS auth engine using web identity federation. +``` \ No newline at end of file