インフラ

Workload Identityを利用した、GitHub ActionsでのGCPへのキーレスデプロイ

サービスアカウントのJSONキーをGitHub Secretsに貼り付けるのはやめましょう。Workload Identity Federationを使用すると、Actionsは有効期間の短いOIDCトークンでGCPに認証できます。

この記事は英語の原文をAIモデルが翻訳したものです。表現が原文と異なる場合があります。 英語の原文を読む

GitHub ActionsからGoogle Cloudにデプロイする一般的な方法は、サービスアカウントを作成し、そのJSONキーをダウンロードして、GitHub Secretに貼り付けるというものです。この方法は最初の試行でうまくいくので、まさにそれが普及する理由です。また、この方法は、有効期限がなく、簡単な監査証跡もない長期的な認証情報を、完全には制御できないシステムに渡すことになります。Workload Identity Federationは、キーを完全に不要にします。GitHub Actionsは実行のたびに署名付きOIDCトークンをすでに発行しており、GCPに、特定のリポジトリとブランチについてそのトークンを信頼するよう教えることができます。キーは生成されないため、キーが漏洩したり、ローテーションしたり、シークレットストアで忘れ去られたままになったりすることはありません。この記事では、完全なセットアップ方法、セキュリティを左右する1つの属性条件、そして、どのような場合に依然として単純なキーを使う方が手っ取り早いかを紹介します。

シークレット内のサービスアカウントキーの問題点

サービスアカウントキーはベアラー資格情報です。この JSON ファイルを保持している人は誰でも、誰かがキーを削除するまでそのサービスアカウントとして振る舞うことができ、デフォルトでは有効期限が切れることはありません。その単一の事実が、ほとんどの問題の原因となっています。

侵害された依存関係、不適切なスコープのログ、シークレットへのアクセス権を持つフォーク、またはラップトップのバックアップなどを通じてキーが漏洩した場合、攻撃者はそのキーが有効である限り、あなたのデプロイIDを保持することになります。多くの場合、その呼び出しはあなたの CI からのものと全く同じに見えるため、漏洩が発生したことに気づかないでしょう。ローテーションは、新しいキーを生成し、シークレットを更新し、何も壊れていないことを確認し、古いキーを削除するという、誰もやりたがらない手作業です。チームはそれをスキップするため、キーが蓄積されていきます。監査も不十分です。キーはどこから使用されたかを記録しないため、あなたの CI だけがそれを使用して呼び出しを行ったことを簡単に証明することはできません。

これらのいずれも、キーフォーマットの欠陥ではありません。静的な資格情報は、単に静的な資格情報としてのリスクを伴うだけです。そして、爆風範囲が本番環境のデプロイパスであるため、CI はそれを保管するのに適さない場所です。

Workload Identity Federation の仕組み

Workload Identity Federation は、外部 ID プロバイダと GCP との間のフェデレーションによる信頼関係です。GCP が発行したキーを GitHub に持ち込む代わりに、GitHub が GCP のすでに信頼しているトークンを発行します。

GitHub Actions の各実行は、GitHub の ID プロバイダに OIDC トークンをリクエストできます。そのトークンは GitHub によって署名された有効期間の短い JWT であり、そのクレームには、どのリポジトリ、どのブランチまたはタグ、どのワークフロー、どの環境かといった、実行に関する情報が記述されています。GCP は、GitHub の発行者を信頼済みとして指定するプロバイダを持つ Workload Identity プールをホストします。ワークフローが GitHub トークンを提示すると、GCP は署名を検証し、ユーザーが設定した条件に対してクレームをチェックし、それに合格した場合、そのトークンを、ユーザーが選択したサービスアカウントを借用する有効期間の短い Google アクセストークンと交換します。

この連鎖は、分かりやすく説明する価値があります。GitHub は、実行のアイデンティティを証明するトークンに署名します。GCP は、GitHub の署名とユーザーが設定した条件を信頼します。サービスアカウントが、実際のデプロイ権限を付与します。シークレットが境界を越えることはなく、返される Google トークンは数分で失効します。

gcloud を使用したプールとプロバイダの設定

一度だけ、プール、その中の OIDC プロバイダ、サービス アカウントでの権限バインディングの 3 つを作成します。まず、プロジェクト変数を設定します。

PROJECT_ID="my-project"
PROJECT_NUMBER="$(gcloud projects describe "$PROJECT_ID" --format='value(projectNumber)')"
REPO="my-org/my-repo"
DEPLOY_SA="deploy@${PROJECT_ID}.iam.gserviceaccount.com"

プールを作成し、次に GitHub の発行者を信頼するプロバイダーを作成します。属性マッピングは、GitHub トークンからクレームを、後で参照できる属性にコピーします。

gcloud iam workload-identity-pools create github-pool \
  --project="$PROJECT_ID" \
  --location="global" \
  --display-name="GitHub Actions"

gcloud iam workload-identity-pools providers create-oidc github-provider \
  --project="$PROJECT_ID" \
  --location="global" \
  --workload-identity-pool="github-pool" \
  --display-name="GitHub OIDC" \
  --issuer-uri="https://token.actions.githubusercontent.com" \
  --attribute-mapping="google.subject=assertion.sub,attribute.repository=assertion.repository,attribute.ref=assertion.ref" \
  --attribute-condition="assertion.repository == '${REPO}' && assertion.ref == 'refs/heads/main'"

attribute-conditionはセキュリティ境界であり、次のセクションはすべてそれに関するものです。現時点では、それが信頼を1つのリポジトリのmainブランチに固定することに注意してください。他のリポジトリやブランチからのトークンは、サービスアカウントに到達する前に交換に失敗します。

信頼を1つのリポジトリとブランチに限定する

セットアップ全体で最も重要な一行は、属性条件です。これを緩くしすぎると、キーレスデプロイを構築したことにはならず、世界中のどのリポジトリでもなりすましできるサービスアカウントを構築したことになります。

この失敗モードは、緩いバージョンでもあなたにとっては機能してしまうため、巧妙です。もし attribute.repository をマップしても特定の値を要求しない場合、見知らぬ人のものを含むどのGitHubリポジトリでも、有効なGitHub署名付きトークンを提示してパスできてしまいます。署名は本物です。あなたがスキップしたのは、それが誰の署名であるかを確認することです。GitHubはプラットフォーム上のすべてのリポジトリに対して正しく署名されたトークンを発行するため、IDプロバイダーの署名だけでは所有権について何も証明しません。あなたの条件が、信頼をあなた自身に結びつけるものです。

常に正確なリポジトリを要求してください。1つのブランチのみがデプロイを行うべき場合は、ブランチを追加してください。

# Good: exactly your repo, main branch only.
--attribute-condition="assertion.repository == 'my-org/my-repo' && assertion.ref == 'refs/heads/main'"

# Dangerous: any repo whose name happens to match a prefix.
--attribute-condition="assertion.repository.startsWith('my-org/')"

# Broken: no ownership check at all, every GitHub repo passes.
--attribute-condition="assertion.repository != ''"

サービスアカウントのバインディングは、同じスコープと一致する必要があります。プールに workloadIdentityUser ロールを付与しますが、権限借用の権限がプール全体に及ばないように、メンバーを正確なリポジトリ属性に制限します。

gcloud iam service-accounts add-iam-policy-binding "$DEPLOY_SA" \
  --project="$PROJECT_ID" \
  --role="roles/iam.workloadIdentityUser" \
  --member="principalSet://iam.googleapis.com/projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/github-pool/attribute.repository/${REPO}"

これで、同じドアが 2 つのレイヤで保護されることになります。プロバイダ条件は交換時に外部のトークンを拒否し、メンバー バインディングはプール内であっても、どの ID がこのサービス アカウントを借用できるかを制限します。サービス アカウント自体も最小限に保ちます。Cloud Run へのデプロイのみが必要な場合は、使用する Cloud Run と Artifact Registry のロールを付与し、それより広範なものは付与しません。サービス アカウントに最小権限を付与することで、CI ロジックがだまされて予期しないものを実行した場合でも、被害を抑えることができます。

ワークフロー側

GitHub側では、1つのパーミッションと1つの認証ステップが必要です。id-token: writeパーミッションは、ジョブがそもそもOIDCトークンをリクエストできるようにするものです。これがないと、認証ステップは分かりにくいエラーで失敗し、これは人々が最も忘れがちなことです。

name: deploy
on:
  push:
    branches: [main]

permissions:
  contents: read
  id-token: write

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - id: auth
        uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: ${{ vars.GCP_WIF_PROVIDER }}
          service_account: ${{ vars.GCP_DEPLOY_SA }}

      - uses: google-github-actions/setup-gcloud@v2

      - run: |
          gcloud run deploy my-service \
            --image "$IMAGE" \
            --region asia-northeast3

2つの入力が secrets ではなく vars から来ていることに注意してください。それが重要な点です。どちらの値も機密情報ではありません。プロバイダー名とサービスアカウントのメールアドレスは、認証情報ではなく識別子であるため、ワークフローをレビューする誰もが読み取れるプレーンなリポジトリ変数として存在します。

CLIから一度設定します。

gh variable set GCP_WIF_PROVIDER \
  --body "projects/${PROJECT_NUMBER}/locations/global/workloadIdentityPools/github-pool/providers/github-provider"

gh variable set GCP_DEPLOY_SA --body "$DEPLOY_SA"

auth アクションは、GitHub OIDC トークンをリクエストし、それをプロバイダーに送信し、短命の Google アクセストークンを受信して、setup-gcloud と SDK が自動的に取得する認証情報を書き込みます。auth の後のすべてのステップは、リポジトリのどこにもキーを置くことなく認証されます。

セキュリティのトレードオフを並べて比較

利点は、単に整理整頓されることだけではありません。これにより、攻撃者が漏洩から何を得るか、そしてあなたが何を維持管理しなければならないかが変わります。

プロパティ サービスアカウントキー Workload Identity連携
認証情報の有効期間 手動で削除されるまで、有効期限なし 数分間、実行ごとに発行
漏洩したシークレットが与えるもの SAへの完全なアクセス、無期限 何もない、保存された認証情報はない
信頼のスコープ ファイルを持つ誰でも 指定した1つのリポジトリとブランチ
ローテーション 手動、忘れやすい なし、トークンは一時的
GitHubに保存されるシークレット JSONキー なし、公開識別子のみ
監査 弱い、キーは発行元を記録しない トークンのクレームがリポジトリ、ref、ワークフローを記録

短命なトークンがその核心です。数分で失効するGitHubトークンと、その直後に失効するGoogleトークンは、実行が終了するとほとんど価値がなくなります。盗むべき永続的なアーティファクトはありません。リポジトリとブランチのスコープ設定は、組織内の他の場所での漏洩や悪意のあるフォークがこのIDを借用できないことを意味します。なぜなら、それらのトークンは異なる repository クレームを持ち、条件に失敗するからです。そして、キーを保存しないため、ローテーションするものがなく、運用から定期的なタスク全体が静かに取り除かれます。

正直なところ、コストはセットアップの複雑さです。キーは、作成とダウンロードの2つのコマンドです。連携は、プール、慎重に記述された条件を持つプロバイダー、メンバーバインディング、そして2つのワークフロー入力から構成されます。特に条件は、緩い方向に微妙に間違えやすく、緩い条件は機能しているように見えます。それを正確に記述し、異なるブランチからのトークンが実際に拒否されることをテストするための時間を確保してください。しかし、その最初のセットアップの後は、管理すべきキーがないため、メンテナンスはほぼゼロになります。

サービスアカウントキーが依然として安易な選択肢となる場合

本番環境にデプロイするCIにとって、フェデレーションは適切なデフォルトですが、それにはコストがかかります。また、いくつかのケースでは純粋にキーが有利となります。

呼び出し元がOIDC対応のIDプロバイダでない場合、フェデレーションには信頼できるものがありません。任意のボックス上のcronジョブ、開発者のラップトップ上のスクリプト、あるいはトークン発行者のないレガシーシステムは、交換に必要な署名付きアサーションを提示できません。それらをワークロードIDプールにブリッジすることは、削減される手間よりも多くの手間がかかることが多く、スコープが限定され、ローテーション期間が短いキーが現実的な答えとなり得ます。

実際のデータがなく、数日で破棄されるような使い捨てのサンドボックスプロジェクトも、もう一つのケースです。プロジェクト全体が来週削除されるのであれば、漏洩したキーの爆発半径は小さく、セットアップの手間をかけても得られるものはほとんどありません。エミュレータに対するローカル開発では、クラウドの認証情報は一切必要ないため、どちらのアプローチも適用されません。

境界線は、おおよそ次のようになります。ワークロードが、GitHub Actions、GitLab CI、およびほとんどの最新プラットフォームのようにOIDCトークンを発行する場所で実行される場合は、フェデレーションを優先し、キーを削除してください。信頼できるトークン発行者のない場所で実行される場合、またはプロジェクトが使い捨てである場合は、スコープが厳密に限定され、ローテーションスケジュールが短いキーが妥当な選択です。しかし、GitHub ActionsでGCPにデプロイするという一般的なケースでは、キーを使えば今日数分間の時間を節約できますが、それが存在する限り永続的な負債となります。フェデレーションは、一度設定に長めの午後を費やすことになりますが、その後はあなたの邪魔をしません。