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 を 2 層に分けています。アプリ層(Lambda、DynamoDB、S3、EventBridge、監視)と、bootstrap 層(Actions が引き受ける IAM ロールと OIDC プロバイダー、つまりアプリ層を apply するための権限そのもの)。
アプリ層は以前から Actions で回していました。今回変えたのは bootstrap 層で、ここだけはローカルから 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 は意図的に付与しないアプリ層の権限も、この系のリソース名の接頭辞に限定しています。効果は 2 つで、事故の範囲がこの bot に閉じることと、新しい種類のリソースを使うときに必ず bootstrap 層の変更が先に要ることです。後者は手間ですが、「どの権限が増えたか」が PR として必ず残ります。順序は、bootstrap の PR で権限を足す、workflow_dispatch で apply、そのあとアプリ層の PR をマージ、です。
危なかったところ: 信頼ポリシーの github_repo が違っていた
provider.tf の github_repo の既定値が、組織に移す前の個人アカウントの名前のまま残っていました。この値は信頼ポリシーの sub 条件に入ります。気づかずに Actions から apply していたら、信頼ポリシーが「存在しないリポジトリからのトークンだけ受け付ける」に書き換わり、以後 Actions はロールを引き受けられなくなります。自分で自分を締め出す形です。bootstrap 層を Actions に寄せる PR の中で見つけて直しましたが、見つけたのは apply の前ではなく、レビューで信頼ポリシーの差分を読んだときでした。
検証
terraform fmt と validate を通し、初回の apply の差分が「手動で作っていた自己管理ポリシーの取り込み」と「デプロイ用ロールへの権限追加」だけであることを plan で確認してから apply しました。信頼ポリシーの sub 条件は、既存の state と同じ値になることを先に確かめています。
どこで止まるか
mainへのマージが、そのまま本番デプロイです。マージの判断がデプロイの判断になります。個人の bot なので、プレビュー環境は意図的に持っていません。失敗するのは apply そのものなので、現行の Lambda は動き続けます- bootstrap 層の apply は手動トリガーです。自動にしていないのは、権限の変更だけは人が差分を読んでから当てたいからです
- 新しいリソースの種類を足すたびに、bootstrap の PR が 1 本先に要ります。頻度は低いので許容しています
この構成で気をつけること
- サンドボックスに認証情報を「注入する」仕組みは、注入先が本当に自分のコンテナか確かめる。書けたように見えて別のコンテナに書いていることがある
- インフラを触る経路を 1 つ(Actions の OIDC)にすると、認証情報を置く場所を考える問題が消える
- 自分自身を書き換えるロールには、自分を消す権限を与えない
- 信頼ポリシーに入る値(リポジトリ名)の既定値は、間違えると自分を締め出す。apply 前に差分で読む