GitHub Actions の OIDC で AWS の鍵を手元から無くした
Slack から 4 コマ漫画を生成して X に投稿する bot を AWS Lambda で動かしていて、インフラは Terraform で管理しています。開発は Claude Code の Docker Sandbox の中で行っていて、そこに AWS の認証情報を渡すためのスクリプトを 3 本書きましたが、3 本とも一度も機能していませんでした。以下は、機能していなかった原因と、認証情報をローカルにもサンドボックスにも置かない構成に切り替えた記録です。
症状: 認証情報を注入するスクリプトが別のコンテナに書いていた
サンドボックスの中から terraform apply を叩くために、ホスト側から docker sandbox exec <名前> ... で環境変数のファイルにキーを書き込むスクリプトを用意していました。書き込みは成功するのに、サンドボックスの中で AWS のコマンドを叩くと認証が通りませんでした。
原因は、docker sandbox exec が Claude Code のセッションが使っている実際のコンテナではなく、同じ名前の別の(停止中の)コンテナを起動して、そこに書き込んでいたことでした。書き込みは成功しても私が作業しているコンテナには何も届いていなかったので、3 本のスクリプトは削除しました。
同じサンドボックスで GitHub の認証が普通に通るのは、サンドボックスのプロキシがネットワークの層でトークンを差し替えているからです(この仕組みは Claude Code の Docker Sandbox で API キーが渡らない原因 に書きました)。AWS の認証情報にはこの差し替えが無いので、サンドボックスに認証情報を渡すこと自体をやめました。
直し方: インフラの操作を全部 GitHub Actions で実行する
.github/workflows/
ci.yml # PR: lint / typecheck / test
terraform-plan.yml # PR: アプリ層の plan
deploy.yml # main への push: アプリ層の apply(= 本番デプロイ)
bootstrap-apply.yml # 手動: bootstrap 層の plan / apply を選んで実行
terraform/
lambda.tf, dynamodb.tf, eventbridge.tf, ... # アプリ層
bootstrap/
oidc.tf # OIDC プロバイダーと、Actions が引き受けるロール
provider.tf # github_repo(信頼ポリシーの sub 条件に使う)Terraform は アプリ層(Lambda、DynamoDB、S3、EventBridge、監視)と bootstrap 層(Actions が引き受ける IAM ロールと OIDC プロバイダー、つまりアプリ層を apply するための権限そのもの)の 2 層に分けています。
アプリ層は以前から Actions で回していました。今回変えたのは bootstrap 層です。bootstrap 層だけはローカルから apply する運用が残っていて、サンドボックスへ認証情報を渡そうとしていたのはこの apply のためでした。bootstrap 層も workflow_dispatch で plan か apply を選んで実行するワークフローにし、PR で terraform/bootstrap/** が変わったときは plan が走るようにしました。これで AWS を操作するのは Actions だけになり、手元にキーを置く必要が無くなりました。
ロールが自分自身を書き換える権限の線引き
bootstrap 層を Actions から apply すると、Actions が引き受けるロールが自分自身のポリシーを書き換えることになるので、ロールに与える権限を次のように限りました。
- ロールの信頼ポリシーは、このリポジトリの
mainブランチと pull_request からの OIDC トークンだけを受け付ける - ロール自身のポリシーを更新する
iam:PutRolePolicy/iam:DeleteRolePolicyは許可する iam:CreateRole/iam:DeleteRoleは意図的に付与しない。ロールを消せる権限をそのロール自身に持たせると、apply の誤りでロールが自分を消せてしまう
# bootstrap/oidc.tf(抜粋)
StringLike = {
"token.actions.githubusercontent.com:sub" = [
"repo:${var.github_repo}:ref:refs/heads/main",
"repo:${var.github_repo}:pull_request",
]
}
# NOTE: iam:CreateRole / iam:DeleteRole は意図的に付与しないアプリ層の権限も、この bot のリソース名の接頭辞に限定しています。このため事故が起きても影響はこの bot のリソースだけで済み、新しい種類のリソースを使うときは必ず先に bootstrap 層を変更することになります。bootstrap 層の変更は手間ですが、「どの権限が増えたか」が PR として必ず残ります。bootstrap の PR で権限を足し、workflow_dispatch で apply してから、アプリ層の PR をマージする順に進めます。
危なかったところ: 信頼ポリシーの github_repo が違っていた
provider.tf の github_repo の既定値が、組織に移す前の個人アカウントの名前のまま残っていました。この値は信頼ポリシーの sub 条件に入るので、気づかずに Actions から apply していたら信頼ポリシーが「存在しないリポジトリからのトークンだけ受け付ける」に書き換わり、以後 Actions はロールを引き受けられなくなっていました。bootstrap 層を Actions で apply するように変える PR の中で見つけて直しましたが、見つけたのは apply の前ではなく、レビューで信頼ポリシーの差分を読んだときでした。
検証
terraform fmt と validate を通し、初回の apply の差分が「手動で作っていた自己管理ポリシーの取り込み」と「デプロイ用ロールへの権限追加」だけであることを plan で確認してから apply しました。信頼ポリシーの sub 条件は、既存の state と同じ値になることを先に確かめています。
main へのマージが本番デプロイになり、権限の変更だけは手動で当てる
mainへのマージがそのまま本番デプロイなので、マージの判断がデプロイの判断になります。個人の bot なのでプレビュー環境は意図的に持っていません。失敗するのは apply だけなので、現行の Lambda は動き続けます- bootstrap 層の apply は手動トリガーです。自動にしていないのは、権限の変更だけは人が差分を読んでから当てたいからです
- 新しいリソースの種類を足すたびに、bootstrap の PR が 1 本先に要ります。頻度は低いので許容しています
認証情報の置き場所を無くし、ロールの権限を絞る
- サンドボックスに認証情報を注入する仕組みは、書き込みが成功しても別のコンテナに書いていることがあるので、注入先が本当に自分のコンテナか確かめる
- インフラを触る経路を Actions の OIDC だけにすると、認証情報をどこに置くかを考えなくて済む
- 自分自身を書き換えるロールには、自分を消す権限を与えない
- 信頼ポリシーに入る値(リポジトリ名)の既定値を間違えると Actions がロールを引き受けられなくなるので、apply 前に差分で読む