Golangはじめて物語(第7話: 権限の限られた環境でAssumeRoleしてローカルテストをする)

# はじめに
セキュリティのために、ログイン用の IAM ユーザーの権限が限られていてクレデンシャルを使っても権限不足でアプリケーションのローカルテストができなくて困るケースがあるかと思う。Docker を使って DynamoDB Local を起動するとか、いくつか方法はあるかと思うが、今回は、実際のAWSのサービスに接続せざるを得ないケースに対応する方法をまとめる。

なお、この記事の内容を行う場合、

- ローカルなり EC2 で AWS CLI が使える

という環境が必要になる。また、Terraform があると検証がしやすい。

# AssumeRole で権限を委任しよう
「はじめに」に書いた環境では、だいたい用途に応じたスイッチロールが準備されているかと思うので、基本はそのロールで AssumeRole しておくことになる。詳細は[AWSのブログ](https://aws.amazon.com/jp/premiumsupport/knowledge-center/iam-assume-role-cli/)にも記載があるので、参考にしてもらえればと思う。

今回は、検証用に以下の IAM ユーザとロールを作ることにする。ロールは、今回は DynamoDB の権限を持ったものとする。
Terraform でサクッと作れるようにしてみた。

```HCL:権限の少ないIAMユーザ
resource "aws_iam_user" "example" {
  name = local.iam_user_name
}

resource "aws_iam_access_key" "example" {
  user = aws_iam_user.example.name
}

resource "aws_iam_policy_attachment" "example" {
  name       = local.iam_user_policy_attachment_name
  users      = [aws_iam_user.example.name]
  policy_arn = "arn:aws:iam::aws:policy/IAMUserChangePassword"
}
```

```HCL:DynamoDBのアクセス権を持ったIAMロール(スイッチロール用)
resource "aws_iam_role" "dynamodb_access" {
  name               = local.dynamodb_access_role_name
  assume_role_policy = data.aws_iam_policy_document.dynamodb_access_policy_assume.json
}

data "aws_iam_policy_document" "dynamodb_access_policy_assume" {
  statement {
    effect = "Allow"

    actions = [
      "sts:AssumeRole",
    ]

    principals {
      type = "AWS"
      identifiers = [
        "arn:aws:iam::${data.aws_caller_identity.self.account_id}:user/${local.iam_user_name}",
      ]
    }
  }
}

resource "aws_iam_role_policy_attachment" "dynamodb_access" {
  role       = aws_iam_role.dynamodb_access.name
  policy_arn = aws_iam_policy.dynamodb_access_custom.arn
}

resource "aws_iam_policy" "dynamodb_access_custom" {
  name   = local.dynamodb_access_policy_name
  policy = data.aws_iam_policy_document.dynamodb_access_custom.json
}

data "aws_iam_policy_document" "dynamodb_access_custom" {
  statement {
    effect = "Allow"

    actions = [
      "dynamodb:BatchGetItem",
      "dynamodb:GetItem",
      "dynamodb:Query",
      "dynamodb:Scan",
      "dynamodb:BatchWriteItem",
    ]

    resources = [
      "*",
    ]
  }
}
```

作ったユーザとロールの情報を、Terraform のアウトプットにしておこう。

```HCL
output "iam_user_id" {
  value = aws_iam_access_key.example.id
}

output "iam_user_secret" {
  value = aws_iam_access_key.example.secret
}

output "dynamodb_access_role_arn" {
  value = aws_iam_role.dynamodb_access.arn
}
```

# まずは CLI で確認する
さて、ユーザの準備ができたら、正しくスイッチロールして権限が付与されることを確認しよう。
まずは、作ったユーザで CLI で利用できるようにする。
すでにいろいろやるためにデフォルトユーザが作られていると思うので、プロファイルを追加しよう。

```shell-session
$ aws configure --profile local-test-sample-user
```

入力を求められるので、以下のように入力する。

```shell-session
AWS Access Key ID [None]: [terraform の output から転記]
AWS Secret Access Key [None]: [terraform の output から転記]
Default region name [None]: ap-northeast-1
Default output format [None]: json
```

また、うっかり権限ありのデフォルトユーザでアクセスしてしまうことを防ぐために、今回作ったユーザでデフォルトを上書きしておく。

```shell-session
$ export AWS_DEFAULT_PROFILE=local-test-sample-user
```

さて、この状態で DynamoDB にアクセスする。

```shell-session
$ aws dynamodb get-item --table-name [DynamoDB のテーブル名] --key '{"id":{"S":"00001"}}'
```

すると、

```
An error occurred (AccessDeniedException) when calling the GetItem operation: User: [IAMユーザ名] is not authorized to perform: dynamodb:GetItem on resource: [DynamoDB のテーブル名]
```

と表示される。

ここで、AssumeRole の出番だ。AssumeRole によって、DynamoDB のアクセス権のあるロールを引き取ってアクセス可能にする。

```shell-session
$ aws sts assume-role --role-arn [terraform の output から転記] --role-session-name AWSCLI-Session
```

実行すると

```JSON
{
    "Credentials": {
        "AccessKeyId": "xxxxxxxxxxxxxxxxxxxx",
        "SecretAccessKey": "xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx",
        "SessionToken": "長ーいトークン情報",
        "Expiration": "2021-01-31T03:47:22Z"
    },
    "AssumedRoleUser": {
        "AssumedRoleId": "xxxxxxxxxxxxxxxxxxxx:AWSCLI-Session",
        "Arn": "arn:aws:sts::[アカウントID]:assumed-role/[IAMロール名]/AWSCLI-Session"
    }
}
```

と出力されるので、この出力を元に

```shell-session
$ export AWS_ACCESS_KEY_ID=[AccessKeyIdの値]
$ export AWS_SECRET_ACCESS_KEY=[SecretAccessKeyの値]
$ export AWS_SESSION_TOKEN=[SessionTokenの値]
```

と設定する。

これでもう一度、DynamoDB にアクセスすると、今度は情報を取得できるはずだ。

戻すときには、

```shell-session
$ unset AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_SESSION_TOKEN
$ unset AWS_DEFAULT_PROFILE
```

とすれば良い。

# Golang でも試してみる
さて、Golang で作ったプログラムも基本は同じである。
というか、CLI の動作する環境であれば、上記の設定をしてしまえば、プログラムを書き換える必要なく動作するようになるはずである。
※GoSDK のセッションは、デフォルトでIAMや現在のクレデンシャル情報を使ってくれるため、CLI での AssumeRole ができていればその情報を使ってくれるのである。

しかし、もし仮に CLI がインストールできていないという悲しい環境であった場合は、以下のようにすれば同様の動作をさせることができる。
環境変数 ```LOCAL_TEST_ROLE_ARN``` には、AssumeRole する IAM ロールを指定する。

```golang
    if (os.Getenv("LOCAL_TEST_ROLE_ARN") == "" ) {
        sess = session.Must(session.NewSessionWithOptions(session.Options{
            SharedConfigState: session.SharedConfigEnable,
        }))
    } else {
        creds := stscreds.NewCredentials(session.Must(session.NewSessionWithOptions(
                     session.Options{
                         SharedConfigState: session.SharedConfigEnable,
                     }
                 )), os.Getenv("LOCAL_TEST_ROLE_ARN"))
        sess = session.New(&aws.Config{
                   Region     : aws.String("ap-northeast-1"),
                   Credentials: creds,
               })
    }
```

これで動かせば、内部で AssumeRole して良い感じに動作するようになる。

なお、AssumeRole はトークン払い出しの処理があるからか、1秒くらい時間がかかる。単発の接続ではあまり問題にならないが、まとめて試験する際に都度 AssumeRole していると時間がかかって邪魔になるので、init で1回だけ接続するように作っておいたほうが良い。

# MFA認証にも対応する
さて、イマドキのAWS環境では、基本的にIAMユーザはMFAを有効化しないと使えないようになっているのではなかろうか。
ということで、スイッチロールなんかも、以下のような感じで、MFAの認証を使わないと使えないようになっていることが多いと思う。

```HCL:DynamoDBのアクセス権を持ったIAMロール(スイッチロール用)
resource "aws_iam_role" "dynamodb_access_mfa" {
  name               = local.dynamodb_access_mfa_role_name
  assume_role_policy = data.aws_iam_policy_document.dynamodb_access_mfa_policy_assume.json
}

data "aws_iam_policy_document" "dynamodb_access_mfa_policy_assume" {
  statement {
    effect = "Allow"

    actions = [
      "sts:AssumeRole",
    ]

    principals {
      type = "AWS"
      identifiers = [
        "arn:aws:iam::${data.aws_caller_identity.self.account_id}:user/${local.iam_user_name}",
      ]
    }

    condition {
      test = "Bool"
      variable = "aws:MultiFactorAuthPresent"

      values = [
        "true",
      ]
    }
  }
}

resource "aws_iam_role_policy_attachment" "dynamodb_access_mfa" {
  role       = aws_iam_role.dynamodb_access_mfa.name
  policy_arn = aws_iam_policy.dynamodb_access_mfa_custom.arn
}

resource "aws_iam_policy" "dynamodb_access_mfa_custom" {
  name   = local.dynamodb_access_mfa_policy_name
  policy = data.aws_iam_policy_document.dynamodb_access_mfa_custom.json
}

data "aws_iam_policy_document" "dynamodb_access_mfa_custom" {
  statement {
    effect = "Allow"

    actions = [
      "dynamodb:BatchGetItem",
      "dynamodb:GetItem",
      "dynamodb:Query",
      "dynamodb:Scan",
      "dynamodb:BatchWriteItem",
      "dynamodb:PutItem",
      "dynamodb:UpdateItem",
      "dynamodb:DeleteItem",
    ]

    resources = [
      "*",
    ]
  }
}
```

ミソになるのは

```HCL
    condition {
      test = "Bool"
      variable = "aws:MultiFactorAuthPresent"

      values = [
        "true",
      ]
    }
```

の部分だ。この条件が設定されていると、AssumeRole時にMFA認証をしないと権限を引き取れない。

こういったケースで AssumeRole するには、以下のようにコマンドラインを実行すれば良い。

```shell-session
$ aws sts assume-role --role-arn [terraform の output から転記] --role-session-name AWSCLI-Session --serial-number arn:aws:iam::xxxxxxxxxxxx:mfa/[AssumeRole 元の IAM ユーザ] --token-code xxxxxx
```

当然ながら、AssumeRole 元の IAM ユーザは、MFA の設定を有効化しておく必要がある。
あとは、```--token-code``` に、二段階認証のコードを設定すれば良い。

Golang でも、同様に MFA 認証による AssumeRole をして実行することができる。
↑の接続の ```stscreds.NewCredentials``` を実行している部分を以下のように変更する。

```golang
    creds := stscreds.NewCredentials(session.Must(session.NewSessionWithOptions(session.Options{
        SharedConfigState: session.SharedConfigEnable,
    })), os.Getenv("LOCAL_TEST_ROLE_ARN"), func(p *stscreds.AssumeRoleProvider) {
        p.SerialNumber = aws.String(os.Getenv("MFA_SERIAL_NUMBER"))
        p.TokenCode = aws.String(os.Getenv("MFA_TOKEN_CODE"))
    })
```

```os.Getenv("MFA_TOKEN_CODE")``` の部分はコマンドライン引数にしても何でもよい(少なくとも、環境変数をいちいち設定するのはナンセンスだろう。例では簡単だからこう書いてみただけ)
面倒であれば、

```golang
    creds := stscreds.NewCredentials(session.Must(session.NewSessionWithOptions(session.Options{
        SharedConfigState: session.SharedConfigEnable,
    })), os.Getenv("LOCAL_TEST_ROLE_ARN"), func(p *stscreds.AssumeRoleProvider) {
        p.SerialNumber = aws.String(os.Getenv("MFA_SERIAL_NUMBER"))
        p.TokenProvider = stscreds.StdinTokenProvider
    })
```

とすれば、実行時に標準入力からトークンを入力可能になる。