TL;DR
個人で運用している複数のリポジトリが1つのAWSアカウントを共有しており、それぞれのGitHub ActionsからAWSへOIDC(OpenID Connect、外部のIDプロバイダをAWS側が信頼して一時的な認証情報を発行する仕組み)経由でデプロイできるようにしました。GitHub Actions側のIAMロールを絞るだけでは不十分で、CDKの cdk deploy を実行する際に実際にリソース操作を行う CloudFormationExecutionRole (通称cfn-exec-role)が、既定では AdministratorAccess 相当の権限を持ってしまう点が抜け穴でした。 cdk bootstrap のオプションでこのロールをサービス単位のAction列挙まで絞り込んだカスタマー管理ポリシーと、自己昇格を防ぐpermissions boundaryに差し替えています。
全体の構成
登場する役者が多いので、先に流れを図にしておきます。
1〜5がGitHub OIDCフェデレーションの範囲、6〜8がcfn-exec-roleの範囲です。この記事はこの2つを順番に見ていきます。
複数リポジトリで1つのAWSアカウント
個人で複数のAWSアプリ(AIエージェント向けのバックエンドや、静的サイトのホスティングなど)を動かしていて、リポジトリはアプリごとに分けています。サンドボックス用と本番用のAWSアカウントを分けるほどの規模ではまだないため、今のところは全リポジトリが1つのAWSアカウントにデプロイする構成です。
各リポジトリのGitHub ActionsからAWSにアクセスする方法として、長期のIAMアクセスキーをGitHub Secretsに置くやり方も考えましたが、キーの漏洩やローテーション忘れのリスクが常につきまとう上に、リポジトリの数だけ管理の手間が増えます。GitHub ActionsはOIDCプロバイダとして動作でき、AWS側にIAM OIDC IDプロバイダとロールの信頼ポリシーを用意しておけば、ワークフロー実行のたびに短命な一時認証情報だけを受け取れます。この仕組みを1つのCDKスタックとしてアカウントに1回だけデプロイし、全リポジトリで共有することにしました。
GitHub OIDCフェデレーション
上の図の1〜5にあたる部分です。実体はGitHub Actions OIDCプロバイダ( token.actions.githubusercontent.com 、アカウントに1つ)と、それを信頼するIAMロール(図のDeployRole)の2つです。信頼ポリシーはGitHub公式ドキュメントが示す標準的な形で、OIDCトークンの sub クレーム(誰が発行されたトークンか、 repo:<owner>/<repo>:ref:refs/heads/main という形式で表現される)を対象リポジトリのmainブランチだけに絞り込んでいます。
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Federated": "arn:aws:iam::<AWSアカウントID>:oidc-provider/token.actions.githubusercontent.com"
},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {
"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"
},
"StringLike": {
"token.actions.githubusercontent.com:sub": "repo:<owner>/<repo>:ref:refs/heads/main"
}
}
}
]
}
GitHub Actions側のワークフローは permissions: { id-token: write } を宣言した上で、 aws-actions/configure-aws-credentials アクションに role-to-assume としてこのロールのARNを渡すだけで、長期キーを一切扱わずに済みます。実際のワークフローは次のような形になります。
name: deploy
on:
push:
branches: [main]
permissions:
contents: read
id-token: write
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: ${{ secrets.DEPLOY_ROLE_ARN }}
aws-region: ap-northeast-1
- uses: actions/setup-node@v4
with: { node-version: 22 }
- run: npm ci
- run: npx cdk deploy --require-approval never
role-to-assume にはARNをそのまま書かずGitHub Secretsに入れています。ARN自体はDeployRoleの信頼ポリシーで参照元リポジトリを絞り込んでいるため機密情報ではありませんが、Secretsにしておけばワークフロー定義だけを見てもARNが分からず、コピーして別リポジトリで誤用されるリスクも減らせます。 actions/checkout や aws-actions/configure-aws-credentials のようなサードパーティActionはタグ(@v4)ではなくコミットSHAで固定するとより安全です(タグは後から向き先を変えられるため)。
ここまでは「誰がDeployRoleを引き受けられるか」という信頼ポリシーの話でした。TL;DRで触れた「GitHub Actions側のIAMロールを絞る」というのは、DeployRole自身の権限ポリシー、つまり「DeployRoleに何が許されているか」を絞ることを指しています。DeployRole自体はS3やIAMなどのAWS APIを直接呼べず、 cdk bootstrap が作る4つの標準ロール( deploy-role 、 file-publishing-role 、 image-publishing-role 、 lookup-role )への sts:AssumeRole だけを許可した、次のようなポリシーです。
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "AssumeCdkBootstrapRoles",
"Effect": "Allow",
"Action": "sts:AssumeRole",
"Resource": [
"arn:aws:iam::<AWSアカウントID>:role/cdk-hnb659fds-deploy-role-*",
"arn:aws:iam::<AWSアカウントID>:role/cdk-hnb659fds-file-publishing-role-*",
"arn:aws:iam::<AWSアカウントID>:role/cdk-hnb659fds-image-publishing-role-*",
"arn:aws:iam::<AWSアカウントID>:role/cdk-hnb659fds-lookup-role-*"
]
}
]
}
つまりDeployRoleは、これら4つのCDK標準ロールへの入り口でしかありません。ただしこのうち deploy-role はCloudFormationの CreateChangeSet / ExecuteChangeSet を呼べるロールで、ここから先の実際のリソース操作はCloudFormationサービスが引き受ける別のロールに委ねられます。DeployRole側をどれだけ絞っても、その先で実際に手を動かすロールが強すぎれば意味がないというのが、次に説明するCloudFormationExecutionRoleの話です。
OIDCの認証フロー
上の図の1〜5をもう少し細かく見ると、GitHubとAWSが何かのシークレットを事前共有しているわけではなく、GitHubが発行した署名付きトークンをAWSが検証するという一方向の信頼関係になっています。
- ワークフロー実行時、GitHub Actionsのランナーが
id-token: write権限を使ってGitHub自身のOIDCエンドポイントに一時的なIDトークン(JWT、GitHubの秘密鍵で署名済み)を要求する。トークンには対象リポジトリ・ブランチを表すsubクレーム、想定される受け取り先を表すaudクレーム、数分程度の有効期限が含まれる -
aws-actions/configure-aws-credentialsがこのトークンを持ってAWS STSのAssumeRoleWithWebIdentityを呼び出す - AWS STSは、IAM OIDCプロバイダとして事前登録済みのGitHubの公開鍵(JWKS、GitHubが公開しているJSON形式の鍵セット)でトークンの署名を検証する
- 署名が正しいことを確認した上でDeployRoleの信頼ポリシーの
Conditionとトークン内のsub/audを照合し、一致すれば数十分程度で失効する一時的なAWS認証情報を発行する
GitHub側の秘密鍵で署名されたトークンをAWSが検証するだけの仕組みなので、AWSとGitHubの間に長期の共有シークレットは存在しません。署名できるのはGitHub自身だけなので、あるリポジトリのワークフローが別リポジトリになりすましたトークンを作ることはできず、 sub クレームで「どのリポジトリのどのブランチか」まで表現されている分だけ、IAM側の条件も細かく絞り込めます。
cdk bootstrap
上の図の6〜8に登場するCloudFormationExecutionRoleは、このコマンドが作ります。 cdk bootstrap は、CDKで初めてあるAWSアカウント・リージョンにデプロイする前に一度だけ実行しておく準備コマンドです。CloudFormationは自分自身ではテンプレートに書いた通りのAWSリソースしか作れず、Lambdaのコードzipやコンテナイメージのような「デプロイ時に必要なファイル資産」を置いておく場所を持ちません。そこで cdk bootstrap が、アセット置き場になるS3バケットとECRリポジトリ、デプロイに使う複数のIAMロール(アセットのアップロードに使うロール、CloudFormationの実行を担うCloudFormationExecutionRoleなど)、そしてブートストラップ自体のバージョンを記録するSSM Parameter Storeのパラメータを、 CDKToolkit という1つのCloudFormationスタックとしてまとめて作成します。
これらのリソース名には既定で hnb659fds という固定のqualifier(名前の衝突を避けるための識別用文字列)が入っており、 --qualifier オプションを使えば同じアカウント内に複数系統のbootstrapを共存させることもできます。今回扱っている --cloudformation-execution-policies や --custom-permissions-boundary も、この cdk bootstrap コマンドが受け取るオプションです。
CloudFormationExecutionRole
DeployRole自体を絞るだけでは、実際のリソース操作までは制御できません。GitHub ActionsのワークフローがCDKで cdk deploy を呼ぶと、実際にCloudFormationのCreateChangeSet/ExecuteChangeSetを実行するのはDeployRoleではなく、CloudFormationサービス自身が引き受ける CloudFormationExecutionRole (通称cfn-exec-role)です(上の図の7〜8)。DeployRoleからcfn-exec-roleへは AssumeRole のチェーンではなくCloudFormationサービスプリンシパルによるAssumeRoleを挟むため、DeployRole側でどれだけIAMポリシーを絞り込んでも、cfn-exec-role自体の権限がそのままリソース操作の上限になります。
cdk bootstrap の既定設定では、このcfn-exec-roleに AdministratorAccess 管理ポリシーがそのままアタッチされます。既定設定のままだと、どこか1つのリポジトリのGitHub Actionsが侵害された場合に、 cdk deploy 経由でアカウント内のあらゆるAWSリソースに到達できてしまいます。
カスタマー管理ポリシーへの差し替え
ここからは、上の図の8(cfn-exec-roleによるリソース作成)の中身をどう絞ったかという話です。 cdk bootstrap には --cloudformation-execution-policies というオプションがあり、既定の AdministratorAccess をカスタマー管理ポリシーのARNに差し替えられます。CDKコードが実際に作成しているAWSリソースの種類(ECR、S3、IAM、コンテナ実行系のサービス、CDNなど)を棚卸しして、サービスごとに必要なActionだけを許可するポリシーに置き換えました。実際のポリシーはサービスごとにもっと細かくActionを列挙していますが、形としては次のようなイメージです。
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "ScopedByService",
"Effect": "Allow",
"Action": [
"ecr:CreateRepository",
"ecr:DeleteRepository",
"s3:CreateBucket",
"s3:DeleteBucket",
"iam:CreateRole",
"iam:PutRolePolicy"
],
"Resource": "*"
},
{
"Sid": "PassRoleScopedToService",
"Effect": "Allow",
"Action": "iam:PassRole",
"Resource": "*",
"Condition": {
"StringEquals": {
"iam:PassedToService": ["lambda.amazonaws.com"]
}
}
}
]
}
Resourceをリポジトリ(アプリ)ごとに絞り込む案も検討しましたが、cfn-exec-roleはCloudFormationサービスがスタック名を問わず共通で引き受けるロールで、呼び出し元のセッションタグを条件に使うABAC(属性ベースアクセス制御。呼び出し元の属性をIAMポリシーの条件に使う仕組み)が原理的に届きません。そのため今回は「必要なサービス・操作の種類だけを許可し、 AdministratorAccess 相当を外す」ことを主眼にし、リポジトリ間の完全な分離は今後の課題として残しています。
permissions boundary
cfn-exec-roleは、CloudFormationがIAMロール・ポリシーを作成できるように iam:CreateRole や iam:PutRolePolicy のようなIAM書き込み系のActionを持っています。IAMはAWSの権限そのものを管理するサービスなので、これらのActionを持つロールは、理屈の上では自分自身や自分が作った別のロールに、元々持っていたより強い権限を後から追加できてしまいます。たとえば新しいロールを iam:CreateRole で作り、そこへ iam:PutRolePolicy で管理者相当のインラインポリシーを書き込み、 iam:PassRole でそのロールをLambda等のサービスに渡して実行させれば、cfn-exec-role自身が最初に持っていた範囲を超えた操作が可能になります。これが自己昇格で、S3やECRのような他のサービスのActionをいくら絞っても防げません(それらのサービスは他人の権限を書き換える力を持たないため)。
--custom-permissions-boundary でcfn-exec-role自体にpermissions boundary(IAMロールが持てる権限の実質的な上限を、通常のポリシーとは別にもう一枚重ねて制限する仕組み)を付与すると、この経路を塞げます。boundaryが付与されたロールは、自分の通常のポリシーがどれだけ強い許可を持っていても、boundaryの範囲を超えて何かを実行することはできません。狙いは、上のカスタマー管理ポリシーで許可した範囲を超える自己昇格(boundary自体の付け替え、他アカウントへの権限借用など)を明示的にDenyすることです。boundary側は次のような形を想定しています。
{
"Sid": "DenySelfEscalation",
"Effect": "Deny",
"Action": [
"iam:DeleteRolePermissionsBoundary",
"iam:PutRolePermissionsBoundary",
"organizations:*"
],
"Resource": "*"
}
現在はCDKの専用スタックが実行ポリシーとboundary用ポリシーの2つを作り、 cdk bootstrap の該当オプションで手動適用しています。cfn-exec-roleには最新の実行ポリシーとboundaryの双方が反映されています。
残っている制約
この対応はあくまで「共有されたcfn-exec-roleから AdministratorAccess 相当を外す」ことが目的で、リポジトリ間の完全な分離までは達成できていません。AWS側が採番するARNを持つリソースタイプはリポジトリ名による絞り込みができないため、一部の操作はResourceを広めに許可せざるを得ず、あるリポジトリのデプロイが別リポジトリのリソースに干渉できる余地も残ったままです。本質的な解決には cdk bootstrap --qualifier によるリポジトリごとのbootstrap分離が必要です。qualifierは、bootstrapが作るS3バケットやIAMロールなどの物理名に埋め込まれる識別用の文字列(既定値は hnb659fds )で、これを変えて cdk bootstrap --qualifier <別の値> を実行すると、同じアカウント・リージョン内にcfn-exec-roleを含む別セットの一式を並存させられます。リポジトリごとに異なるqualifierを割り当て、CDKアプリ側でそのqualifierを使うよう指定してデプロイすれば、あるリポジトリは自分専用のcfn-exec-roleしか使わなくなり、他のリポジトリのリソースに干渉する経路自体がなくなります。ただしqualifierを分けるだけでは不十分で、それぞれのqualifierのcfn-exec-roleにも今回と同じようにリポジトリ専用の --cloudformation-execution-policies を用意しない限り、同じ強さのロールが複数に増えるだけで終わってしまいます(AWS公式ドキュメントもこの点を明記しています)。これは複数のアプリが本番相当で実際に稼働し始めてから改めて着手する予定です。
もう1つ、cfn-exec-roleとは別の経路も残っています。S3とCDNで静的サイトをホスティングしている場合、バケットやディストリビューションといったインフラ自体は最初に1回 cdk deploy で作ってしまえば、以後の日々のデプロイはビルド済みの静的ファイルをS3に同期し、CDNのキャッシュを無効化するだけで済みます。これはCloudFormationを一切経由しない直接のAWS CLI操作なので、cfn-exec-roleの権限とは無関係に、DeployRole自身が s3:PutObject や cloudfront:CreateInvalidation のような権限を持っている必要があります。現状のDeployRoleは前述の AssumeCdkBootstrapRoles しか持っておらず、この経路用の権限はまだ追加していません。追加する場合も対象バケット・対象ディストリビューションをリポジトリごとに絞り込む必要があり、今のところ該当リポジトリのデプロイは手動運用でしのいでいます。