はじめに

CI/CD パイプラインにおいて、Terraform を使用してインフラを構築・変更する場合、GitHub Actions などの実行環境には更新管理を実施するための強力な権限が必要になります。しかし、セキュリティの観点からは、常に強力な権限を持たせておくことは望ましくありません。
とはいえ、システム毎に管理対象のリソースを精査し、権限を細かく定義した専用ロールを作るのは、運用負荷が高すぎて現実的ではありません。

ここで参考になるのが、CyberArk のリスクモデルです。このリスクモデルでは、「特権レベル」×「影響範囲」×「侵害の容易性」の3要素で特権のリスクを評価します。
運用効率を考えると、「特権レベル」「影響範囲」を絞るのは難しいため、いかに「侵害の容易性」を下げるかが鍵となります。

Google Cloud では、Workload Identity (OIDC 認証) を用いて永続的なクレデンシャルを排除するのが一般的ですが、本記事ではさらに一歩踏み込み、Google Cloud Privileged Access Manager (PAM) を活用し、GitHub Actions の実行時のみ一時的に権限を昇格(JIT:Just-In-Time アクセス)するワークフローの実装例をご紹介します。

構成の概要

今回実装した処理の流れは以下の通りです。

  1. OIDC 認証: Workload Identity を使用して Google Cloud へ認証
  2. JIT 昇格リクエスト: PAM を使用して、事前に定義した Entitlement(権利)への昇格をリクエスト
  3. アクティブ化待機: 権限が有効(ACTIVE)になるまでポーリング
  4. Terraform 実行: 昇格した権限で init および apply を実行
  5. 権限の剥奪: 実行完了後(成功・失敗問わず)、即座に権限を無効化

今回は、PAM 側では昇格リクエストを「自動承認」に設定しました。ワークフローからの昇格リクエストは即座に承認され、権限が付与されます。誰でもデプロイできてしまう状態を防ぐため、GitHub 側のブランチ保護、Pull Request 承認と組み合わせて運用する想定をしています。

GitHub 上でレビューして Approve し、マージされたものだけが PAM 経由で自動昇格してデプロイされるというガードレールを設けることで、安全性とスピードの両立を試みました。

事前準備

本ワークフローを動作させるには、Google Cloud 側で以下の設定が完了している必要があります。

1. Workload Identity Federation の設定

GitHub Actions から Google Cloud へ鍵を使わずに認証するための設定です。

  • Workload Identity プールおよびプロバイダーの作成
  • サービスアカウントの作成と Workload Identity User ロールの付与

2. PAM Entitlementの作成

昇格の対象となる権限や期間を定義した「Entitlement」を事前に作成しておきます。

  • 付与するロール: roles/writerroles/admin など、デプロイに必要な権限
  • 承認設定: 自動承認(Auto-approval)を有効に設定

3. サービスアカウントへの権限付与

GitHub Actions が使用するサービスアカウントには、PAM を操作するための以下の権限を付与しておきます。

  • privilegedaccessmanager.grants.get
  • privilegedaccessmanager.grants.revoke
  • privilegedaccessmanager.operations.get

ワークフローの実装例

name: Terraform Apply with JIT Access

on:
  push:
    branches:
      - main
    paths:
      - 'terraform/**'

permissions:
  id-token: write
  contents: read

jobs:
  terraform-apply:
    runs-on: ubuntu-latest
    env:
      TF_VERSION: "1.14.7"
      TERRAFORM_DIR: "terraform"
      # PAM entitlement の完全修飾リソース名
      PAM_ENTITLEMENT: "projects/YOUR_PROJECT_ID/locations/global/entitlements/YOUR_ENTITLEMENT_ID"

    steps:
      - name: Checkout
        uses: actions/checkout@v6

      - name: Authenticate to Google Cloud
        uses: google-github-actions/auth@v3
        with:
          workload_identity_provider: ${{ secrets.GCP_WORKLOAD_IDENTITY_PROVIDER }}
          service_account: ${{ secrets.GCP_SERVICE_ACCOUNT }}

      - name: Request JIT Access via PAM
        id: request-pam
        env:
          GH_ACTOR: ${{ github.actor }}
          GH_REPO: ${{ github.repository }}
          GH_REF_VALUE: ${{ github.ref }}
          GH_SHA_VALUE: ${{ github.sha }}
          GH_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
        run: |
          # 監査ログで追跡できるよう、GitHub Actions の実行情報を埋め込む
          JUSTIFICATION="GitHub Actions deploy: actor=${GH_ACTOR}, repo=${GH_REPO}, ref=${GH_REF_VALUE}, sha=${GH_SHA_VALUE}, run=${GH_RUN_URL}"

          GRANT_OUTPUT=$(gcloud pam grants create \
            --entitlement="${PAM_ENTITLEMENT}" \
            --requested-duration=3600s \
            --justification="${JUSTIFICATION}" \
            --format=json)

          GRANT_NAME=$(echo "${GRANT_OUTPUT}" | jq -r '.name')
          echo "grant_name=${GRANT_NAME}" >> "${GITHUB_ENV}"
          echo "Grant created: ${GRANT_NAME}"

      - name: Wait for JIT Access Activation
        run: |
          echo "Waiting for grant to be activated..."
          for i in $(seq 1 30); do
            STATE=$(gcloud pam grants describe "${grant_name}" --format="value(state)")
            echo "Attempt ${i}: Grant state is ${STATE}"
            if [ "${STATE}" = "ACTIVE" ]; then
              echo "Grant is now active."
              exit 0
            elif [ "${STATE}" = "DENIED" ] || [ "${STATE}" = "EXPIRED" ] || [ "${STATE}" = "ACTIVATION_FAILED" ]; then
              echo "Grant failed with state: ${STATE}"
              exit 1
            fi
            sleep 10
          done
          echo "Timed out waiting for grant activation."
          exit 1

      - name: Setup Terraform
        uses: hashicorp/setup-terraform@v3
        with:
          terraform_version: ${{ env.TF_VERSION }}

      - name: Terraform Init & Apply
        run: |
          terraform -chdir=${{ env.TERRAFORM_DIR }} init
          terraform -chdir=${{ env.TERRAFORM_DIR }} apply -auto-approve

      - name: Revoke JIT Access
        if: always() && env.grant_name != ''
        continue-on-error: true
        env:
          REVOKE_REASON: "Terraform apply completed (run: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }})"
        run: |
          echo "Revoking grant: ${grant_name}"
          gcloud pam grants revoke "${grant_name}" --reason="${REVOKE_REASON}"

実装のポイント

1. トレーサビリティの確保

PAM で権限昇格をリクエストする際、--justification 引数に GitHub Actions の実行ユーザーやリポジトリ名を含めています。これにより、Google Cloud 側のログを確認した際に「誰の操作で、どの Actions ワークフローによって、なぜ権限が昇格されたのか」を明確に追跡することが可能になります。

2. アクティブ化の待機処理

昇格リクエストは即座に完了しますが、実際に IAM 権限が反映されるまでにはわずかなタイムラグが生じることがあります。そのため、ワークフロー内で gcloud pam grants describe をループ実行し、状態が ACTIVE になるまで待機するステップを設けています。これにより、権限不足による Terraform の実行失敗を防ぐことができます。
※ IAM 権限が実際に使用可能になるまでのタイムラグにより、この待機処理をもってしても失敗する可能性はゼロではありません。

3. always() による確実な権限剥奪

セキュリティ上のリスクを最小限に抑えるため、Terraform の実行が成功した場合はもちろん、エラーで失敗した場合でも必ず権限を剥奪(Revoke)するように設計しています。GitHub Actions の if: always() 条件を使用することで、後処理のステップが確実に実行されるようにしています。

なお、権限を剥奪しなくても PAM により一定期間後に自動で権限削除されるのですが、一度有効化された Grant が有効なままの状態で再度権限をリクエストするとエラーになります。既に有効な権限を再利用してしまうと、Justification が前回のものしか残らず追跡困難になるため、都度 revoke して毎回新しくリクエストするフローとしました。

おわりに

Google Cloud PAM を利用することで、CI/CD 用サービスアカウントに永続的な管理者権限を持たせるというリスクを回避し、最小権限の原則に基づいた安全な運用が可能になります。

セットアップには IAM 権限の事前準備などいくつか手順が必要ですが、一度構築してしまえば強力なセキュリティレベルを維持できます。