GitHub ActionsからTerraformを実行するとき、IAMユーザーのアクセスキーをGitHubのSecretsに登録していないでしょうか。この方法は手軽ですが、長期間有効な鍵を外部に置き続けることになります。
この記事では、OIDC(OpenID Connect)連携を使い、アクセスキーを一切発行せずにGitHub ActionsからAWSを操作する仕組みをTerraformで作ります。
アクセスキーを置くことの問題
IAMユーザーのアクセスキーには、次のような問題があります。
- 有効期限がない:漏えいすると、無効化するまで使われ続ける
- ローテーションが手間:定期的に作り直してSecretsを更新する運用が必要になる
- どこで使われたか追いにくい:同じ鍵を複数のリポジトリで使い回すと、影響範囲が分からなくなる
OIDC連携では、ワークフローの実行ごとに数十分〜1時間程度で失効する一時的な認証情報が発行されます。保管すべき鍵そのものがなくなります。
OIDC連携の仕組み
流れは次のとおりです。
- GitHub Actionsが、実行中のワークフローの情報(リポジトリ名、ブランチ名など)を含むトークンを発行する
- ワークフローがそのトークンをAWS STSに渡し、IAMロールの引き受けを求める
- AWSは、IAMロールの信頼ポリシーとトークンの内容を照合する
- 条件に合えば、一時的な認証情報が返される
ここで重要なのは3番です。**信頼ポリシーで「どのリポジトリの、どのブランチ(またはPR)からなら引き受けてよいか」を絞り込みます。**この絞り込みが甘いと、他人のリポジトリからでもロールを引き受けられてしまいます。
OIDCプロバイダーの作成
まず、GitHubをIDプロバイダーとしてAWSに登録します。これはAWSアカウントに1つあれば十分です。
resource "aws_iam_openid_connect_provider" "github" {
url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
}
以前はthumbprint_list(証明書のフィンガープリント)の指定が必須でした。現在はAWS側で証明書を検証するため、新しいAWSプロバイダーでは省略できます。古いバージョンでエラーになる場合は、プロバイダーを更新してください。
planとapplyでロールを分ける
ロールは用途ごとに2つ作ります。
- plan用ロール:Pull Requestで実行する。読み取り権限とStateの参照だけを持つ
- apply用ロール:mainブランチへのマージ後に実行する。リソースを変更できる権限を持つ
こうしておけば、PRのブランチに悪意のあるコードが入っても、plan用ロールではリソースを変更できません。
variable "github_repository" {
type = string
description = "owner/repo の形式"
}
data "aws_iam_policy_document" "plan_trust" {
statement {
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.github.arn]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:sub"
values = ["repo:${var.github_repository}:pull_request"]
}
}
}
data "aws_iam_policy_document" "apply_trust" {
statement {
actions = ["sts:AssumeRoleWithWebIdentity"]
principals {
type = "Federated"
identifiers = [aws_iam_openid_connect_provider.github.arn]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:aud"
values = ["sts.amazonaws.com"]
}
condition {
test = "StringEquals"
variable = "token.actions.githubusercontent.com:sub"
values = ["repo:${var.github_repository}:ref:refs/heads/main"]
}
}
}
resource "aws_iam_role" "terraform_plan" {
name = "github-actions-terraform-plan"
assume_role_policy = data.aws_iam_policy_document.plan_trust.json
}
resource "aws_iam_role" "terraform_apply" {
name = "github-actions-terraform-apply"
assume_role_policy = data.aws_iam_policy_document.apply_trust.json
}
sub(subject)の値は、ワークフローの実行状況によって次のように変わります。
- Pull Requestから実行:repo:owner/repo:pull_request
- ブランチへのpushで実行:repo:owner/repo:ref:refs/heads/ブランチ名
- Environmentを指定したジョブ:repo:owner/repo:environment:環境名
**ジョブにenvironmentを指定すると、ブランチ名ではなく環境名がsubに入ります。**本番デプロイにEnvironmentの承認機能を使う場合は、apply用ロールの条件をenvironment形式に合わせてください。
ロールに権限を付ける
plan用ロールには、読み取り権限と、State(S3)の読み取り、ロック用の書き込みを付けます。
variable "tfstate_bucket" {
type = string
}
resource "aws_iam_role_policy_attachment" "plan_readonly" {
role = aws_iam_role.terraform_plan.name
policy_arn = "arn:aws:iam::aws:policy/ReadOnlyAccess"
}
resource "aws_iam_role_policy" "plan_state" {
name = "terraform-state-access"
role = aws_iam_role.terraform_plan.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Action = ["s3:ListBucket"]
Resource = "arn:aws:s3:::${var.tfstate_bucket}"
},
{
Effect = "Allow"
Action = ["s3:GetObject"]
Resource = "arn:aws:s3:::${var.tfstate_bucket}/*"
},
{
Effect = "Allow"
Action = ["s3:PutObject", "s3:DeleteObject"]
Resource = "arn:aws:s3:::${var.tfstate_bucket}/*.tflock"
}
]
})
}
最後のステートメントは、S3ネイティブのStateロック(use_lockfile = true)で使うロックファイル用です。DynamoDBでロックしている場合は、代わりにDynamoDBテーブルへの読み書き権限を付けます。
apply用ロールの権限は、Terraformで管理するリソースに合わせて決めます。最初はAdministratorAccessで動かし、IAM Access Analyzerなどで実際に使った権限を確認しながら絞っていくのが現実的です。
ワークフローの例
PRではplan、mainへのpushではapplyを実行します。
name: terraform
on:
pull_request:
branches: [main]
push:
branches: [main]
permissions:
id-token: write
contents: read
jobs:
terraform:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ github.event_name == 'pull_request' && vars.PLAN_ROLE_ARN || vars.APPLY_ROLE_ARN }}
aws-region: ap-northeast-1
- uses: hashicorp/setup-terraform@v3
- run: terraform init
- run: terraform plan -input=false
- if: github.event_name == 'push'
run: terraform apply -auto-approve -input=false
ロールのARNは秘密情報ではないので、SecretsではなくVariables(vars)に登録しています。
よくあるエラーと対処
Credentials could not be loaded
ワークフローにpermissionsのid-token: writeがありません。これがないと、GitHubがOIDCトークンを発行しません。
Not authorized to perform sts:AssumeRoleWithWebIdentity
信頼ポリシーのsubと、実際のトークンのsubが一致していません。次の点を確認します。
- リポジトリ名の大文字・小文字
- PRから実行しているのに、ブランチ形式の条件になっていないか
- ジョブにenvironmentを指定していないか
最初のロールをどう作るか
OIDCプロバイダーとロール自体は、GitHub Actionsからは作れません(まだロールがないため)。この部分だけは、手元から一度terraform applyします。
まとめ
- OIDC連携なら、アクセスキーを発行・保管せずにGitHub ActionsからAWSを操作できる
- 信頼ポリシーのsub条件で、リポジトリとブランチ(またはPR)を必ず絞り込む
- plan用とapply用でロールを分け、PRからはリソースを変更できないようにする
- Environmentを使うとsubの形式が変わる点に注意する
鍵を「安全に管理する」より、「そもそも鍵を持たない」ほうが確実です。新しくCI/CDを組むなら、最初からOIDC連携で始めることをおすすめします。