Terraform の AWS プロバイダのクレデンシャルの優先順が AWS CLI や AWS SDK と異なる

環境とか。

Terraform v0.13.4
+ provider registry.terraform.io/hashicorp/aws v3.9.0

Terraform でデプロイ対象の AWS アカウントが MFA 必須だったので aws-vault を使う前提で provider aws にはクレデンシャルの指定なし、一方で tfstate のバックエンドは MFA 無しの別の AWS アカウントの S3 バケットを利用するために profile を指定していました。

terraform {
  backend "s3" {
    profile = "ore"
    region  = "ap-northeast-1"
    bucket  = "ore-no-terraform"
    key     = "are.tfstate"
  }
}

provider "aws" {
  region  = "ap-northeast-1"
}

こんな変な構成にしていることが圧倒的に悪い気がしますが、これは期待通りにはなりません。次のように aws-vault で実行すると tfstate のバックエンドの S3 へも are のクレデンシャルでアクセスしてしまいます。

aws-vault exec are -- terraform plan

aws-vault が AWS STS から取得した一時的なクレデンシャルが AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY などの環境変数に設定されたうえで Terraform が実行されるのですが、環境変数のクレデンシャルが tf ファイルで指定している profile よりも優先されるためです。

原因

Terraform の AWS プロバイダは hashicorp/aws-sdk-go-base で AWS に接続します。

import (
    // ...snip...
    awsbase "github.com/hashicorp/aws-sdk-go-base"
    // ...snip...
)

// ...snip...

// Client configures and returns a fully initialized AWSClient
func (c *Config) Client() (interface{}, error) {
    // ...snip...
    sess, accountID, partition, err := awsbase.GetSessionWithAccountIDAndPartition(awsbaseConfig)
    // ...snip...
}

そして hashicorp/aws-sdk-go-base のこの辺りで profile よりも環境変数が優先されています。

   // build a chain provider, lazy-evaluated by aws-sdk
    providers := []awsCredentials.Provider{
        &awsCredentials.StaticProvider{Value: awsCredentials.Value{
            AccessKeyID:     c.AccessKey,
            SecretAccessKey: c.SecretKey,
            SessionToken:    c.Token,
        }},
        &awsCredentials.EnvProvider{},
        &awsCredentials.SharedCredentialsProvider{
            Filename: sharedCredentialsFilename,
            Profile:  c.Profile,
        },
    }

ちなみに環境変数 AWS_PROFILESharedCredentialsProviderprofile が指定されていないときのフォールバックになっているので、環境変数 AWS_PROFILE よりも直接指定された profile が優先されます。

// profile returns the AWS shared credentials profile.  If empty will read
// environment variable "AWS_PROFILE". If that is not set profile will
// return "default".
func (p *SharedCredentialsProvider) profile() string {
    if p.Profile == "" {
        p.Profile = os.Getenv("AWS_PROFILE")
    }
    if p.Profile == "" {
        p.Profile = "default"
    }

    return p.Profile
}

整理すると、次のような優先順になっています。

  • provider aws で指定した access_keysecret_key
  • 環境変数 AWS_ACCESS_KEY_IDAWS_SECRET_ACCESS_KEY
  • provider aws で指定した profile
  • 環境変数 AWS_PROFILE

AWS CLI はそうではありません。次のように実行すると ore のクレデンシャルが使用されていることがわかります。

aws-vault exec are -- aws sts --profile ore get-caller-identity --query Account

AWS SDK for Go も同じです。次のようなコードで確認できます。

package main

import (
    "flag"
    "fmt"
    "io/ioutil"
    "log"

    "github.com/aws/aws-sdk-go/aws/session"
    "github.com/aws/aws-sdk-go/service/iam"
    "github.com/aws/aws-sdk-go/service/sts"
    awsbase "github.com/hashicorp/aws-sdk-go-base"
)

func printAccountIdAndAlias(sess *session.Session) {
    stsClient := sts.New(sess)
    identity, err := stsClient.GetCallerIdentity(&sts.GetCallerIdentityInput{})
    if err != nil {
        panic(err)
    }
    iamClient := iam.New(sess)
    aliases, err := iamClient.ListAccountAliases(&iam.ListAccountAliasesInput{})
    if err != nil && len(aliases.AccountAliases) == 0 {
        fmt.Println(*identity.Account)
    } else {
        fmt.Println(*identity.Account, *aliases.AccountAliases[0])
    }
}

func main() {
    log.SetOutput(ioutil.Discard)
    profile := flag.String("profile", "default", "profile")
    flag.Parse()

    fmt.Print("aws/aws-sdk-go: ")
    printAccountIdAndAlias(session.Must(session.NewSessionWithOptions(session.Options{
        Profile: *profile,
    })))

    fmt.Print("hashicorp/aws-sdk-go-base: ")
    printAccountIdAndAlias(session.Must(awsbase.GetSession(&awsbase.Config{
        Profile: *profile,
    })))
}

次のように実行すると違いがわかります。

aws-vault exec are -- go run main.go -profile ore

さいごに

下記の記述のよると意図されたもののようです(AWS CLI と異なっているのが意図されたものかどうかはさておき)。