🔑

CDKTFでGithub Actionsで使うWorkload Identityを構築するぞ

2023/09/01に公開

こんにちはこんにちは、株式会社TERASSのこうさかです。

Github ActionsでGCPへのデプロイなどをする際に、公式の認証Actionを使うと思うのですが、最近だとService Accountで認証するのではなくWorkload Identityを使うことが推奨されています。
設定する項目が多く手でやりたくなかったので、これをCDKTFで定義してみました。

Workload Identityとは

このドキュメントでは、外部ワークロードのための ID 連携の概要について説明します。ID 連携を使用することで、サービス アカウント キーを使用せずに、Google Cloud リソースへのアクセス権を、オンプレミスまたはマルチクラウドのワークロードに付与できます。
ID 連携は、アマゾン ウェブ サービス(AWS)や、OpenID Connect(OIDC)をサポートする任意の ID プロバイダ(IdP)(Microsoft Azure など)、SAML 2.0 で使用できます。
https://cloud.google.com/iam/docs/workload-identity-federation?hl=ja

とのことで、なんのこっちゃという感じですが、要するにOIDCやらSAMLで他のIaaSやGithubなどと話して認証できますということらしい。
Githubの場合GithubがOIDCをサポートしているのでOIDCで話してOIDCトークンから短時間だけ使えるGCPの認証情報を取得できるようになっています。

GitHub リポジトリを信頼するように Workload Identity プールを構成すると、そのリポジトリのワークフローで GitHub OIDC トークンを使用して、有効期間の短い Google Cloud 認証情報を取得できます。
https://cloud.google.com/iam/docs/workload-identity-federation-with-deployment-pipelines?hl=ja

前準備

CDKTFは公式サイトなどを確認してインストールしてください。今回はTypescriptで進めるので、package.jsonに依存を追加する形で用意しました。
今回は1プロジェクトにTerraformのステートファイルとWorkload Identityを詰め込む構成で作りますが、状況に応じて別のプロジェクトで集中管理するなどの方法を取ったほうがいいと思います。
Terraform ステートファイルはGCSで管理し、Github ActionsでCDKTFをデプロイできる状況を目指します。

Terraformステート管理の準備

一旦作業自体はローカルのマシンで行います。gcloudコマンドを認証した上で作業していきます。

gcloud auth login // 自分のアカウントで認証しとく
// bucket作成
gcloud storage buckets create gs://terass-kosaka-sample-bucket --project=terass-kosaka-sample-a --location=ASIA-NORTHEAST1 --uniform-bucket-level-access

そして、以下がバックエンドをGCPに指定する場合のコードになります。

import { GoogleProvider } from "@cdktf/provider-google/lib/provider"
import { App, TerraformStack, GcsBackend } from "cdktf"
import { Construct } from "constructs"

class SampleStack extends TerraformStack {
  constructor(scope: Construct, id: string) {
    super(scope, id)

    new GoogleProvider(this, "google", {
      project: "terass-kosaka-sample-a",
    })
    new GcsBackend(this, {
      bucket: "terass-kosaka-sample-bucket",
      prefix: "terraform/state",
    })
  }
}

const app = new App()
new SampleStack(app, "cdktf-sample")
app.synth()

これをcdktfを用いてplanしてみます。

$ cdktf plan
cdktf-sample  Initializing the backend...
cdktf-sample
              Successfully configured the backend "gcs"! Terraform will automatically
              use this backend unless the backend configuration changes.
.....

上記のような表示が出て、用意したGCSの中にtfstateファイルが作成されます。これでCDKTFで開発する環境が最低限整いました。

Workload Identityを定義する

CDKTFをGithub Actionsで実行するためにGCPのWorkload IdentityをCDKTF作ってみましょう。鶏卵的な要素がありますが、実運用する際はWorkload Identityを管理するGCPプロジェクトを別プロジェクトで管理するのが良さそうです。

Workload Identityで利用するService Accountの作成

Workload IdentityはSAの権限を借用して認可するので、そのためのSAを作り適切な権限を与えます。参考

    const sa = new ServiceAccount(this, "github-actions-sa", {
      accountId: "github-actions-sa",
    })
    // terraformで使うので今回は手っ取り早くownerをあげちゃう
    new GoogleProjectIamMember(
      this,
      "github-actions-sa-project-iam-member-owner",
      {
        role: "roles/owner",
        project: "terass-kosaka-sample-a",
        member: `serviceAccount:${sa.email}`,
      },
    )
    // https://cloud.google.com/iam/docs/workload-identity-federation-with-deployment-pipelines?hl=ja#expandable-1
    new GoogleProjectIamMember(
      this,
      "github-actions-sa-project-iam-member-service-account-token-creator",
      {
        role: "roles/iam.serviceAccountTokenCreator",
        project: "terass-kosaka-sample-a",
        member: `serviceAccount:${sa.email}`,
      },
    )

Workload Identity Poolの設定

ワークロードIdentity自体の設定をやっていきます。
流れとしてはPoolを作りその下のPoolProviderで属性のマッピングと認可の条件設定を行います。
最後にサービスアカウントの借用の設定を行って、SAをWorkload Identityから使用できる状態にします。

    const repositoryName = "terass/sample"
    const identityPool = new GoogleIamWorkloadIdentityPool(
      this,
      "github-actions-ip",
      {
        workloadIdentityPoolId: "github-actions-ip",
      },
    )
    // 属性マッピング
    // この例ではrepositoryで絞って一致していない限り使えないようになっている 
    new GoogleIamWorkloadIdentityPoolProvider(
      this,
      "github-actions-ip-provider",
      {
        workloadIdentityPoolId: identityPool.workloadIdentityPoolId,
        workloadIdentityPoolProviderId: "github-actions-ip-provider",
        attributeCondition: `assertion.repository == "${repositoryName}"`,
        attributeMapping: {
          "google.subject": "assertion.sub",
          "attribute.repository": "assertion.repository",
        },
        oidc: {
          issuerUri: "https://token.actions.githubusercontent.com",
        },
      },
    )
    // 権限の借用を許可する設定
    // 属性マッピングの値を使ってそこにマッチするもので絞っているが、PoolProviderのattributeConditionとどう使い分けていくのがいいかわからん
    new ServiceAccountIamMember(
      this,
      "github-actions-iam-member-workload-identity",
      {
        serviceAccountId: sa.name,
        role: "roles/iam.workloadIdentityUser",
        member: `principalSet://iam.googleapis.com/${identityPool.name}/attribute.repository/${repositoryName}`,
      },
    )

以上でWorkload Identityの設定は終わりです。
最後にこれを一度GCPに反映してみます。反映するコマンドは以下になります。

$cdktf deploy

デプロイが完了するとWorkload Identity プールとWorkload Identity Providerができていると思います。これを使ってGithub Actionsで認証をしてみましょう。

Github Actions

name: Deploy

on:
  workflow_dispatch:
  release:
    types: [published]

jobs:
  deploy:
    permissions:
      contents: read
      id-token: write
    runs-on: ubuntu-latest
    steps:
      - id: auth
        uses: google-github-actions/auth@v1
        with:
          workload_identity_provider: <workload_identity_provider>
          service_account: <SERVICE_ACCOUNT>
          project_id: terass-kosaka-sample-a
...

これはGithub公式の例をそのまま使うだけです。
実際の処理はここに引き続き定義すると、GCPが認証された状態でgcloudコマンドやGCP系のGithub Actionsが使えます。

最後にソースを貼っておきます。

import { GoogleProvider } from "@cdktf/provider-google/lib/provider"
import { ServiceAccount } from "@cdktf/provider-google/lib/service-account"
import { ServiceAccountIamMember } from "@cdktf/provider-google/lib/service-account-iam-member"
import { GoogleIamWorkloadIdentityPool } from "@cdktf/provider-google-beta/lib/google-iam-workload-identity-pool"
import { GoogleIamWorkloadIdentityPoolProvider } from "@cdktf/provider-google-beta/lib/google-iam-workload-identity-pool-provider"
import { GoogleProjectIamMember } from "@cdktf/provider-google-beta/lib/google-project-iam-member"
import { GoogleBetaProvider } from "@cdktf/provider-google-beta/lib/provider"
import { App, TerraformStack, GcsBackend } from "cdktf"
import { Construct } from "constructs"

class SampleStack extends TerraformStack {
  constructor(scope: Construct, id: string) {
    super(scope, id)

    new GoogleProvider(this, "google", {
      project: "terass-kosaka-sample-a",
    })
    new GcsBackend(this, {
      bucket: "terass-kosaka-sample-bucket",
      prefix: "terraform/state",
    })

    new GoogleBetaProvider(this, "google-beta", {
      project: "terass-kosaka-sample-a",
    })

    const sa = new ServiceAccount(this, "github-actions-sa", {
      accountId: "github-actions-sa",
    })
    new GoogleProjectIamMember(
      this,
      "github-actions-sa-project-iam-member-owner",
      {
        role: "roles/owner",
        project: "terass-kosaka-sample-a",
        member: `serviceAccount:${sa.email}`,
      },
    )

    new GoogleProjectIamMember(
      this,
      "github-actions-sa-project-iam-member-service-account-token-creator",
      {
        role: "roles/iam.serviceAccountTokenCreator",
        project: "terass-kosaka-sample-a",
        member: `serviceAccount:${sa.email}`,
      },
    )
    const repositoryName = "terass/kosaka-sample"
    const identityPool = new GoogleIamWorkloadIdentityPool(
      this,
      "github-actions-ip",
      {
        workloadIdentityPoolId: "github-actions-ip",
      },
    )
    new GoogleIamWorkloadIdentityPoolProvider(
      this,
      "github-actions-ip-provider",
      {
        workloadIdentityPoolId: identityPool.workloadIdentityPoolId,
        workloadIdentityPoolProviderId: "github-actions-ip-provider",
        attributeCondition: `assertion.repository == "${repositoryName}"`,
        attributeMapping: {
          "google.subject": "assertion.sub",
          "attribute.repository": "assertion.repository",
        },
        oidc: {
          issuerUri: "https://token.actions.githubusercontent.com",
        },
      },
    )
    new ServiceAccountIamMember(
      this,
      "github-actions-iam-member-workload-identity",
      {
        serviceAccountId: sa.name,
        role: "roles/iam.workloadIdentityUser",
        member: `principalSet://iam.googleapis.com/${identityPool.name}/attribute.repository/${repositoryName}`,
      },
    )
  }
}

const app = new App()
new SampleStack(app, "cdktf-sample")
app.synth()

以上

SAをシークレットに登録せずともGithub Actions場でGCPの認証ができるようになりました。
毎回毎回シークレットを発行せずとも、Workload IdentityのSA権限借用の仕組みを使い中央集権化することもできそうです。一方IaCせずに手でポチポチやるのは結構めんどくさい場所なので、そこだけは対応したほうが楽できそうだなと感じました。

おまけ

CDKTFでGCPのAPI有効化するコードを書くときに、terraformのsetとかfor_each的なものがあるのかなと思ったのですが見つからず困りました。よくよく考えてみると変数やループなどTypescriptのネイティブ機能を使って表現できるのでそっちを使えばよかったです。
結局こんな感じにしてみました。

    const services = [
      "artifactregistry.googleapis.com",
      "storage.googleapis.com",
      "iamcredentials.googleapis.com",
    ]
    services.map((service) => {
      return new ProjectService(this, `project-service-${service}`, {
        service,
      })
    })
Terass Tech Blog

Discussion