From b1dc128dbac1747ce7d596ef9f6fd3140748b420 Mon Sep 17 00:00:00 2001 From: Mihai Anei Date: Mon, 25 Mar 2024 16:35:11 +0200 Subject: [PATCH] Add optional external_id flag when using iam_role for assuming another IAM Role Signed-off-by: Mihai Anei --- aws_helper/config.go | 21 ++++++++++++---- cli/app_test.go | 14 +++++++++++ cli/commands/flags.go | 7 ++++++ config/config.go | 9 +++++++ config/config_as_cty.go | 1 + config/config_partial.go | 4 ++++ config/include.go | 8 +++++++ .../_docs/01_getting-started/configuration.md | 2 +- .../work-with-multiple-aws-accounts.md | 24 +++++++++++++++++++ docs/_docs/04_reference/cli-options.md | 9 +++++++ .../config-blocks-and-attributes.md | 18 ++++++++++++++ options/options.go | 8 +++++++ .../.terraform.lock.hcl | 1 + .../custom-key/backend.tf | 4 ++-- test/fixture-s3-encryption/sse-aes/backend.tf | 4 ++-- 15 files changed, 125 insertions(+), 9 deletions(-) diff --git a/aws_helper/config.go b/aws_helper/config.go index 16bcb7d20..5680cf257 100644 --- a/aws_helper/config.go +++ b/aws_helper/config.go @@ -117,6 +117,9 @@ func getSTSCredentialsFromIAMRoleOptions(sess *session.Session, iamRoleOptions o if iamRoleOptions.AssumeRoleSessionName != "" { p.RoleSessionName = iamRoleOptions.AssumeRoleSessionName } + if iamRoleOptions.ExternalId != "" { + p.ExternalID = &iamRoleOptions.ExternalId + } }) return stscreds.NewCredentials(sess, iamRoleOptions.RoleARN, optFns...) } @@ -188,10 +191,20 @@ 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), + var input sts.AssumeRoleInput + if iamRoleOpts.ExternalId != "" { + input = sts.AssumeRoleInput{ + RoleArn: aws.String(iamRoleOpts.RoleARN), + ExternalId: aws.String(iamRoleOpts.ExternalId), + RoleSessionName: aws.String(sessionName), + DurationSeconds: aws.Int64(sessionDurationSeconds), + } + } else { + input = sts.AssumeRoleInput{ + RoleArn: aws.String(iamRoleOpts.RoleARN), + RoleSessionName: aws.String(sessionName), + DurationSeconds: aws.Int64(sessionDurationSeconds), + } } output, err := stsClient.AssumeRole(&input) diff --git a/cli/app_test.go b/cli/app_test.go index 26b1390cd..eb5b99b2a 100644 --- a/cli/app_test.go +++ b/cli/app_test.go @@ -119,6 +119,12 @@ func TestParseTerragruntOptionsFromArgs(t *testing.T) { nil, }, + { + []string{doubleDashed(commands.FlagNameTerragruntExternalId), "your-set-value"}, + mockOptionsWithExternalId(t, util.JoinPath(workingDir, config.DefaultTerragruntConfigPath), workingDir, []string{}, false, "", false, "your-set-value"), + nil, + }, + { []string{doubleDashed(commands.FlagNameTerragruntIAMAssumeRoleDuration), "36000"}, mockOptionsWithIamAssumeRoleDuration(t, util.JoinPath(workingDir, config.DefaultTerragruntConfigPath), workingDir, []string{}, false, "", false, 36000), @@ -231,6 +237,14 @@ func mockOptionsWithIamRole(t *testing.T, terragruntConfigPath string, workingDi return opts } +func mockOptionsWithExternalId(t *testing.T, terragruntConfigPath string, workingDir string, terraformCliArgs []string, nonInteractive bool, terragruntSource string, ignoreDependencyErrors bool, externalId string) *options.TerragruntOptions { + opts := mockOptions(t, terragruntConfigPath, workingDir, terraformCliArgs, nonInteractive, terragruntSource, ignoreDependencyErrors, false, defaultLogLevel, false) + opts.OriginalIAMRoleOptions.ExternalId = externalId + opts.IAMRoleOptions.ExternalId = externalId + + return opts +} + func mockOptionsWithIamAssumeRoleDuration(t *testing.T, terragruntConfigPath string, workingDir string, terraformCliArgs []string, nonInteractive bool, terragruntSource string, ignoreDependencyErrors bool, iamAssumeRoleDuration int64) *options.TerragruntOptions { opts := mockOptions(t, terragruntConfigPath, workingDir, terraformCliArgs, nonInteractive, terragruntSource, ignoreDependencyErrors, false, defaultLogLevel, false) opts.OriginalIAMRoleOptions.AssumeRoleDuration = iamAssumeRoleDuration diff --git a/cli/commands/flags.go b/cli/commands/flags.go index 1d1b9060a..e0db76d60 100644 --- a/cli/commands/flags.go +++ b/cli/commands/flags.go @@ -20,6 +20,7 @@ const ( FlagNameTerragruntSourceMap = "terragrunt-source-map" FlagNameTerragruntSourceUpdate = "terragrunt-source-update" FlagNameTerragruntIAMRole = "terragrunt-iam-role" + FlagNameTerragruntExternalId = "terragrunt-external-id" FlagNameTerragruntIAMAssumeRoleDuration = "terragrunt-iam-assume-role-duration" FlagNameTerragruntIAMAssumeRoleSessionName = "terragrunt-iam-assume-role-session-name" FlagNameTerragruntIgnoreDependencyErrors = "terragrunt-ignore-dependency-errors" @@ -126,6 +127,12 @@ func NewGlobalFlags(opts *options.TerragruntOptions) cli.Flags { EnvVar: "TERRAGRUNT_IAM_ROLE", Usage: "Assume the specified IAM role before executing Terraform. Can also be set via the TERRAGRUNT_IAM_ROLE environment variable.", }, + &cli.GenericFlag[string]{ + Name: FlagNameTerragruntExternalId, + Destination: &opts.IAMRoleOptions.ExternalId, + EnvVar: "TERRAGRUNT_EXTERNAL_ID", + Usage: "Assume the specified IAM role, together with the given ExternalId, before executing Terraform. Can also be set via the TERRAGRUNT_EXTERNAL_ID environment variable.", + }, &cli.GenericFlag[int64]{ Name: FlagNameTerragruntIAMAssumeRoleDuration, Destination: &opts.IAMRoleOptions.AssumeRoleDuration, diff --git a/config/config.go b/config/config.go index 5945b29cd..17be1bd20 100644 --- a/config/config.go +++ b/config/config.go @@ -47,6 +47,7 @@ const ( MetadataPreventDestroy = "prevent_destroy" MetadataSkip = "skip" MetadataIamRole = "iam_role" + MetadataExternalId = "external_id" MetadataIamAssumeRoleDuration = "iam_assume_role_duration" MetadataIamAssumeRoleSessionName = "iam_assume_role_session_name" MetadataInputs = "inputs" @@ -90,6 +91,7 @@ type TerragruntConfig struct { PreventDestroy *bool Skip bool IamRole string + ExternalId string IamAssumeRoleDuration *int64 IamAssumeRoleSessionName string Inputs map[string]interface{} @@ -123,6 +125,7 @@ func (conf *TerragruntConfig) String() string { func (conf *TerragruntConfig) GetIAMRoleOptions() options.IAMRoleOptions { configIAMRoleOptions := options.IAMRoleOptions{ RoleARN: conf.IamRole, + ExternalId: conf.ExternalId, AssumeRoleSessionName: conf.IamAssumeRoleSessionName, } if conf.IamAssumeRoleDuration != nil { @@ -162,6 +165,7 @@ type terragruntConfigFile struct { PreventDestroy *bool `hcl:"prevent_destroy,attr"` Skip *bool `hcl:"skip,attr"` IamRole *string `hcl:"iam_role,attr"` + ExternalId *string `hcl:"external_id,attr"` IamAssumeRoleDuration *int64 `hcl:"iam_assume_role_duration,attr"` IamAssumeRoleSessionName *string `hcl:"iam_assume_role_session_name,attr"` TerragruntDependencies []Dependency `hcl:"dependency,block"` @@ -1046,6 +1050,11 @@ func convertToTerragruntConfig(ctx *ParsingContext, configPath string, terragrun terragruntConfig.SetFieldMetadata(MetadataIamRole, defaultMetadata) } + if terragruntConfigFromFile.ExternalId != nil { + terragruntConfig.ExternalId = *terragruntConfigFromFile.ExternalId + terragruntConfig.SetFieldMetadata(MetadataExternalId, defaultMetadata) + } + if terragruntConfigFromFile.IamAssumeRoleDuration != nil { terragruntConfig.IamAssumeRoleDuration = terragruntConfigFromFile.IamAssumeRoleDuration terragruntConfig.SetFieldMetadata(MetadataIamAssumeRoleDuration, defaultMetadata) diff --git a/config/config_as_cty.go b/config/config_as_cty.go index 8268504ef..b24b4951d 100644 --- a/config/config_as_cty.go +++ b/config/config_as_cty.go @@ -25,6 +25,7 @@ func TerragruntConfigAsCty(config *TerragruntConfig) (cty.Value, error) { output[MetadataTerragruntVersionConstraint] = gostringToCty(config.TerragruntVersionConstraint) output[MetadataDownloadDir] = gostringToCty(config.DownloadDir) output[MetadataIamRole] = gostringToCty(config.IamRole) + output[MetadataExternalId] = gostringToCty(config.ExternalId) output[MetadataSkip] = goboolToCty(config.Skip) output[MetadataIamAssumeRoleSessionName] = gostringToCty(config.IamAssumeRoleSessionName) diff --git a/config/config_partial.go b/config/config_partial.go index 4457f41c9..8ee5f1d96 100644 --- a/config/config_partial.go +++ b/config/config_partial.go @@ -61,6 +61,7 @@ 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"` + ExternalId *string `hcl:"external_id,attr"` PreventDestroy *bool `hcl:"prevent_destroy,attr"` Skip *bool `hcl:"skip,attr"` Remain hcl.Body `hcl:",remain"` @@ -289,6 +290,9 @@ func PartialParseConfig(ctx *ParsingContext, file *hclparse.File, includeFromChi if decoded.IamRole != nil { output.IamRole = *decoded.IamRole } + if decoded.ExternalId != nil { + output.ExternalId = *decoded.ExternalId + } case TerragruntInputs: decoded := terragruntInputs{} diff --git a/config/include.go b/config/include.go index 760013be9..f683ddded 100644 --- a/config/include.go +++ b/config/include.go @@ -211,6 +211,10 @@ func (targetConfig *TerragruntConfig) Merge(sourceConfig *TerragruntConfig, terr targetConfig.IamRole = sourceConfig.IamRole } + if sourceConfig.ExternalId != "" { + targetConfig.ExternalId = sourceConfig.ExternalId + } + if sourceConfig.IamAssumeRoleDuration != nil { targetConfig.IamAssumeRoleDuration = sourceConfig.IamAssumeRoleDuration } @@ -323,6 +327,10 @@ func (targetConfig *TerragruntConfig) DeepMerge(sourceConfig *TerragruntConfig, targetConfig.IamRole = sourceConfig.IamRole } + if sourceConfig.ExternalId != "" { + targetConfig.ExternalId = sourceConfig.ExternalId + } + if sourceConfig.IamAssumeRoleDuration != nil { targetConfig.IamAssumeRoleDuration = sourceConfig.IamAssumeRoleDuration } diff --git a/docs/_docs/01_getting-started/configuration.md b/docs/_docs/01_getting-started/configuration.md index 288e0bcfa..fe794f953 100644 --- a/docs/_docs/01_getting-started/configuration.md +++ b/docs/_docs/01_getting-started/configuration.md @@ -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`, `external_id`, `iam_assume_role_duration`, and `iam_assume_role_session_name` attributes, if defined 4. `dependencies` block diff --git a/docs/_docs/02_features/work-with-multiple-aws-accounts.md b/docs/_docs/02_features/work-with-multiple-aws-accounts.md index 6960f4cf8..7b7de261f 100644 --- a/docs/_docs/02_features/work-with-multiple-aws-accounts.md +++ b/docs/_docs/02_features/work-with-multiple-aws-accounts.md @@ -50,3 +50,27 @@ iam_role = "arn:aws:iam::ACCOUNT_ID:role/ROLE_NAME" Terragrunt will resolve the value of the option by first looking for the cli argument, then looking for the environment variable, then defaulting to the value specified in the config file. Terragrunt will call the `sts assume-role` API on your behalf and expose the credentials it gets back as environment variables when running Terraform. The advantage of this approach is that you can store your AWS credentials in a secret store and never write them to disk in plaintext, you get fresh credentials on every run of Terragrunt, without the complexity of calling `assume-role` yourself, and you don’t have to modify your Terraform code or backend configuration at all. + +### Configuring Terragrunt to assume an IAM role with ExternalId + +More: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-user_externalid.html + +You might be in a situation where you need to use ExternalIds when assuming an IAM role. Besides the IAM role itself, Terragrunt can be configured to attach an ExternalId when assuming said role. + +``` bash +terragrunt apply --terragrunt-external-id "your-set-value" +``` + +Alternatively, you can set the `TERRAGRUNT_EXTERNAL_ID` environment variable: + +``` bash +export TERRAGRUNT_EXTERNAL_ID="your-set-value" +terragrunt apply +``` + +Additionally, you can specify an `external_id` property in the terragrunt config: + +``` hcl +iam_role = "arn:aws:iam::ACCOUNT_ID:role/ROLE_NAME" +external_id = "your-set-value" +``` diff --git a/docs/_docs/04_reference/cli-options.md b/docs/_docs/04_reference/cli-options.md index 1e339857c..8e157b6e1 100644 --- a/docs/_docs/04_reference/cli-options.md +++ b/docs/_docs/04_reference/cli-options.md @@ -834,6 +834,15 @@ When passed in, the `*-all` commands continue processing components even if a de Assume the specified IAM role ARN before running Terraform or AWS commands. This is a convenient way to use Terragrunt and Terraform with multiple AWS accounts. +### terragrunt-external-id + +**CLI Arg**: `--terragrunt-external-id`
+**Environment Variable**: `TERRAGRUNT_EXTERNAL_ID`
+**Requires an argument**: `--terragrunt-iam-role "your-set-value"` + +Assume the specified IAM role ARN, together with ExternalId, before running Terraform or AWS commands. +This is a convenient way to use Terragrunt and Terraform with multiple AWS accounts. + ### terragrunt-iam-assume-role-duration diff --git a/docs/_docs/04_reference/config-blocks-and-attributes.md b/docs/_docs/04_reference/config-blocks-and-attributes.md index 4b80e61a7..6055eb89b 100644 --- a/docs/_docs/04_reference/config-blocks-and-attributes.md +++ b/docs/_docs/04_reference/config-blocks-and-attributes.md @@ -1075,6 +1075,7 @@ generate = local.common.generate - [prevent_destroy](#prevent_destroy) - [skip](#skip) - [iam_role](#iam_role) +- [external_id](#external_id) - [iam_assume_role_duration](#iam_assume_role_duration) - [iam_assume_role_session_name](#iam_assume_role_session_name) - [terraform_binary](#terraform_binary) @@ -1211,6 +1212,23 @@ iam_role = "arn:aws:iam::ACCOUNT_ID:role/ROLE_NAME" * Value of `iam_role` can reference local variables * Definitions of `iam_role` included from other HCL files through `include` +### external_id + +The `external_id` attribute can be used to specify an ExternalId that Terragrunt should use when assuming a role, prior to invoking Terraform. + +The precedence is as follows: `--terragrunt-external-id` command line option → `TERRAGRUNT_EXTERNAL_ID` env variable → +`external_id` attribute of the `terragrunt.hcl` file in the module directory → `external_id` attribute of the included +`terragrunt.hcl`. + +Example: + +```hcl +external_id = "your-set-value" +``` +**Notes:** +* Value of `external_id` can reference local variables +* Definitions of `external_id` included from other HCL files through `include` + ### iam_assume_role_duration The `iam_assume_role_duration` attribute can be used to specify the STS session duration, in seconds, for the IAM role that Terragrunt should assume prior to invoking Terraform. diff --git a/options/options.go b/options/options.go index 2ebac2b62..f2dc054ea 100644 --- a/options/options.go +++ b/options/options.go @@ -273,6 +273,10 @@ type IAMRoleOptions struct { // The ARN of an IAM Role to assume. Used when accessing AWS, both internally and through terraform. RoleARN string + // The ExternalId to use when assuming RoleARN. Used when accessing AWS, both internally and through terraform. + // See: https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-user_externalid.html + ExternalId string + // Duration of the STS Session when assuming the role. AssumeRoleDuration int64 @@ -287,6 +291,10 @@ func MergeIAMRoleOptions(target IAMRoleOptions, source IAMRoleOptions) IAMRoleOp out.RoleARN = source.RoleARN } + if source.ExternalId != "" { + out.ExternalId = source.ExternalId + } + if source.AssumeRoleDuration != 0 { out.AssumeRoleDuration = source.AssumeRoleDuration } diff --git a/test/fixture-download/custom-lock-file-terraform/.terraform.lock.hcl b/test/fixture-download/custom-lock-file-terraform/.terraform.lock.hcl index 2ffb9a9bb..7a5d8ce28 100755 --- a/test/fixture-download/custom-lock-file-terraform/.terraform.lock.hcl +++ b/test/fixture-download/custom-lock-file-terraform/.terraform.lock.hcl @@ -6,6 +6,7 @@ provider "registry.terraform.io/hashicorp/aws" { constraints = "5.23.0" hashes = [ "h1:AwjyBYctD8UKCXcm+kLJfRjYdUYzG0hetStKrw8UL9M=", + "h1:jV3S2mVUT0sc3pxG6XrQLizk5epHYEFd8Eh1Wciw4Mw=", "zh:100966f25b1878b7c4ee250dcbaf09e5a2dad4bcebba2482d77c4cc4e48957da", "zh:57ed5e66949568d25788ebcd170abf5961f81bb141f69d3acca9a7454994d0c5", "zh:5acf55f8901d5443b6994463d7b2dcbb137a242486f47963e0f33c4cce30171a", diff --git a/test/fixture-s3-encryption/custom-key/backend.tf b/test/fixture-s3-encryption/custom-key/backend.tf index 90546c6c7..6fe5a1031 100644 --- a/test/fixture-s3-encryption/custom-key/backend.tf +++ b/test/fixture-s3-encryption/custom-key/backend.tf @@ -1,8 +1,8 @@ # Generated by Terragrunt. Sig: nIlQXj57tbuaRZEa terraform { backend "s3" { - bucket = "terragrunt-test-bucket-qh7pvm" - dynamodb_table = "terragrunt-test-locks-jmktrj" + bucket = "terragrunt-test-bucket-av7csi" + dynamodb_table = "terragrunt-test-locks-rdpe2z" encrypt = true key = "terraform.tfstate" region = "us-west-2" diff --git a/test/fixture-s3-encryption/sse-aes/backend.tf b/test/fixture-s3-encryption/sse-aes/backend.tf index f9660f58b..99e593e83 100644 --- a/test/fixture-s3-encryption/sse-aes/backend.tf +++ b/test/fixture-s3-encryption/sse-aes/backend.tf @@ -1,8 +1,8 @@ # Generated by Terragrunt. Sig: nIlQXj57tbuaRZEa terraform { backend "s3" { - bucket = "terragrunt-test-bucket-hid5kv" - dynamodb_table = "terragrunt-test-locks-xrkt1b" + bucket = "terragrunt-test-bucket-dxbsru" + dynamodb_table = "terragrunt-test-locks-3fwxqx" encrypt = true key = "terraform.tfstate" region = "us-west-2"