CTS-KB
インフラ

GitLab → GCP 認証方式の 3 世代と CTS の選択 — Direct Resource Access への移行判断

⏱ 約 10 分で読めます
#WIF #Workload Identity Federation #OIDC #GitLab CI #GCP #CI/CD #キーレス認証 #技術選定 #インパーソネーション

🔑 はじめに:なぜ「認証方式の歴史」を整理するのか

GitLab CI から GCP リソースを操作する認証方式は、ここ数年で 3 世代 にわたって進化しました。それぞれの世代は単に「古い・新しい」の関係ではなく、 解決した課題と新たに引き受けた制約のトレードオフ を持ちます。「最新だから正しい」と一律に飛びつくと、運用の現実に合わずに痛い目を見るのが、認証アーキテクチャの常です。

本記事では、

  • 3 世代の 進化の流れと差分
  • CTS が infra 集約リポジトリクライアントリポジトリ で異なる方式を採用した判断
  • 「インパーソネーションあり / なし」という社内用語の整理

を、実際の external_account credential JSON と principalSet:// バインディング例を交えて解説します。

前提知識WIF でキーレス認証 で WIF の基本構造と用語を扱っています。本記事は「その上に乗る運用設計」の話です。

🕰️ GitLab → GCP 認証の 3 世代

第 1 世代:Service Account Key(JSON キー)

最も古典的な方式。GCP のサービスアカウントから JSON キーを発行し、GitLab Project Variables に貼り付けて使います。

# 第 1 世代:JSON キーをそのまま使う
deploy:
  variables:
    GOOGLE_APPLICATION_CREDENTIALS: /tmp/sa-key.json
  before_script:
    - echo "$GCP_SA_KEY" > /tmp/sa-key.json   # Variables から復元
  script:
    - gcloud auth activate-service-account --key-file=/tmp/sa-key.json
    - gcloud run deploy ...
項目状況
キーの有効期限無期限(明示削除まで生き続ける)
ローテーション手動。各環境への再配布が必要
漏洩時の被害キー無効化までの全期間、攻撃者が任意の API を叩ける
ログ流出リスクset -x 等で平文出力する事故が定番

結論: 2026 年時点では新規採用は推奨されません。既存のキー運用は全て次世代に置き換えるのが望ましい状態です。

第 2 世代:WIF + Service Account Impersonation

WIF(Workload Identity Federation)が GA した 2021 年以降、デファクトとなった方式です。GitLab CI が発行する OIDC ID トークンを GCP STS が検証し、 指定したサービスアカウントを impersonate して短命 access token に交換します。

{
  "type": "external_account",
  "audience": "//iam.googleapis.com/projects/<num>/locations/global/workloadIdentityPools/<pool>/providers/<provider>",
  "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
  "token_url": "https://sts.googleapis.com/v1/token",
  "service_account_impersonation_url": "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/<target-sa>:generateAccessToken",
  "credential_source": { "file": "/tmp/oidc_token" }
}

特徴:

  • 鍵は不要。鍵を作らないので漏洩リスクがゼロ
  • IAM ロールは サービスアカウント側に付与 し、CI 側は SA を「借りる」
  • WIF Pool と SA の間に roles/iam.workloadIdentityUser のバインドが必要
項目状況
鍵の存在なし(短命 access token のみ)
権限の主体サービスアカウント(CI は SA を impersonate)
必要な IAMSA に通常ロール + roles/iam.workloadIdentityUser を WIF principal に付与
主な弱点SA が「経路上の中間オブジェクト」として 1 つ余計に存在する

第 3 世代:WIF + Direct Resource Access

最新の推奨パスです。 サービスアカウントを経由せず、連携 ID(principal:// / principalSet://)自身に直接 IAM ロールを付与 します。

# 第 3 世代:Direct Resource Access の IAM バインディング
resource "google_project_iam_member" "ci_run_developer" {
  project = var.project_id
  role    = "roles/run.developer"
  member  = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.pool.name}/attribute.project_path/my-org/clients/client-a/app-repo"
}
{
  "type": "external_account",
  "audience": "//iam.googleapis.com/projects/<num>/locations/global/workloadIdentityPools/<pool>/providers/<provider>",
  "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
  "token_url": "https://sts.googleapis.com/v1/token",
  "credential_source": { "file": "/tmp/oidc_token" }
}

第 2 世代との JSON の差は 1 行service_account_impersonation_url を持たない)ですが、IAM 設計上の意味は大きく変わります。

項目状況
中間 SA不要(連携 ID 自身が権限主体)
IAM 設計1 段階(SA を経由する 2 段階構造が消える)
監査ログprincipal://... が直接アクター名として残る
適用できない場面連携プリンシパルを直接受け取らない API(一部の Cloud Storage signed URL 等)

「経路が短いほど安全で観測しやすい」という IAM の基本原則に最も沿った方式です。Google も公式ドキュメント上で direct resource access を デフォルト推奨 に格上げしています。

📊 3 世代の比較

観点第 1 世代
SA Key
第 2 世代
WIF + SA Impersonation
第 3 世代
WIF + Direct Resource Access
鍵の存在あり(無期限)なしなし
キー漏洩リスクなしなし
ローテーション手動不要不要
必要な中間 SAありありなし
IAM の段数2 段(CI→Key→SA)2 段(CI→WIF→SA)1 段(CI→WIF→Resource)
監査ログのアクターSASA(CI から impersonate)連携 ID 自身
複数 SA への切替鍵の切替で対応impersonation URL を差し替えバインド対象を増やす
API 互換性全 API全 API一部 API は不可
採用推奨度×

🧭 CTS の選択:1 つではない最適解

CTS のインフラ運用では、 目的の異なるリポジトリで方式を使い分けています 。「最新だから第 3 世代」と一律に押し通すのではなく、運用要件に合わせて第 2 / 第 3 を選択しています。

infra 集約リポジトリ:第 2 世代(WIF + SA Impersonation)

infra リポジトリは 複数のクライアントプロジェクト・複数環境を 1 本のパイプラインで管理 します。MR 1 件で複数クライアント(client-a / client-b / 共通サービス系)のスタックが任意の組み合わせで影響範囲になり、それぞれ別の GCP プロジェクト・別の Terraform SA で実行する必要があります。

そのため、認証は 「2 段階 WIF」 構造になっています。

GitLab OIDC
  ├── (1) Bootstrap SA に impersonate → Secret Manager から WIF 設定取得
  └── (2) スタック固有の Terraform SA に impersonate → terraform plan/apply

実装上は同じ external_account credential を、 2 つの異なる SA に対して 順次切り替える設計です。

# wif_bootstrap.sh — Bootstrap SA への impersonation
cat > /tmp/wif_bootstrap.json <<JSON
{
  "type": "external_account",
  "audience": "${BOOTSTRAP_WIF_AUDIENCE}",
  "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
  "token_url": "https://sts.googleapis.com/v1/token",
  "service_account_impersonation_url":
    "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${BOOTSTRAP_SA}:generateAccessToken",
  "credential_source": { "file": "/tmp/oidc_token" }
}
JSON
# wif_auth.sh — Stack SA への impersonation(Secret Manager から取得した値で)
cat > /tmp/wif_cred.json <<JSON
{
  "type": "external_account",
  "audience": "//iam.googleapis.com/${WIF_AUDIENCE}",
  "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
  "token_url": "https://sts.googleapis.com/v1/token",
  "service_account_impersonation_url":
    "https://iamcredentials.googleapis.com/v1/projects/-/serviceAccounts/${WIF_SA}:generateAccessToken",
  "credential_source": { "file": "/tmp/oidc_token" }
}
JSON

第 3 世代に移行できないのは、 「Bootstrap → Stack」という SA 切替の運用が、SA という中間オブジェクトを必要とする ためです。具体的には:

  • スタックごとに別プロジェクトの Terraform SA が必要
  • どの SA を使うかは Secret Manager から動的に決まる(パイプライン生成時に確定)
  • principalSet:// で全プロジェクトの全ロールを直付けすると、 WIF Provider 1 個に対して権限が爆発的に増え 、最小権限の原則を保てない

つまりこの場合、 SA 経由は「冗長な経路」ではなく「権限を集約・差し替えるためのレイヤ」として機能 しています。中間 SA を捨てると IAM 設計が破綻する典型例です。

クライアントリポジトリ:第 3 世代(Direct Resource Access)

一方、各クライアントのアプリ用リポジトリ(例:app-repo)は 単一プロジェクトのアプリデプロイ専用 です。CI で Cloud Run へのデプロイ・Artifact Registry への push しか行いません。

このシナリオでは、SA を経由する理由がありません。 WIF Pool の連携 ID 自身に必要なロールを直接付与 すれば十分です。

# クライアントリポジトリの IAM バインディング(一部)
resource "google_project_iam_member" "ci_run_developer" {
  project = module.project.project_id
  role    = "roles/run.developer"
  member  = "principalSet://iam.googleapis.com/${module.gitlab_wif.pool_name}/attribute.project_path/my-org/clients/client-a/app-repo"
}

resource "google_project_iam_member" "ci_artifact_writer" {
  project = module.project.project_id
  role    = "roles/artifactregistry.writer"
  member  = "principalSet://iam.googleapis.com/${module.gitlab_wif.pool_name}/attribute.project_path/my-org/clients/client-a/app-repo"
}

attribute.project_path「このクライアントの該当リポジトリからの認証だけ」 を厳密に絞り込んでいるため、第 2 世代の SA バインドより条件記述が直感的です。SA という余分なオブジェクトを管理する必要もなく、IAM ポリシーは gcloud projects get-iam-policy直接ポリシー全体を読めば運用がわかる 状態を保てます。

「インパーソネーションあり / なし」の社内用語

CTS のインフラドキュメントでは「インパーソネーションあり / なし」という言い回しが使われていますが、Google の正式用語ではないため定義を揃えます。

社内用語公式分類意味
インパーソネーションあり(公式分類なし。社内固有の 2 段階方式)Principal SA → Target SA の 2 段階で SA を借りる
インパーソネーションなし第 2 世代 (WIF + SA Impersonation)WIF から 直接 Target SA を 1 段階で借りる
直接アクセス第 3 世代 (Direct Resource Access)SA を全く介さず連携 ID 自身に IAM ロール

「インパーソネーションなし」は 公式の分類では第 2 世代 に当たる点に注意が必要です。「中継 SA を経由しない」という意味であり、「impersonation API を呼ばない」という意味ではありません。

🪧 補足:GitLab CI 側の OIDC トークン取得方法の遍歴

クラウド側の認証方式(第 1〜3 世代)と並行して、 GitLab 側の OIDC トークン発行 API にも独立した世代変遷があります。本記事の YAML 例で id_tokens を当然のように使っていますが、これは比較的新しい仕組みで、それ以前は別の方法が標準でした。

時期機構特徴状態
~ GitLab 15.8CI_JOB_JWT / CI_JOB_JWT_V2 事前定義変数全ジョブに自動配布、audience が固定GitLab 15.9(2023-02)で deprecated
GitLab 15.7+id_tokens キーワードジョブ単位で複数 audience を明示宣言現行推奨
GitLab 16.1+id_tokens:aud で変数展開可能aud に CI 変数や式を埋め込める機能拡充
GitLab 17.0CI_JOB_JWT_V2 削除予定EOL

CI_JOB_JWT_V2 は事前定義変数だったため、 すべてのジョブから無条件に参照でき、aud が GitLab インスタンス URL に固定 という制約がありました。これでは複数の信頼境界(GCP・AWS・Vault 等)を 1 パイプラインで使い分けにくく、 流用攻撃のリスクも高い という指摘がコミュニティから上がっていました。

id_tokens キーワードでは:

  • ジョブ単位で 複数の独立した audience を宣言できる(GCP 用・AWS 用・Vault 用を別トークンで発行)
  • ジョブが明示的に id_tokens: を書いた場合のみ発行されるため、 不要なジョブにトークンが配布されない
  • GitLab 16.1 以降は aud に変数展開が効くため、Provider URL を環境別に切替できる

CTS では、CI_JOB_JWT_V2 の非推奨化(GitLab 15.9, 2023 年 2 月)を機に、すべての CI/CD パイプラインを id_tokens 方式へ全面移行 しました。これにより、Terraform 用と AWS 用で別の audience を発行し、各信頼境界に対して 最小限のクレデンシャル だけを渡せる構成になっています。本記事以降の YAML 例はすべて id_tokens 前提で記述しています。

移行時の典型的な落とし穴: WIF Provider 側の allowed_audiences を変えずに id_tokens:aud だけ書き換えると、 Invalid token audience で失敗します。両側を一貫した値で揃える必要があります。

🔄 同じパイプラインで使い分ける実装例

GitLab CI 1 本で、 Terraform 実行は第 2 世代・アプリデプロイは第 3 世代 という使い分けも当然可能です。id_tokens を共通化し、before_script で credential JSON だけ切り替えます。

.gen2_terraform:
  id_tokens:
    GOOGLE_OIDC: { aud: https://gitlab.com }
  before_script:
    - bash scripts/wif_bootstrap.sh
    - source scripts/load_env_from_gsm.sh
    - bash scripts/wif_auth.sh   # Stack SA に impersonate(第 2 世代)

.gen3_app_deploy:
  id_tokens:
    GOOGLE_OIDC: { aud: https://gitlab.com }
  before_script:
    - |
      cat > /tmp/cred.json <<EOF
      {
        "type": "external_account",
        "audience": "//iam.googleapis.com/${WIF_AUDIENCE}",
        "subject_token_type": "urn:ietf:params:oauth:token-type:jwt",
        "token_url": "https://sts.googleapis.com/v1/token",
        "credential_source": { "file": "/tmp/oidc_token" }
      }
      EOF
    - export GOOGLE_APPLICATION_CREDENTIALS=/tmp/cred.json   # Direct(第 3 世代)

service_account_impersonation_url を入れるかどうか」だけで世代が切り替わる、というのが実装上のポイントです。

🛡️ どの世代でも外せないベストプラクティス

世代に関わらず、 WIF を採用する以上は守るべき 基本原則があります。これは WIF でキーレス認証 で詳述したとおりですが、再掲します。

原則理由
attribute_conditionproject_path を厳密一致同じ GitLab.com の他プロジェクトからの侵入を防ぐ
allowed_audiences を明示リプレイ攻撃の難易度を上げる
Pool は環境(prod/staging)ごとに分割横展開時の事故防止
ブランチ条件(refs/heads/main / ref_protected)を併用ブランチ保護を活かす
監査ログで principal:// または serviceAccount: のアクターを必ず確認想定外の経路を早期発見

📝 移行ガイド:何から始めるべきか

第 1 世代から脱出したい場合の最短ルートです。

📝 移行ガイド:何から始めるべきか

SA キーを止める

中間 SA を畳む

第 1 世代

SA Key

第 2 世代

SA Impersonation

第 3 世代

Direct Resource Access

  1. まず第 1 → 第 2 に切り替える(鍵を撤廃する効果が最も大きい)
  2. その上で、SA を経由する 必然性がない 経路を洗い出し、第 2 → 第 3 に段階移行
  3. 集約パイプライン等、SA 切替が要件に組み込まれているものは無理に第 3 世代に倒さない

「全部第 3 世代に揃える」をゴールにするのではなく、 経路ごとの最小段数 を達成するのがゴールです。

🎯 まとめ

採用判断推奨世代
単一プロジェクトのアプリデプロイ第 3 世代(Direct Resource Access)
単一プロジェクトの Terraform 実行第 3 世代 または第 2 世代(API 互換性で判断)
複数プロジェクト・複数 SA を切り替える集約パイプライン第 2 世代(中間 SA が運用上の意味を持つ)
公開済みの SA キーが残っている直ちに第 2 世代へ移行

CTS では:

  • infra 集約リポジトリ:第 2 世代(Bootstrap SA → Stack SA の 2 段階 impersonation)
  • クライアント側アプリ用リポジトリ:第 3 世代(principalSet:// で直接バインド)

と方式を分けています。これは 「最新が常に最適」ではなく、運用要件に応じた最小段数を選ぶ」 という技術選定の典型例です。


関連記事

関連用語

外部資料