Skip to content

Commit

Permalink
chore: Cleaning up OIDC Work (#3554)
Browse files Browse the repository at this point in the history
* fix: Adding docs on `--terragrunt-iam-web-identity-token`

* fix: Adjusting tests for CircleCI

* fix: Removing key + secret before testing OIDC

* fix: Fixing up some testing

* fix: Fixing up tests by using the proper flag name

* fix: Fixing up tests by using the proper flag names

* fix: Adding debug logs

* fix: Use manually created empty files

* feat: Adding `auth-provider-cmd` support for OIDC

* fix: Fixing typo in path to mock auth command

* fix: Fixing unrelated test failures

* fix: Fixing more missed merge conflicts
  • Loading branch information
yhakbar authored Nov 14, 2024
1 parent 580998c commit d5ed96d
Show file tree
Hide file tree
Showing 9 changed files with 171 additions and 65 deletions.
3 changes: 3 additions & 0 deletions .circleci/config.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down
77 changes: 75 additions & 2 deletions cli/commands/terraform/creds/providers/externalcmd/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -66,17 +67,29 @@ 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
}

type Response struct {
AWSCredentials *AWSCredentials `json:"awsCredentials"`
AWSRole *AWSRole `json:"awsRole"`
Envs map[string]string `json:"envs"`
}

Expand All @@ -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 == "" {
Expand Down
19 changes: 19 additions & 0 deletions docs/_docs/04_reference/cli-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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`<br/>
**Environment Variable**: `TERRAGRUNT_IAM_WEB_IDENTITY_TOKEN`<br/>
**Requires an argument**: `--terragrunt-iam-web-identity-token [/path/to/web-identity-token | web-identity-token-value]`<br/>

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`<br/>
Expand Down Expand Up @@ -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": ""
}
Expand All @@ -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`<br/>
Expand Down
4 changes: 0 additions & 4 deletions test/fixtures/assume-role-web-identity/env-var/main.tf

This file was deleted.

16 changes: 0 additions & 16 deletions test/fixtures/assume-role-web-identity/env-var/terragrunt.hcl

This file was deleted.

Empty file.
11 changes: 11 additions & 0 deletions test/fixtures/auth-provider-cmd/oidc/mock-auth-cmd.sh
Original file line number Diff line number Diff line change
@@ -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}}'
Empty file.
106 changes: 63 additions & 43 deletions test/integration_aws_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down

0 comments on commit d5ed96d

Please sign in to comment.