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

Add support for AssumeRoleWithWebIdentity #2997

Merged
merged 13 commits into from
Jun 13, 2024
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) {
partcyborg marked this conversation as resolved.
Show resolved Hide resolved
// 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 @@ -158,6 +159,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 @@ -49,6 +49,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 @@ -94,6 +95,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 @@ -126,6 +128,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 @@ -166,6 +169,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 @@ -1059,6 +1063,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 @@ -60,10 +60,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 @@ -289,7 +290,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