[AWS × Terraform] plan できるけど apply できない GitOps な IAM ユーザーポリシーの設定方法

知らんかった、terraform planってほぼReadOnlyAccessだけでいけるんや
2022.03.14

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

ちゃだいん(@chazuke4649)です。

AWS × Terraform 構成にて、terraform plan 等はできるけど terraform apply 等はできない GitOps な IAM ユーザーポリシーを試してみました。

※ ちなみにここで言う"GitOps"は、Kubernetesに限らず、TerraformなどのIaCツールでGitHubリポジトリのみを信頼できるソースとし、インフラCI/CDすることを指しています(使い方間違ってたら教えてください)

前段

前提

前提を要約します。

  • 環境
    • Terraform: v1.0.10
    • aws provider: v3.67.0
  • TerraformによるインフラCI/CD構成
  • チーム開発のため、ローカルからは変更できず、GitHub経由でしか変更しない運用という設定
  • バックエンドはリモートステート先としてS3バケット、排他制御としてDynamoDBを東京リージョンに作成済み

やりたいこと

TerraformにてAWSインフラをチーム開発する際に、ローカル環境から実行できること/できないことを管理したい場合があります。例えば、ローカルでコード開発を行う際に、 terraform plan を実行してコードが正しく定義できているか確認はしたいです。ただし、 terraform apply までできてしまうと、インフラCI/CDパイプラインをせっかく整備したのに、それらをスルーしてリソースを変更できることになってしまいます。これを避けるために、特定のローカル開発時に必要な権限は与えつつ、実際にクラウド環境に変更を加える場合はパイプラインを通さないとできない状態を目指します。

つまり開発者へ付与したい権限は以下のようなイメージです。

  • 許可したいこと: AWS実環境とstateファイルの参照権限
  • 禁止したいこと: AWS実環境とstateファイルの作成・変更・削除権限

さらに具体的にいうと、以下のようなイメージです。

ローカルマシンから実行できるコマンド

  • terraform init
  • terraform plan
  • terraform state list/show/pull

ローカルマシンから実行できないコマンド

  • terraform apply
  • terraform destroy
  • terraform state push

範囲外

以下の観点やコマンドは今回検証してません。

  • バージョン管理(Terraform, プロバイダー, モジュール)
  • terraform state mv
  • terraform import
  • terraform state force-unlock

また、GitHub Acitons側の設定についても割愛しています。以下は参考記事です。

巷で話題の GitHub Actions で AWS の IAM ロールを利用する方法を簡素なコードにしてみた with Terraform | zenn

GitHub Actions OIDCでconfigure-aws-credentialsでAssumeRoleする | DevelopersIO

IAMポリシー

さて本題ですが、これを実現するIAMユーザーポリシーは実はとても簡単でした。

  • ReadOnlyAccessのAWS管理ポリシー
  • DynamoDBのカスタムポリシー

この2つでOKです。

allowUpdateStateLockDdb.json

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "dynamodb:GetItem",
                "dynamodb:PutItem",
                "dynamodb:DeleteItem"
            ],
            "Resource": "arn:aws:dynamodb:ap-northeast-1:123456789012:table/terraform_state_lock"
        }
    ]
}

Backend Type: s3 | Terraform by HashiCorp

今回の構成では、DynamoDBにて排他制御を行なっており、該当するDynamoDBテーブルへのアイテム変更権限がないとエラーになります。

動作検証

では動作確認を行います。 作成するリソースはとてもシンプルなS3バケットのみです。

s3.tf

data "aws_caller_identity" "current" {}

output "account_id" {
  value = data.aws_caller_identity.current.account_id
}

resource "aws_s3_bucket" "test" {
  bucket = "test-${data.aws_caller_identity.current.account_id}"
  acl    = "private"
  tags = {
    hoge = "hoge"
  }
}

terraform init

initは実行できました。

% terraform init

Initializing the backend...

Successfully configured the backend "s3"! Terraform will automatically
use this backend unless the backend configuration changes.

### 中略 

Terraform has been successfully initialized!

terraform plan

planは実行できました。

% terraform plan

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  + create

Terraform will perform the following actions:

  # aws_s3_bucket.test will be created
  + resource "aws_s3_bucket" "test" {
      + acceleration_status         = (known after apply)
      + acl                         = "private"
      + arn                         = (known after apply)
      + bucket                      = "test-123456789012"
      + bucket_domain_name          = (known after apply)

### 中略

    }

Plan: 1 to add, 0 to change, 0 to destroy.

──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────

Note: You didn't use the -out option to save this plan, so Terraform can't guarantee to take exactly these actions if you run "terraform
apply" now.

terraform apply

applyはエラーになりました。

% terraform apply

### 中略

Plan: 1 to add, 0 to change, 0 to destroy.

Do you want to perform these actions?
  Terraform will perform the actions described above.
  Only 'yes' will be accepted to approve.

  Enter a value: yes

aws_s3_bucket.test: Creating...
╷
│ Error: Failed to save state
│
│ Error saving state: failed to upload state: AccessDenied: Access Denied
│ 	status code: 403, request id: D6S3CKYWFBZH3164, host id: uo4jh5vABn+rTEzi7hzwl...
╵
╷
│ Error: Failed to persist state to backend
│
│ The error shown above has prevented Terraform from writing the updated state to the configured backend. To allow for recovery, the state has
│ been written to the file "errored.tfstate" in the current working directory.
│
│ Running "terraform apply" again at this point will create a forked state, making it harder to recover.
│
│ To retry writing this state, use the following command:
│     terraform state push errored.tfstate
│
╵
╷
│ Error: Error creating S3 bucket: AccessDenied: Access Denied
│ 	status code: 403, request id: D6S9PD0ZCGC3NDRP, host id: jP9lSI54pS2WC8gr11h1l8xElzjxlZr...
│
│   with aws_s3_bucket.test,
│   on s3.tf line 11, in resource "aws_s3_bucket" "test":
│   11: resource "aws_s3_bucket" "test" {
│
  • 15/21行目はバックエンドとしてのS3バケットにstateファイルを保存できなかったというエラー
  • 31行目は作成対象としての新しいS3バケットを作成できなかったというエラー

次のコマンドの検証のために、一度別途Admin権限で terraform apply を実行しリソースを作ってしまいます。

terraform state list/show/pull

これら3つのコマンドはどれも実行できました。

## 管理されているリソースブロックのリストを取得
% terraform state list
data.aws_caller_identity.current
aws_s3_bucket.test


## S3バケットの詳細を取得
% terraform state show aws_s3_bucket.test
# aws_s3_bucket.test:
resource "aws_s3_bucket" "test" {
    acl                         = "private"
    arn                         = "arn:aws:s3:::test-123456789023"
    bucket                      = "test-123456789023"
    bucket_domain_name          = "test-123456789023.s3.amazonaws.com"

### 中略

    region                      = "ap-northeast-1"
    request_payer               = "BucketOwner"
    tags                        = {
        "hoge" = "hoge"
    }
    versioning {
        enabled    = false
        mfa_delete = false
    }
}

## stateファイルをローカルにプル
% terraform state pull > temp.tfstate

terraform state push

terraform state push はエラーになりました。

% terraform state push temp.tfstate
Failed to persist state: failed to upload state: AccessDenied: Access Denied
	status code: 403, request id: 0JZ8E2X8NE36DEWE, host id: 1UOBteInj/tmpNbxonDw5i1aTd1WX6TArKMlexCXo1OqaZRzjVvBOArc9Kei9DgvEtMipzYSeLw=

terraform destroy

あまり使う機会はありませんが、これをされたらたまりません。

% terraform destroy
aws_s3_bucket.test: Refreshing state... [id=test-123456789012]

Terraform used the selected providers to generate the following execution plan. Resource actions are indicated with the following symbols:
  - destroy

Terraform will perform the following actions:

  # aws_s3_bucket.test will be destroyed
  - resource "aws_s3_bucket" "test" {
      - acl                         = "private" -> null
      - arn                         = "arn:aws:s3:::test-123456789012" -> null
      - bucket                      = "test-123456789012" -> null
      - bucket_domain_name          = "test-123456789012.s3.amazonaws.com" -> null
      - bucket_regional_domain_name = "test-123456789012.s3.ap-northeast-1.amazonaws.com" -> null
      - force_destroy               = false -> null
      - hosted_zone_id              = "Z2M4EXAMPLE" -> null
      - id                          = "test-123456789012" -> null
      - region                      = "ap-northeast-1" -> null
      - request_payer               = "BucketOwner" -> null
      - tags                        = {
          - "hoge" = "hoge"
        } -> null
      - versioning {
          - enabled    = false -> null
          - mfa_delete = false -> null
        }
    }

Plan: 0 to add, 0 to change, 1 to destroy.

Changes to Outputs:
  - account_id = "123456789012" -> null

Do you really want to destroy all resources?
  Terraform will destroy all your managed infrastructure, as shown above.
  There is no undo. Only 'yes' will be accepted to confirm.

  Enter a value: yes

aws_s3_bucket.test: Destroying... [id=test-123456789012]
╷
│ Error: error deleting S3 Bucket (test-123456789012): AccessDenied: Access Denied
│ 	status code: 403, request id: 31BCR2TD2MZZHCPE, host id: Rmi3i713Ci8Fg2Uur7L8dkzxzS21...
│
│
╵
╷
│ Error: Failed to save state
│
│ Error saving state: failed to upload state: AccessDenied: Access Denied
│ 	status code: 403, request id: 31BF2WS47FT7XRGD, host id: SergPyp3iYQ/DQR7tJ4yaLdCeC9...
╵
╷
│ Error: Failed to persist state to backend
│
│ The error shown above has prevented Terraform from writing the updated state to the configured backend. To allow for recovery, the state has
│ been written to the file "errored.tfstate" in the current working directory.
│
│ Running "terraform apply" again at this point will create a forked state, making it harder to recover.
│
│ To retry writing this state, use the following command:
│     terraform state push errored.tfstate
│
  • 最初のapplyと同様に、作成済みのS3バケットの削除に失敗し、stateファイルの更新にも失敗しました

検証は以上です。

余談

ReadOnlyAccessはもっと絞れる?

絞ろうと思ったら絞れそうです。ただし、作成予定のAWSサービスに対し参照権限がないとplanでコケるようです。(例: AmazonS3ReadOnlyAccess だとVPCやEC2など作成予定の場合planがコケる模様)

終わりに

なんとなくplanするだけでも変更権限はいるだろうと思ってたのですが、やってみたらほぼ必要ありませんでした。試してナンボですね。

それではこの辺で。ちゃだいん(@chazuke4649)でした。