[AWS × Terraform] plan できるけど apply できない GitOps な IAM ユーザーポリシーの設定方法
ちゃだいん(@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です。
{ "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バケットのみです。
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)でした。