CI/CDパイプラインで本番環境を保護する

こんにちは、Legalscape (採用情報) で法情報のグラフデータを構築・運用するチームに所属するあざらし です。

弊社ではCI/CDをGithub Actions上で実行しています。またクラウドは主にGoogle Cloudを利用していますが、そのCI/CDのワークフローの中で利用されるシークレットやIAMの管理なども全てterraformで行っています。

一方で、デプロイフローが整備されIaCが進むことで変更が容易になるにつれ本番環境に対して意図しない変更を加えてしまうリスクも上がります。

ここでいう意図しない変更というのは、例えば人為的なミス(e.g. 誤ってreleaseブランチにpushしてしまいそのまま本番にデプロイされてしまった)や生成AIによるガードレールの逸脱、あるいは組織内外問わず悪意を持った人間による攻撃などが挙げられます。

いずれにせよ意図しないタイミングと内容で本番環境が変更されてしまう状況は避けるべきでしょう。

そこで、この記事では弊社におけるCI/CDパイプラインにおける本番環境の保護についての取り組みを簡単に紹介したいと思います。

問題

この場合の保護というのは、特定の環境が適切な権限・フローによって変更されたものであることを担保するということです。

例えば弊社で言うと以下のようなポリシーなどがあります。

  • 本番環境

    • release/*タグの作成時のみデプロイできる
    • release/*タグは (developブランチをベースとして) CODEOWNERSだけが作成できる
  • staging環境

    • developブランチの更新時のみデプロイできる
    • developブランチはCODEOWNERSからの承認を得たPRのみマージできる

これを抽象化すると、以下の両輪での担保が必要ということになります。

  • ブランチ・タグが特定のルールに則って更新・作成されている
  • デプロイジョブがそのブランチ・タグからトリガーされている

こう書くと当たり前に見えますが、これまで弊社では前者にあたるGitHub上でブランチ・タグのRuleSetだけ作成し、後者の「デプロイ実行対象のブランチ・タグの制限」が存在していない片手落ちの状況が続いていました。

つまり、例えばフィーチャーブランチでCIのワークフローの定義ファイルを編集することで任意の処理を実行でき、最悪の場合 本番環境へのデプロイを行うことも可能な状況になっていたということになります。

このため、ブランチの保護だけでなくデプロイジョブについても特定のルールで保護されたブランチからトリガーされることを担保する必要があります。

対応策

Google Cloud (以下GCP) をメインのクラウドとして利用している弊社では、この施策の実現にあたり以下の選択肢を検討していました。

  1. GitHub Environment
  2. Workload Identity 連携

順に見ていきます。

GitHub Environment

GitHubにはEnvironmentという概念が存在します。

docs.github.com

各Environmentには特定ブランチ・タグでトリガーされたワークフローでしか実行できない制約を付与することができ、またそのEnvironmentからしか参照できないSecretを作成できます。

デプロイジョブは(当然ですが)GCP上のアプリケーションに変更を加えるのでGCPの対象リソース(e.g. Cloud Run, ...)の権限を持つ必要があるため、GCP上のリソースの適切な権限を持ったサービスアカウントとして認証する必要がありますが、サービスアカウントキーをEnvironment Secretにおくことで結果的にデプロイ環境を保護できるということになります。

具体的な定義としては、GitHub Actions上でGCPの認証をする場合は基本的にはgoogle-github-actions/authを利用するかと思いますが一応例を載せておくと以下のようなイメージになります。

      - uses: "google-github-actions/auth@v2"
        with:
          credentials_json: "${{ secrets.SERVICE_ACCOUNT_KEY }}"

ブランチ・タグと合わせて全てをGitHub上で管理するのはシンプルで運用も楽になるので非常に魅力的な選択肢でした。

ただし、この方式だと従来通りサービスアカウントキーを利用するため、セキュリティ上適切に管理する手間がかかります(非推奨となっています)。

docs.cloud.google.com

また弊社では元々Environmentを利用しておらず、また当時(別観点ですがキャッシュ容量問題やDockerイメージのpushのネットワークレイテンシ問題もあり)Cloud Buildへの移行を検討していたため、Environment 前提のワークフローに移行したり既存のシークレット群をEnvironmentごとに分けて動作確認する工数が高くつくという判断で見送ることにしました。

Workload Identity 連携

GCPでは、OIDCなどをサポートするIdPとのWorkload Identity 連携が利用できます。これは一言で言うと外部IdPのプリンシパルに対しサービスアカウントとしてGCPのリソースの権限を直接付与できるものです。

docs.cloud.google.com

GitHubも対応しており、認証に必要なシークレットをGitHub上におかず(つまりEnvironment Secretを利用せず)にGCP上で認証することができます。

cloud.google.com

この方式では、例えば以下のようにGitHub Actions上でworkload_identity_providerを指定する(& 別途workload_identity_providerの設定を行う)だけでサービスアカウントキーを参照することなく認証することができます。

      - uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: 'projects/${{ inputs.project_number }}/locations/global/workloadIdentityPools/cd-pool/providers/github-actions'
          service_account: 'cd-github-actions@${{ inputs.project_id }}.iam.gserviceaccount.com'

またGitHubから受け取ったOIDCトークンに載っている情報を元に、Common Expression Language(CEL) というDSLを用いて(ほぼ)任意の検証処理を行うことができます。

これにより、以下のようにして特定リポジトリの特定ブランチ・タグでトリガーされたかどうかを検証することができます。

  • staging環境はdevelopブランチの更新時のみデプロイできる
assertion.aud=='https://github.com/legalscape' &&
assertion.repository=='legalscape/xxx' &&
assertion.ref=='refs/heads/main'
  • 本番環境はrelease/*タグの作成時のみデプロイできる
assertion.aud=='https://github.com/legalscape' &&
assertion.repository=='legalscape/xxx' &&
assertion.ref.startsWith('refs/tags/release/')

このようにWorkload Identity連携の認証時にトリガー元を検証した上で、該当するサービスアカウントに特定環境へのリソース変更権限を付与することで目的を達成することができるようになります。

参考としてterraformの定義を掲載しておきます。

terraform(一部抜粋)

terraform/modules/cd/main.tf

resource "google_service_account" "cd" {
  account_id = "cd-github-actions"
}

resource "google_service_account_iam_member" "cd" {
  service_account_id = google_service_account.cd.name
  role               = "roles/iam.workloadIdentityUser"
  member             = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.cd.name}/attribute.repository/legalscape/xxx"
}

resource "google_iam_workload_identity_pool" "cd" {
  workload_identity_pool_id = "cd-pool"
}

resource "google_iam_workload_identity_pool_provider" "github_actions" {
  workload_identity_pool_id          = google_iam_workload_identity_pool.cd.workload_identity_pool_id
  workload_identity_pool_provider_id = "github-actions"

  attribute_mapping = {
    "google.subject" = "assertion.sub"

    # https://docs.github.com/en/actions/concepts/security/openid-connect#understanding-the-oidc-token
    "attribute.aud"              = "assertion.aud"
    "attribute.repository"       = "assertion.repository"
    "attribute.ref"              = "assertion.ref"
  }

  # https://cloud.google.com/iam/docs/workload-identity-federation#conditions
  attribute_condition = join("&&", concat(
    [
      "assertion.aud=='https://github.com/legalscape'",
      "assertion.repository=='legalscape/xxx'",
      "assertion.ref.startsWith('refs/tags/release/')",
    ],
  ))

  oidc {
    issuer_uri = "https://token.actions.githubusercontent.com"
  }
}

ちなみに余談ですが弊社ではGitHub Environmentを利用していない都合上、環境ごとに必要なSecretも含め全てのSecretがGCPのSecret Manager上で管理されています。これにより、より精細なセキュリティ管理ができ、またGCP上に集約することで管理コストも抑えられます。

GitHub Actionsでは以下のように簡単に参照することができます。

      - id: 'secrets'
        uses: 'google-github-actions/get-secretmanager-secrets@v2'
        with:
          secrets: |-
            sentry_auth_token:${{ inputs.project_id }}/sentry_auth_token
            auth0_deploy_client_secret:${{ inputs.project_id }}/auth0_deploy_client_secret

まとめ

Workload Identity連携を用いてCI/CDパイプラインにおいて特定環境の保護について紹介しました。

弊社ではパフォーマンス観点も含めCI/CDのパイプラインの改善も継続的に取り組んでいますので、ご興味ある方はまずはぜひカジュアル面談をさせていただけると嬉しいです。お待ちしております。