diff --git a/.circleci/config.yml b/.circleci/config.yml index 556c512e7..616cc7584 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -761,6 +761,7 @@ workflows: only: /^v.*/ context: - AWS__PHXDEVOPS__circle-ci-test + - AWS__PHXDEVOPS__terragrunt-oidc-test - GCP__automated-tests - GITHUB__PAT__gruntwork-ci - APPLE__OSX__code-signing @@ -770,6 +771,7 @@ workflows: only: /^v.*/ context: - AWS__PHXDEVOPS__circle-ci-test + - AWS__PHXDEVOPS__terragrunt-oidc-test - GCP__automated-tests - GITHUB__PAT__gruntwork-ci - APPLE__OSX__code-signing @@ -779,6 +781,7 @@ workflows: only: /^v.*/ context: - AWS__PHXDEVOPS__circle-ci-test + - AWS__PHXDEVOPS__terragrunt-oidc-test - GCP__automated-tests - GITHUB__PAT__gruntwork-ci - APPLE__OSX__code-signing diff --git a/cli/commands/terraform/creds/providers/externalcmd/provider.go b/cli/commands/terraform/creds/providers/externalcmd/provider.go index 90f44192a..07e894de1 100644 --- a/cli/commands/terraform/creds/providers/externalcmd/provider.go +++ b/cli/commands/terraform/creds/providers/externalcmd/provider.go @@ -8,6 +8,7 @@ import ( "strings" "github.com/gruntwork-io/terragrunt/cli/commands/terraform/creds/providers" + "github.com/gruntwork-io/terragrunt/cli/commands/terraform/creds/providers/amazonsts" "github.com/gruntwork-io/terragrunt/internal/errors" "github.com/gruntwork-io/terragrunt/options" "github.com/gruntwork-io/terragrunt/shell" @@ -66,10 +67,21 @@ func (provider *Provider) GetCredentials(ctx context.Context) (*providers.Creden } if resp.AWSCredentials != nil { - if envs := resp.AWSCredentials.Envs(provider.terragruntOptions); envs != nil { + if envs := resp.AWSCredentials.Envs(ctx, provider.terragruntOptions); envs != nil { provider.terragruntOptions.Logger.Debugf("Obtaining AWS credentials from the %s.", provider.Name()) maps.Copy(creds.Envs, envs) } + + return creds, nil + } + + if resp.AWSRole != nil { + if envs := resp.AWSRole.Envs(ctx, provider.terragruntOptions); envs != nil { + provider.terragruntOptions.Logger.Debugf("Assuming AWS role %s using the %s.", resp.AWSRole.RoleARN, provider.Name()) + maps.Copy(creds.Envs, envs) + } + + return creds, nil } return creds, nil @@ -77,6 +89,7 @@ func (provider *Provider) GetCredentials(ctx context.Context) (*providers.Creden type Response struct { AWSCredentials *AWSCredentials `json:"awsCredentials"` + AWSRole *AWSRole `json:"awsRole"` Envs map[string]string `json:"envs"` } @@ -86,7 +99,67 @@ type AWSCredentials struct { SessionToken string `json:"SESSION_TOKEN"` } -func (creds *AWSCredentials) Envs(opts *options.TerragruntOptions) map[string]string { +type AWSRole struct { + RoleARN string `json:"roleARN"` + RoleSessionName string `json:"roleSessionName"` + Duration int64 `json:"duration"` + WebIdentityToken string `json:"webIdentityToken"` +} + +func (role *AWSRole) Envs(ctx context.Context, opts *options.TerragruntOptions) map[string]string { + if role.RoleARN == "" { + opts.Logger.Warnf("The command %s completed successfully, but AWS role assumption contains empty required value: roleARN, nothing is being done.", opts.AuthProviderCmd) + return nil + } + + sessionName := role.RoleSessionName + if sessionName == "" { + sessionName = options.GetDefaultIAMAssumeRoleSessionName() + } + + duration := role.Duration + if duration == 0 { + duration = options.DefaultIAMAssumeRoleDuration + } + + // Construct minimal TerragruntOptions for role assumption. + providerOpts := options.TerragruntOptions{ + IAMRoleOptions: options.IAMRoleOptions{ + RoleARN: role.RoleARN, + AssumeRoleDuration: duration, + AssumeRoleSessionName: sessionName, + }, + Logger: opts.Logger, + } + + if role.WebIdentityToken != "" { + providerOpts.IAMRoleOptions.WebIdentityToken = role.WebIdentityToken + } + + provider := amazonsts.NewProvider(&providerOpts) + + creds, err := provider.GetCredentials(ctx) + if err != nil { + opts.Logger.Warnf("Failed to assume role %s: %v", role.RoleARN, err) + return nil + } + + if creds == nil { + opts.Logger.Warnf("The command %s completed successfully, but failed to assume role %s, nothing is being done.", opts.AuthProviderCmd, role.RoleARN) + return nil + } + + envs := map[string]string{ + "AWS_ACCESS_KEY_ID": creds.Envs["AWS_ACCESS_KEY_ID"], + "AWS_SECRET_ACCESS_KEY": creds.Envs["AWS_SECRET_ACCESS_KEY"], + "AWS_SESSION_TOKEN": creds.Envs["AWS_SESSION_TOKEN"], + "AWS_SECURITY_TOKEN": creds.Envs["AWS_SESSION_TOKEN"], + } + + return envs +} + +func (creds *AWSCredentials) Envs(_ context.Context, opts *options.TerragruntOptions) map[string]string { var emptyFields []string if creds.AccessKeyID == "" { diff --git a/docs/_docs/04_reference/cli-options.md b/docs/_docs/04_reference/cli-options.md index 2b5a50a1a..70be71637 100644 --- a/docs/_docs/04_reference/cli-options.md +++ b/docs/_docs/04_reference/cli-options.md @@ -48,6 +48,7 @@ This page documents the CLI commands and options available with Terragrunt: - [terragrunt-iam-role](#terragrunt-iam-role) - [terragrunt-iam-assume-role-duration](#terragrunt-iam-assume-role-duration) - [terragrunt-iam-assume-role-session-name](#terragrunt-iam-assume-role-session-name) + - [terragrunt-iam-web-identity-token](#terragrunt-iam-web-identity-token) - [terragrunt-excludes-file](#terragrunt-excludes-file) - [terragrunt-exclude-dir](#terragrunt-exclude-dir) - [terragrunt-include-dir](#terragrunt-include-dir) @@ -984,6 +985,14 @@ Uses the specified duration as the session duration (in seconds) for the STS ses Used as the session name for the STS session which assumes the role defined in `--terragrunt-iam-role`. +### terragrunt-iam-web-identity-token + +**CLI Arg**: `--terragrunt-iam-web-identity-token`
+**Environment Variable**: `TERRAGRUNT_IAM_WEB_IDENTITY_TOKEN`
+**Requires an argument**: `--terragrunt-iam-web-identity-token [/path/to/web-identity-token | web-identity-token-value]`
+ +Used as the web identity token for assuming a role temporarily using the AWS Security Token Service (STS) with the [AssumeRoleWithWebIdentity](https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRoleWithWebIdentity.html) API. + ### terragrunt-excludes-file **CLI Arg**: `--terragrunt-excludes-file`
@@ -1529,6 +1538,12 @@ The output must be valid JSON of the following schema: "SECRET_ACCESS_KEY": "", "SESSION_TOKEN": "" }, + "awsRole": { + "roleARN": "", + "sessionName": "", + "duration": 0, + "webIdentityToken": "" + }, "envs": { "ANY_KEY": "" } @@ -1553,8 +1568,12 @@ Note that more specific configurations (e.g. `awsCredentials`) take precedence o If you would like to set credentials for AWS with this method, you are encouraged to use `awsCredentials` instead of `envs`, as these keys will be validated to conform to the officially supported environment variables expected by the AWS SDK. +Similarly, if you would like Terragrunt to assume an AWS role on your behalf, you are encouraged to use the `awsRole` configuration instead of `envs`. + Other credential configurations will be supported in the future, but until then, if your provider authenticates via environment variables, you can use the `envs` field to fetch credentials dynamically from a secret store, etc before Terragrunt executes any IAC. +**Note**: The `awsRole` configuration is only used when the `awsCredentials` configuration is not present. If both are present, the `awsCredentials` configuration will take precedence. + ### terragrunt-disable-log-formatting **CLI Arg**: `--terragrunt-disable-log-formatting`
diff --git a/test/fixtures/assume-role-web-identity/env-var/main.tf b/test/fixtures/assume-role-web-identity/env-var/main.tf deleted file mode 100644 index ec64be1fd..000000000 --- a/test/fixtures/assume-role-web-identity/env-var/main.tf +++ /dev/null @@ -1,4 +0,0 @@ -resource "local_file" "test_file" { - content = "test_file" - filename = "${path.module}/test_file.txt" -} diff --git a/test/fixtures/assume-role-web-identity/env-var/terragrunt.hcl b/test/fixtures/assume-role-web-identity/env-var/terragrunt.hcl deleted file mode 100644 index b64cb5d5b..000000000 --- a/test/fixtures/assume-role-web-identity/env-var/terragrunt.hcl +++ /dev/null @@ -1,16 +0,0 @@ -iam_role = "__FILL_IN_ASSUME_ROLE__" -iam_web_identity_token = get_env("__FILL_IN_IDENTITY_TOKEN_ENV_VAR__", "") - -remote_state { - backend = "s3" - generate = { - path = "backend.tf" - if_exists = "overwrite_terragrunt" - } - config = { - bucket = "__FILL_IN_BUCKET_NAME__" - key = "${path_relative_to_include()}/terraform.tfstate" - region = "__FILL_IN_REGION__" - encrypt = true - } -} diff --git a/test/fixtures/auth-provider-cmd/oidc/main.tf b/test/fixtures/auth-provider-cmd/oidc/main.tf new file mode 100644 index 000000000..e69de29bb diff --git a/test/fixtures/auth-provider-cmd/oidc/mock-auth-cmd.sh b/test/fixtures/auth-provider-cmd/oidc/mock-auth-cmd.sh new file mode 100755 index 000000000..408b48419 --- /dev/null +++ b/test/fixtures/auth-provider-cmd/oidc/mock-auth-cmd.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +set -o pipefail + +: "${AWS_TEST_OIDC_ROLE_ARN:?The AWS_TEST_OIDC_ROLE_ARN environment variable must be set.}" +: "${CIRCLE_OIDC_TOKEN_V2:?The CIRCLE_OIDC_TOKEN_V2 environment variable must be set.}" + +jq -n \ + --arg role "$AWS_TEST_OIDC_ROLE_ARN" \ + --arg token "$CIRCLE_OIDC_TOKEN_V2" \ + '{awsRole: {roleARN: $role, webIdentityToken: $token}}' diff --git a/test/fixtures/auth-provider-cmd/oidc/terragrunt.hcl b/test/fixtures/auth-provider-cmd/oidc/terragrunt.hcl new file mode 100644 index 000000000..e69de29bb diff --git a/test/integration_aws_test.go b/test/integration_aws_test.go index 065aad92c..d50aab1bb 100644 --- a/test/integration_aws_test.go +++ b/test/integration_aws_test.go @@ -759,74 +759,79 @@ func TestAwsAssumeRoleDuration(t *testing.T) { assert.Contains(t, output, "no changes are needed.") } -func TestAwsAssumeRoleWebIdentityEnv(t *testing.T) { - t.Parallel() - - assumeRole := os.Getenv("AWS_TEST_S3_ASSUME_ROLE") - tokenEnvVar := os.Getenv("AWS_TEST_S3_IDENTITY_TOKEN_VAR") - if tokenEnvVar == "" { - t.Skip("Missing required env var AWS_TEST_S3_IDENTITY_TOKEN_VAR") - return +func TestAwsAssumeRoleWebIdentityFile(t *testing.T) { + if os.Getenv("CIRCLECI") != "true" { + t.Skip("Skipping test because it requires valid CircleCI OIDC credentials to work") } - tmpEnvPath := helpers.CopyEnvironment(t, testFixtureAssumeRoleWebIdentityEnv) - helpers.CleanupTerraformFolder(t, tmpEnvPath) - testPath := util.JoinPath(tmpEnvPath, testFixtureAssumeRoleWebIdentityEnv) + // These tests need to be run without the static key + secret + // used by most AWS tests here. + t.Setenv("AWS_ACCESS_KEY_ID", "") + os.Unsetenv("AWS_ACCESS_KEY_ID") + t.Setenv("AWS_SECRET_ACCESS_KEY", "") + os.Unsetenv("AWS_SECRET_ACCESS_KEY") - originalTerragruntConfigPath := util.JoinPath(testFixtureAssumeRoleWebIdentityEnv, "terragrunt.hcl") + tmpEnvPath := helpers.CopyEnvironment(t, testFixtureAssumeRoleWebIdentityFile) + cleanupTerraformFolder(t, tmpEnvPath) + testPath := util.JoinPath(tmpEnvPath, testFixtureAssumeRoleWebIdentityFile) + + originalTerragruntConfigPath := util.JoinPath(testFixtureAssumeRoleWebIdentityFile, "terragrunt.hcl") tmpTerragruntConfigFile := util.JoinPath(testPath, "terragrunt.hcl") s3BucketName := "terragrunt-test-bucket-" + strings.ToLower(helpers.UniqueID()) - defer helpers.DeleteS3Bucket(t, helpers.TerraformRemoteStateS3Region, s3BucketName, options.WithIAMRoleARN(assumeRole), options.WithIAMWebIdentityToken(os.Getenv(tokenEnvVar))) + role := os.Getenv("AWS_TEST_OIDC_ROLE_ARN") + require.NotEmpty(t, role) + token := os.Getenv("CIRCLE_OIDC_TOKEN_V2") + require.NotEmpty(t, token) + + tokenFile := t.TempDir() + "/oidc-token" + require.NoError(t, os.WriteFile(tokenFile, []byte(token), 0400)) + + defer helpers.DeleteS3Bucket(t, helpers.TerraformRemoteStateS3Region, s3BucketName, options.WithIAMRoleARN(role), options.WithIAMWebIdentityToken(token)) helpers.CopyAndFillMapPlaceholders(t, originalTerragruntConfigPath, tmpTerragruntConfigFile, map[string]string{ - "__FILL_IN_BUCKET_NAME__": s3BucketName, - "__FILL_IN_REGION__": helpers.TerraformRemoteStateS3Region, - "__FILL_IN_ASSUME_ROLE__": assumeRole, - "__FILL_IN_IDENTITY_TOKEN_ENV_VAR__": tokenEnvVar, + "__FILL_IN_BUCKET_NAME__": s3BucketName, + "__FILL_IN_REGION__": helpers.TerraformRemoteStateS3Region, + "__FILL_IN_ASSUME_ROLE__": role, + "__FILL_IN_IDENTITY_TOKEN_FILE_PATH__": tokenFile, }) stdout := bytes.Buffer{} stderr := bytes.Buffer{} - err := helpers.RunTerragruntCommand(t, "terragrunt apply -auto-approve --terragrunt-non-interactive --terragrunt-working-dir "+testPath, &stdout, &stderr) + err := helpers.RunTerragruntCommand(t, "terragrunt apply -auto-approve --terragrunt-non-interactive --terragrunt-log-level debug --terragrunt-working-dir "+testPath, &stdout, &stderr) require.NoError(t, err) output := fmt.Sprintf("%s %s", stderr.String(), stdout.String()) assert.Contains(t, output, "Apply complete! Resources: 1 added, 0 changed, 0 destroyed.") } -func TestAwsAssumeRoleWebIdentityFile(t *testing.T) { - t.Parallel() - - tmpEnvPath := helpers.CopyEnvironment(t, testFixtureAssumeRoleWebIdentityFile) - helpers.CleanupTerraformFolder(t, tmpEnvPath) - testPath := util.JoinPath(tmpEnvPath, testFixtureAssumeRoleWebIdentityFile) - - originalTerragruntConfigPath := util.JoinPath(testFixtureAssumeRoleWebIdentityFile, "terragrunt.hcl") - tmpTerragruntConfigFile := util.JoinPath(testPath, "terragrunt.hcl") - s3BucketName := "terragrunt-test-bucket-" + strings.ToLower(helpers.UniqueID()) +func TestAwsAssumeRoleWebIdentityFlag(t *testing.T) { + if os.Getenv("CIRCLECI") != "true" { + t.Skip("Skipping test because it requires valid CircleCI OIDC credentials to work") + } - assumeRole := os.Getenv("AWS_TEST_S3_ASSUME_ROLE") - tokenFilePath := os.Getenv("AWS_TEST_S3_IDENTITY_TOKEN_FILE_PATH") + // These tests need to be run without the static key + secret + // used by most AWS tests here. + t.Setenv("AWS_ACCESS_KEY_ID", "") + os.Unsetenv("AWS_ACCESS_KEY_ID") + t.Setenv("AWS_SECRET_ACCESS_KEY", "") + os.Unsetenv("AWS_SECRET_ACCESS_KEY") - defer helpers.DeleteS3Bucket(t, helpers.TerraformRemoteStateS3Region, s3BucketName, options.WithIAMRoleARN(assumeRole), options.WithIAMWebIdentityToken(tokenFilePath)) + tmp := t.TempDir() - helpers.CopyAndFillMapPlaceholders(t, originalTerragruntConfigPath, tmpTerragruntConfigFile, map[string]string{ - "__FILL_IN_BUCKET_NAME__": s3BucketName, - "__FILL_IN_REGION__": helpers.TerraformRemoteStateS3Region, - "__FILL_IN_ASSUME_ROLE__": assumeRole, - "__FILL_IN_IDENTITY_TOKEN_FILE_PATH__": tokenFilePath, - }) + emptyTerragruntConfigPath := filepath.Join(tmp, "terragrunt.hcl") + require.NoError(t, os.WriteFile(emptyTerragruntConfigPath, []byte(""), 0400)) - stdout := bytes.Buffer{} - stderr := bytes.Buffer{} + emptyMainTFPath := filepath.Join(tmp, "main.tf") + require.NoError(t, os.WriteFile(emptyMainTFPath, []byte(""), 0400)) - err := helpers.RunTerragruntCommand(t, "terragrunt apply -auto-approve --terragrunt-non-interactive --terragrunt-working-dir "+testPath, &stdout, &stderr) - require.NoError(t, err) + roleARN := os.Getenv("AWS_TEST_OIDC_ROLE_ARN") + require.NotEmpty(t, roleARN) + token := os.Getenv("CIRCLE_OIDC_TOKEN_V2") + require.NotEmpty(t, token) - output := fmt.Sprintf("%s %s", stderr.String(), stdout.String()) - assert.Contains(t, output, "Apply complete! Resources: 1 added, 0 changed, 0 destroyed.") + helpers.RunTerragrunt(t, "terragrunt apply --terragrunt-non-interactive --terragrunt-log-level debug --terragrunt-working-dir "+tmp+" --terragrunt-iam-role "+roleARN+" --terragrunt-iam-web-identity-token "+token) } // Regression testing for https://github.com/gruntwork-io/terragrunt/issues/906 @@ -1096,6 +1101,21 @@ func TestAwsReadTerragruntAuthProviderCmdWithSops(t *testing.T) { assert.Equal(t, "Welcome to SOPS! Edit this file as you please!", outputs["hello"].Value) } +func TestAwsReadTerragruntAuthProviderCmdWithOIDC(t *testing.T) { + t.Parallel() + + if os.Getenv("CIRCLECI") != "true" { + t.Skip("Skipping test because it requires valid CircleCI OIDC credentials to work") + } + + cleanupTerraformFolder(t, testFixtureAuthProviderCmd) + tmpEnvPath := helpers.CopyEnvironment(t, testFixtureAuthProviderCmd) + oidcPath := util.JoinPath(tmpEnvPath, testFixtureAuthProviderCmd, "oidc") + mockAuthCmd := filepath.Join(oidcPath, "mock-auth-cmd.sh") + + helpers.RunTerragrunt(t, fmt.Sprintf(`terragrunt apply -auto-approve --terragrunt-non-interactive --terragrunt-working-dir %s --terragrunt-auth-provider-cmd %s`, oidcPath, mockAuthCmd)) +} + func TestAwsReadTerragruntConfigIamRole(t *testing.T) { t.Parallel()