Skip to content

Commit

Permalink
Add support for AssumeRoleWithWebIdentity (#2997)
Browse files Browse the repository at this point in the history
* Add support for AssumeRoleWithWebIdentity

Add support for STS [AssumeRoleWithWebIdentity](https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRoleWithWebIdentity.html).

Includes new config option `iam_web_identity_token` which takes either a WebIdentity token (designed to be passed in with `get_env()`), or a
path to a file containing a WebIdentity token.

* replace ioutil.ReadFile with os.ReadFile

* fix flag name per new naming convention

* remove unnecessary else clause

* Add integration tests

* Support passing through IAM role options through deleteS3Bucket

* fix bug in TestTerragruntAssumeRoleWebIdentityEnv

* Update and improve documentation

* Fixed web credentials fetching (#1)

Found that in internal tests, only with WebIdentityToken, Terragrunt
fails with:
```
time=2024-06-05T18:11:01Z level=error msg=Error finding AWS credentials (did you set the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables?): NoCredentialProviders: no valid providers in chain. Deprecated.
	For verbose messaging see aws.Config.CredentialsChainVerboseErrors
time=2024-06-05T[18](https://github.com/gruntwork-test/testing-terragrunt-with-web-identity/actions/runs/9389092410/job/25855946545#step:6:19):11:01Z level=error msg=Unable to determine underlying exit code, so Terragrunt will exit with error code 1
```

Fixed by updating AssumeIamRole

* Do not log the WebIdentity token

* fix docs syntax issue

* Updates from review feedback

* fix comment

---------

Co-authored-by: Matt Wilder <mwilder@singlestore.com>
Co-authored-by: Denis O <denis@universal-development.com>
  • Loading branch information
3 people committed Jun 13, 2024
1 parent d7097df commit 8e216f8
Show file tree
Hide file tree
Showing 19 changed files with 354 additions and 21 deletions.
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ vendor
terragrunt
.DS_Store
mocks/
.go-version
98 changes: 88 additions & 10 deletions aws_helper/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package aws_helper

import (
"fmt"
"os"
"time"

"github.com/aws/aws-sdk-go/aws/request"
Expand Down Expand Up @@ -95,6 +96,11 @@ func CreateAwsSessionFromConfig(config *AwsSessionConfig, terragruntOptions *opt
)
}

if iamRoleOptions.WebIdentityToken != "" && iamRoleOptions.RoleARN != "" {
sess.Config.Credentials = getWebIdentityCredentialsFromIAMRoleOptions(sess, iamRoleOptions)
return sess, nil
}

credentialOptFn := func(p *stscreds.AssumeRoleProvider) {
if config.ExternalID != "" {
p.ExternalID = aws.String(config.ExternalID)
Expand All @@ -107,6 +113,38 @@ func CreateAwsSessionFromConfig(config *AwsSessionConfig, terragruntOptions *opt
return sess, nil
}

type tokenFetcher string

// FetchToken Implements the stscreds.TokenFetcher interface.
// Supports providing a token value or the path to a token on disk
func (f tokenFetcher) FetchToken(ctx credentials.Context) ([]byte, error) {
// Check if token is a raw value
if _, err := os.Stat(string(f)); err != nil {
return []byte(f), nil
}
token, err := os.ReadFile(string(f))
if err != nil {
return nil, errors.WithStackTrace(err)
}
return token, nil
}

func getWebIdentityCredentialsFromIAMRoleOptions(sess *session.Session, iamRoleOptions options.IAMRoleOptions) *credentials.Credentials {
roleSessionName := iamRoleOptions.AssumeRoleSessionName
if roleSessionName == "" {
// Set a unique session name in the same way it is done in the SDK
roleSessionName = fmt.Sprintf("%d", time.Now().UTC().UnixNano())
}
svc := sts.New(sess)
p := stscreds.NewWebIdentityRoleProviderWithOptions(svc, iamRoleOptions.RoleARN, roleSessionName, tokenFetcher(iamRoleOptions.WebIdentityToken))
if iamRoleOptions.AssumeRoleDuration > 0 {
p.Duration = time.Second * time.Duration(iamRoleOptions.AssumeRoleDuration)
} else {
p.Duration = time.Second * time.Duration(options.DefaultIAMAssumeRoleDuration)
}
return credentials.NewCredentials(p)
}

func getSTSCredentialsFromIAMRoleOptions(sess *session.Session, iamRoleOptions options.IAMRoleOptions, optFns ...func(*stscreds.AssumeRoleProvider)) *credentials.Credentials {
optFns = append(optFns, func(p *stscreds.AssumeRoleProvider) {
if iamRoleOptions.AssumeRoleDuration > 0 {
Expand Down Expand Up @@ -139,8 +177,13 @@ func CreateAwsSession(config *AwsSessionConfig, terragruntOptions *options.Terra
}
sess.Handlers.Build.PushFrontNamed(addUserAgent)
if terragruntOptions.IAMRoleOptions.RoleARN != "" {
terragruntOptions.Logger.Debugf("Assuming role %s", terragruntOptions.IAMRoleOptions.RoleARN)
sess.Config.Credentials = getSTSCredentialsFromIAMRoleOptions(sess, terragruntOptions.IAMRoleOptions)
if terragruntOptions.IAMRoleOptions.WebIdentityToken != "" {
terragruntOptions.Logger.Debugf("Assuming role %s using WebIdentity token", terragruntOptions.IAMRoleOptions.RoleARN)
sess.Config.Credentials = getWebIdentityCredentialsFromIAMRoleOptions(sess, terragruntOptions.IAMRoleOptions)
} else {
terragruntOptions.Logger.Debugf("Assuming role %s", terragruntOptions.IAMRoleOptions.RoleARN)
sess.Config.Credentials = getSTSCredentialsFromIAMRoleOptions(sess, terragruntOptions.IAMRoleOptions)
}
}
} else {
sess, err = CreateAwsSessionFromConfig(config, terragruntOptions)
Expand Down Expand Up @@ -171,6 +214,10 @@ func AssumeIamRole(iamRoleOpts options.IAMRoleOptions) (*sts.Credentials, error)

sess.Handlers.Build.PushFrontNamed(addUserAgent)

if iamRoleOpts.RoleARN != "" && iamRoleOpts.WebIdentityToken != "" {
sess.Config.Credentials = getWebIdentityCredentialsFromIAMRoleOptions(sess, iamRoleOpts)
}

_, err = sess.Config.Credentials.Get()
if err != nil {
return nil, errors.WithStackTraceAndPrefix(err, "Error finding AWS credentials (did you set the AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY environment variables?)")
Expand All @@ -188,18 +235,49 @@ func AssumeIamRole(iamRoleOpts options.IAMRoleOptions) (*sts.Credentials, error)
sessionDurationSeconds = iamRoleOpts.AssumeRoleDuration
}

input := sts.AssumeRoleInput{
RoleArn: aws.String(iamRoleOpts.RoleARN),
RoleSessionName: aws.String(sessionName),
DurationSeconds: aws.Int64(sessionDurationSeconds),
if iamRoleOpts.WebIdentityToken == "" {
// Use regular sts AssumeRole
input := sts.AssumeRoleInput{
RoleArn: aws.String(iamRoleOpts.RoleARN),
RoleSessionName: aws.String(sessionName),
DurationSeconds: aws.Int64(sessionDurationSeconds),
}

output, err := stsClient.AssumeRole(&input)
if err != nil {
return nil, errors.WithStackTrace(err)
}

return output.Credentials, nil
}

output, err := stsClient.AssumeRole(&input)
if err != nil {
// Use sts AssumeRoleWithWebIdentity
var token string
// Check if value is a raw token or a path to a file with a token
if _, err := os.Stat(iamRoleOpts.WebIdentityToken); err != nil {
token = iamRoleOpts.WebIdentityToken
} else {
tb, err := os.ReadFile(iamRoleOpts.WebIdentityToken)
if err != nil {
return nil, errors.WithStackTrace(err)
}
token = string(tb)
}
input := sts.AssumeRoleWithWebIdentityInput{
RoleArn: aws.String(iamRoleOpts.RoleARN),
RoleSessionName: aws.String(sessionName),
WebIdentityToken: aws.String(token),
DurationSeconds: aws.Int64(sessionDurationSeconds),
}
req, resp := stsClient.AssumeRoleWithWebIdentityRequest(&input)
// InvalidIdentityToken error is a temporary error that can occur
// when assuming an Role with a JWT web identity token.
// N.B: copied from SDK implementation
req.RetryErrorCodes = append(req.RetryErrorCodes, sts.ErrCodeInvalidIdentityTokenException)
if err := req.Send(); err != nil {
return nil, errors.WithStackTrace(err)
}

return output.Credentials, nil
return resp.Credentials, nil
}

// Return the AWS caller identity associated with the current set of credentials
Expand Down
13 changes: 13 additions & 0 deletions cli/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,12 @@ func TestParseTerragruntOptionsFromArgs(t *testing.T) {
nil,
},

{
[]string{doubleDashed(commands.TerragruntIAMWebIdentityTokenFlagName), "web-identity-token"},
mockOptionsWithIamWebIdentityToken(t, util.JoinPath(workingDir, config.DefaultTerragruntConfigPath), workingDir, []string{}, false, "", false, "web-identity-token"),
nil,
},

{
[]string{doubleDashed(commands.TerragruntConfigFlagName), fmt.Sprintf("/some/path/%s", config.DefaultTerragruntConfigPath), "--terragrunt-non-interactive"},
mockOptions(t, fmt.Sprintf("/some/path/%s", config.DefaultTerragruntConfigPath), workingDir, []string{}, true, "", false, false, defaultLogLevel, false),
Expand Down Expand Up @@ -254,6 +260,13 @@ func mockOptionsWithIamAssumeRoleSessionName(t *testing.T, terragruntConfigPath
return opts
}

func mockOptionsWithIamWebIdentityToken(t *testing.T, terragruntConfigPath string, workingDir string, terraformCliArgs []string, nonInteractive bool, terragruntSource string, ignoreDependencyErrors bool, webIdentityToken string) *options.TerragruntOptions {
opts := mockOptions(t, terragruntConfigPath, workingDir, terraformCliArgs, nonInteractive, terragruntSource, ignoreDependencyErrors, false, defaultLogLevel, false)
opts.OriginalIAMRoleOptions.WebIdentityToken = webIdentityToken
opts.IAMRoleOptions.WebIdentityToken = webIdentityToken
return opts
}

func mockOptionsWithSourceMap(t *testing.T, terragruntConfigPath string, workingDir string, terraformCliArgs []string, sourceMap map[string]string) *options.TerragruntOptions {
opts := mockOptions(t, terragruntConfigPath, workingDir, terraformCliArgs, false, "", false, false, defaultLogLevel, false)
opts.SourceMap = sourceMap
Expand Down
7 changes: 7 additions & 0 deletions cli/commands/flags.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ const (
TerragruntIAMRoleFlagName = "terragrunt-iam-role"
TerragruntIAMAssumeRoleDurationFlagName = "terragrunt-iam-assume-role-duration"
TerragruntIAMAssumeRoleSessionNameFlagName = "terragrunt-iam-assume-role-session-name"
TerragruntIAMWebIdentityTokenFlagName = "terragrunt-iam-web-identity-token"
TerragruntIgnoreDependencyErrorsFlagName = "terragrunt-ignore-dependency-errors"
TerragruntIgnoreDependencyOrderFlagName = "terragrunt-ignore-dependency-order"
TerragruntIgnoreExternalDependenciesFlagName = "terragrunt-ignore-external-dependencies"
Expand Down Expand Up @@ -161,6 +162,12 @@ func NewGlobalFlags(opts *options.TerragruntOptions) cli.Flags {
EnvVar: "TERRAGRUNT_IAM_ASSUME_ROLE_SESSION_NAME",
Usage: "Name for the IAM Assummed Role session. Can also be set via TERRAGRUNT_IAM_ASSUME_ROLE_SESSION_NAME environment variable.",
},
&cli.GenericFlag[string]{
Name: TerragruntIAMWebIdentityTokenFlagName,
Destination: &opts.IAMRoleOptions.WebIdentityToken,
EnvVar: "TERRRAGRUNT_IAM_ASSUME_ROLE_WEB_IDENTITY_TOKEN",
Usage: "For AssumeRoleWithWebIdentity, the WebIdentity token. Can also be set via TERRRAGRUNT_IAM_ASSUME_ROLE_WEB_IDENTITY_TOKEN environment variable",
},
&cli.BoolFlag{
Name: TerragruntIgnoreDependencyErrorsFlagName,
Destination: &opts.IgnoreDependencyErrors,
Expand Down
9 changes: 9 additions & 0 deletions config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ const (
MetadataIamRole = "iam_role"
MetadataIamAssumeRoleDuration = "iam_assume_role_duration"
MetadataIamAssumeRoleSessionName = "iam_assume_role_session_name"
MetadataIamWebIdentityToken = "iam_web_identity_token"
MetadataInputs = "inputs"
MetadataLocals = "locals"
MetadataLocal = "local"
Expand Down Expand Up @@ -95,6 +96,7 @@ type TerragruntConfig struct {
IamRole string
IamAssumeRoleDuration *int64
IamAssumeRoleSessionName string
IamWebIdentityToken string
Inputs map[string]interface{}
Locals map[string]interface{}
TerragruntDependencies []Dependency
Expand Down Expand Up @@ -127,6 +129,7 @@ func (conf *TerragruntConfig) GetIAMRoleOptions() options.IAMRoleOptions {
configIAMRoleOptions := options.IAMRoleOptions{
RoleARN: conf.IamRole,
AssumeRoleSessionName: conf.IamAssumeRoleSessionName,
WebIdentityToken: conf.IamWebIdentityToken,
}
if conf.IamAssumeRoleDuration != nil {
configIAMRoleOptions.AssumeRoleDuration = *conf.IamAssumeRoleDuration
Expand Down Expand Up @@ -167,6 +170,7 @@ type terragruntConfigFile struct {
IamRole *string `hcl:"iam_role,attr"`
IamAssumeRoleDuration *int64 `hcl:"iam_assume_role_duration,attr"`
IamAssumeRoleSessionName *string `hcl:"iam_assume_role_session_name,attr"`
IamWebIdentityToken *string `hcl:"iam_web_identity_token,attr"`
TerragruntDependencies []Dependency `hcl:"dependency,block"`

// We allow users to configure code generation via blocks:
Expand Down Expand Up @@ -1060,6 +1064,11 @@ func convertToTerragruntConfig(ctx *ParsingContext, configPath string, terragrun
terragruntConfig.SetFieldMetadata(MetadataIamAssumeRoleSessionName, defaultMetadata)
}

if terragruntConfigFromFile.IamWebIdentityToken != nil {
terragruntConfig.IamWebIdentityToken = *terragruntConfigFromFile.IamWebIdentityToken
terragruntConfig.SetFieldMetadata(MetadataIamWebIdentityToken, defaultMetadata)
}

generateBlocks := []terragruntGenerateBlock{}
generateBlocks = append(generateBlocks, terragruntConfigFromFile.GenerateBlocks...)

Expand Down
1 change: 1 addition & 0 deletions config/config_as_cty.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ func TerragruntConfigAsCty(config *TerragruntConfig) (cty.Value, error) {
output[MetadataIamRole] = gostringToCty(config.IamRole)
output[MetadataSkip] = goboolToCty(config.Skip)
output[MetadataIamAssumeRoleSessionName] = gostringToCty(config.IamAssumeRoleSessionName)
output[MetadataIamWebIdentityToken] = gostringToCty(config.IamWebIdentityToken)

catalogConfigCty, err := catalogConfigAsCty(config.Catalog)
if err != nil {
Expand Down
2 changes: 2 additions & 0 deletions config/config_as_cty_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,8 @@ func terragruntConfigStructFieldToMapKey(t *testing.T, fieldName string) (string
return "iam_assume_role_duration", true
case "IamAssumeRoleSessionName":
return "iam_assume_role_session_name", true
case "IamWebIdentityToken":
return "iam_web_identity_token", true
case "Inputs":
return "inputs", true
case "Locals":
Expand Down
13 changes: 8 additions & 5 deletions config/config_partial.go
Original file line number Diff line number Diff line change
Expand Up @@ -61,10 +61,11 @@ type terraformConfigSourceOnly struct {

// terragruntFlags is a struct that can be used to only decode the flag attributes (skip and prevent_destroy)
type terragruntFlags struct {
IamRole *string `hcl:"iam_role,attr"`
PreventDestroy *bool `hcl:"prevent_destroy,attr"`
Skip *bool `hcl:"skip,attr"`
Remain hcl.Body `hcl:",remain"`
IamRole *string `hcl:"iam_role,attr"`
IamWebIdentityToken *string `hcl:"iam_web_identity_token,attr"`
PreventDestroy *bool `hcl:"prevent_destroy,attr"`
Skip *bool `hcl:"skip,attr"`
Remain hcl.Body `hcl:",remain"`
}

// terragruntVersionConstraints is a struct that can be used to only decode the attributes related to constraining the
Expand Down Expand Up @@ -290,7 +291,9 @@ func PartialParseConfig(ctx *ParsingContext, file *hclparse.File, includeFromChi
if decoded.IamRole != nil {
output.IamRole = *decoded.IamRole
}

if decoded.IamWebIdentityToken != nil {
output.IamWebIdentityToken = *decoded.IamWebIdentityToken
}
case TerragruntInputs:
decoded := terragruntInputs{}

Expand Down
20 changes: 20 additions & 0 deletions config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,25 @@ func TestParseIamAssumeRoleSessionName(t *testing.T) {
assert.Equal(t, "terragrunt-iam-assume-role-session-name", terragruntConfig.IamAssumeRoleSessionName)
}

func TestParseIamWebIdentity(t *testing.T) {
t.Parallel()
token := "test-token"

config := fmt.Sprintf(`iam_web_identity_token = "%s"`, token)

ctx := NewParsingContext(context.Background(), mockOptionsForTest(t))
terragruntConfig, err := ParseConfigString(ctx, DefaultTerragruntConfigPath, config, nil)
if err != nil {
t.Fatal(err)
}
assert.Nil(t, terragruntConfig.RemoteState)
assert.Nil(t, terragruntConfig.Terraform)
assert.Nil(t, terragruntConfig.Dependencies)
assert.Nil(t, terragruntConfig.RetryableErrors)
assert.Empty(t, terragruntConfig.IamRole)
assert.Equal(t, token, terragruntConfig.IamWebIdentityToken)
}

func TestParseTerragruntConfigDependenciesOnePath(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -692,6 +711,7 @@ func TestParseTerragruntConfigEmptyConfig(t *testing.T) {
assert.Nil(t, cfg.PreventDestroy)
assert.False(t, cfg.Skip)
assert.Empty(t, cfg.IamRole)
assert.Empty(t, cfg.IamWebIdentityToken)
assert.Nil(t, cfg.RetryMaxAttempts)
assert.Nil(t, cfg.RetrySleepIntervalSec)
assert.Nil(t, cfg.RetryableErrors)
Expand Down
15 changes: 15 additions & 0 deletions config/include_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,21 @@ func TestMergeConfigIntoIncludedConfig(t *testing.T) {
&TerragruntConfig{IamRole: "role1"},
&TerragruntConfig{IamRole: "role2"},
},
{
&TerragruntConfig{IamWebIdentityToken: "token"},
&TerragruntConfig{IamWebIdentityToken: "token"},
&TerragruntConfig{IamWebIdentityToken: "token"},
},
{
&TerragruntConfig{IamWebIdentityToken: "token"},
&TerragruntConfig{IamWebIdentityToken: "token2"},
&TerragruntConfig{IamWebIdentityToken: "token2"},
},
{
&TerragruntConfig{},
&TerragruntConfig{IamWebIdentityToken: "token"},
&TerragruntConfig{IamWebIdentityToken: "token"},
},
}

for _, testCase := range testCases {
Expand Down
1 change: 1 addition & 0 deletions configstack/module_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,7 @@ func TestResolveTerraformModulesReadConfigFromParentConfig(t *testing.T) {
"iam_assume_role_duration": interface{}(nil),
"iam_assume_role_session_name": "",
"iam_role": "",
"iam_web_identity_token": "",
"inputs": interface{}(nil),
"locals": cfg.Locals,
"retry_max_attempts": interface{}(nil),
Expand Down
2 changes: 1 addition & 1 deletion docs/_docs/01_getting-started/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -55,7 +55,7 @@ Currently terragrunt parses the config in the following order:

2. `locals` block

3. Evaluation of values for `iam_role`, `iam_assume_role_duration`, and `iam_assume_role_session_name` attributes, if defined
3. Evaluation of values for `iam_role`, `iam_assume_role_duration`, `iam_assume_role_session_name`, and `iam_web_identity_token` attributes, if defined

4. `dependencies` block

Expand Down
Loading

0 comments on commit 8e216f8

Please sign in to comment.