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
76 changes: 74 additions & 2 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

// 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, err
partcyborg marked this conversation as resolved.
Show resolved Hide resolved
}
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 with WebIdentity token %s", terragruntOptions.IAMRoleOptions.RoleARN, terragruntOptions.IAMRoleOptions.WebIdentityToken)
partcyborg marked this conversation as resolved.
Show resolved Hide resolved
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 @@ -188,6 +231,35 @@ func AssumeIamRole(iamRoleOpts options.IAMRoleOptions) (*sts.Credentials, error)
sessionDurationSeconds = iamRoleOpts.AssumeRoleDuration
}

if iamRoleOpts.WebIdentityToken != "" {
partcyborg marked this conversation as resolved.
Show resolved Hide resolved
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, 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 resp.Credentials, nil
}

input := sts.AssumeRoleInput{
RoleArn: aws.String(iamRoleOpts.RoleARN),
RoleSessionName: aws.String(sessionName),
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
42 changes: 42 additions & 0 deletions docs/_docs/04_reference/config-blocks-and-attributes.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ The following is a reference of all the supported blocks and attributes in the c
- [iam\_role](#iam_role)
- [iam\_assume\_role\_duration](#iam_assume_role_duration)
- [iam\_assume\_role\_session\_name](#iam_assume_role_session_name)
- [iam\_web\_identity\_token](#iam_web_identity_token)
- [terraform\_binary](#terraform_binary)
- [terraform\_version\_constraint](#terraform_version_constraint)
- [terragrunt\_version\_constraint](#terragrunt_version_constraint)
Expand Down Expand Up @@ -1172,6 +1173,7 @@ generate = local.common.generate
- [iam\_role](#iam_role)
- [iam\_assume\_role\_duration](#iam_assume_role_duration)
- [iam\_assume\_role\_session\_name](#iam_assume_role_session_name)
- [iam\_web\_identity\_token](#iam_web_identity_token)
- [terraform\_binary](#terraform_binary)
- [terraform\_version\_constraint](#terraform_version_constraint)
- [terragrunt\_version\_constraint](#terragrunt_version_constraint)
Expand Down Expand Up @@ -1328,6 +1330,46 @@ The precedence is as follows: `--terragrunt-iam-assume-role-session-name` comman
`iam_assume_role_session_name` attribute of the `terragrunt.hcl` file in the module directory → `iam_assume_role_session_name` attribute of the included
`terragrunt.hcl`.

### iam_web_identity_token

The `iam_web_identity_token` attribute can be used along with `iam_role` to assume a role using AssumeRoleWithWebIdentity. `iam_web_identity_token` can be set to either the token value (typically using `get_env()`), or the path to a file on disk.

The precedence is as follows: `--terragrunt-iam-web-identity-token` command line option → `TERRRAGRUNT_IAM_ASSUME_ROLE_WEB_IDENTITY_TOKEN` env variable →
`iam_web_identity_token` attribute of the `terragrunt.hcl` file in the module directory → `iam_web_identity_token` attribute of the included
`terragrunt.hcl`.

The primary benefit of using AssumeRoleWithWebIdentity over regular AssumeRole is that it enables you to run terragrunt in your CI/CD pipelines wihthout static AWS credentials.

#### Git Provider Configuration

To use AssumeRoleWithWebIdentity in your CI/CD environment, you must first configure an AWS [OpenID Connect
provider](https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_providers_create_oidc.html) to trust the OIDC service
provided by your git provider.

Follow the instructions below for whichever Git provider you use:
- GitLab: [Configure OpenID Connect in AWS to retrieve temporary credentials](https://docs.gitlab.com/ee/ci/cloud_services/aws/)
- GitHub: [Configuring OpenID Connect in Amazon Web Services](https://docs.github.com/en/actions/deployment/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services)
- CircleCI: [Using OpenID Connect tokens in jobs](https://circleci.com/docs/openid-connect-tokens/)

Once you have configured your OpenID Connect Provider and configured the trust policy of your IAM role according to the above instructions, you
can configure Terragrunt to use the Web Identity Token in the following manner.

If your Git provider provides the OIDC token as an environment variable, pass it in to the `iam_web_identity_token` as follows

```terragrunt
iam_role = "arn:aws:iam::<AWS account number>:role/<IAM role name>"

iam_web_identity_token = get_env("<variable name>")
```

If your Git provider provides the OIDC token as a file, simply pass the file path to `iam_web_identity_token`

```terragrunt
iam_role = "arn:aws:iam::<AWS account number>:role/<IAM role name>"

iam_web_identity_token = "/path/to/token/file"
```

### terraform_binary

The terragrunt `terraform_binary` string option can be used to override the default terraform binary path (which is
Expand Down
Loading