🎉

GHES の GitHub Actions で Google Cloud との OIDC 連携がしやすくなりました

2024/01/26に公開

みなさん GitHub Actions の OpenID Connect (以下OIDC) 連携使っていますか?

GitHub Actions はワークフローの中で OIDC の ID トークンを発行でき,これを Google Cloud などのクラウドプロバイダの認証に用いることでサービスアカウントのクレデンシャルを発行することなくクラウドプロバイダのリソースをワークフロー内で操作できるようになります.これによりクレデンシャルのローテーションなどの管理コストが減り,よりセキュアな連携が可能になります.

詳しくは GitHub の公式ドキュメントや解説記事を見ていただけると良いと思います.
https://docs.github.com/ja/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect
https://zenn.dev/miyajan/articles/github-actions-support-openid-connect

基本的に良いことづくめな OIDC 連携ですが,GitHub Enterprise Server (オンプレ版 GitHub.以下GHES) では OIDC 連携が難しいケースがあります.OIDC 連携ではクラウドプロバイダが ID トークンの検証のために GHES から検証鍵を取得する通信が発生しますが,プライベートネットワーク内にGHESをホストし外部ネットワークからの通信を遮断している場合は検証鍵を取得できず連携が失敗してしまいます.特定のエンドポイントのみ通信を許せば連携可能ですが,そのような穴は開けたくないという人も多いのではないでしょうか.

そんなこんなで GHES の GitHub Actions では OIDC 連携は諦めていたのですが,最近 Google Cloud の Workload Identity 連携にローカル JWK (Json Web Key) ファイルのアップロードという機能[1]が実装されているのに気づき,プライベートネットワーク下の GHES でも OIDC 連携が可能なことを確認したので,その手順について説明したいと思います.なお,OIDC や ID トークン自体に関する説明は基本的に省略するので,そちらが気になる方は以下の記事から関連する RFC に飛んでください.

https://qiita.com/TakahikoKawasaki/items/185d34814eb9f7ac7ef3

GitHub Actions の OIDC 連携の仕組み

具体的な手順について説明する前に,そもそも GitHub Actions の OIDC 連携というものがどのような仕組みで動いているのかを簡単に説明したいと思います(設定手順が見たい人はこの章をスパッと飛ばしてください).

↓の図は GitHub Actions の OIDC 連携の流れを簡単に示したものです.

GitHub Actions の OIDC 連携を簡単に説明すると,クラウドプロバイダに GHES (or GitHub.com) の OIDC プロバイダを信頼させ,OIDC プロバイダが生成した ID トークンの属性(ワークフローが実行されたリポジトリやブランチなど)に応じてリソースの権限(ロール)を持った短命のアクセストークンを発行して,ワークフローからクラウドプロバイダのリソースにアクセスできるようにする仕組みです.

以降では図を基に OIDC 連携の流れを説明していきます.

1. ID トークンの取得

まず最初に,GitHub Actions のワークフロー内で,GHES (または GitHub.com) の OIDC プロバイダにアクセスし,ID トークンを取得します.この ID トークンはリポジトリやワークフローごとに異なる情報が格納されており,ID トークンの内容の違いによって,クラウドプロバイダ側でどのような権限を与えるかを制御できます.

ID トークンは,ワークフローの中で以下のようなコマンドを実行すると取得できます.ACTIONS_ID_TOKEN_REQUEST_URL は OIDC プロバイダの URL,ACTIONS_ID_TOKEN_REQUEST_TOKEN は OIDC プロバイダへのリクエストに必要なトークンが格納されています.

curl -H "Authorization: bearer $ACTIONS_ID_TOKEN_REQUEST_TOKEN" "$ACTIONS_ID_TOKEN_REQUEST_URL"

この処理をワークフローで実行するためには,id-token: write の権限が必要なので,ワークフローファイルに以下の permissions を追記してください.

permissions:
  id-token: write # ID トークンを取得するために必要な権限
  contents: read  # permissions を追記すると contents に対する read 権限がなくなるので actions/checkout などを使う時は明示的に read にしてあげる

上記のコマンドを実行すると ID トークンが取得できますが,実行結果はマスキングされて表示されます.ID トークンはJWT(Json Web Token)形式になっているため,以下のようなコマンドを実行してあげると ID トークンのヘッダとペイロードが確認できます.

curl -s -H "Authorization: bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" "${ACTIONS_ID_TOKEN_REQUEST_URL}" | jq '.value | split(".") | .[0],.[1] | @base64d | fromjson'

自分が GHES の GitHub Actions で実行した結果は以下のようになりました.

JWTの中身
{
  "typ": "JWT",
  "alg": "RS256",
  "x5t": "...",
  "kid": "..."
}
{
  "jti": "...",
  "sub": "repo:keisuke-emi/github-actions-playground:ref:refs/heads/oidc-test",
  "aud": "https://<ghes-host>/keisuke-emi",
  "ref": "refs/heads/oidc-test",
  "sha": "...",
  "repository": "keisuke-emi/github-actions-playground",
  "repository_owner": "keisuke-emi",
  "repository_owner_id": "...",
  "run_id": "...",
  "run_number": "...",
  "run_attempt": "...",
  "repository_visibility": "public",
  "repository_id": "...",
  "actor_id": "...",
  "actor": "keisuke-emi",
  "workflow": "Playground",
  "head_ref": "",
  "base_ref": "",
  "event_name": "push",
  "ref_type": "branch",
  "workflow_ref": "keisuke-emi/github-actions-playground/.github/workflows/playground.yaml@refs/heads/oidc-test",
  "workflow_sha": "...",
  "job_workflow_ref": "keisuke-emi/github-actions-playground/.github/workflows/playground.yaml@refs/heads/oidc-test",
  "job_workflow_sha": "...",
  "enterprise": "cybozu-inc",
  "iss": "https://<ghes-host>/_services/token",
  "nbf": ...,
  "exp": ...,
  "iat": ...
}

<ghes-host> には GHES のホスト名やドメイン名が入ります.GitHub.com の GitHub Actions の場合は github.com が入り,issクレームは https://token.actions.githubusercontent.com となります[2]
また,audクレームはデフォルトではワークフローを実行したリポジトリのオーナーのURLとなりますが,OIDC プロバイダの URL のクエリパラメータを使って任意の文字列を指定することができます[3]

このような流れで取得できる ID トークンですが,実際に取得する際は各クラウドプロバイダが提供している Action を利用して取得することがほとんどなので,開発者がこのような流れを意識することはまずありません.たとえば,Google Cloud と連携したい場合は google-github-actions/auth を利用すれば ID トークンを取得してくれます.内部的には,GitHub の提供する @actions/core という npm パッケージに core.getIDToken() というメソッドが用意されており,各クラウドプロバイダの Action はそのメソッドを利用しています.

2. ID トークンによる認証

ID トークンが用意できたら,ワークフローはその ID トークンを用いてクラウドプロバイダの認証を行います.この時,クラウドプロバイダ側には OIDC Trust と呼ばれる「クラウドプロバイダと OIDC プロバイダ間の信頼関係」を表現した設定が必要となります(ここら辺は専門ではないので用語や概念が間違ってたらご指摘ください).

たとえば,Google Cloudの場合は Workload Identity 連携が OIDC Trust を設定するサービスとなります.この Workload Identity 連携で,どの OIDC プロバイダを信頼し,どのような条件の ID トークンが渡されたらどのような権限を与えるかを判断します.今回で言えば,信頼する OIDC プロバイダは GHES や GitHub.com となり,より具体的には ID トークンの iss クレームに入ってくる https://<ghes-host>/_services/tokenhttps://token.actions.githubusercontent.com となります.

ID トークンの取得と同様に,主要なクラウドプロバイダでは Action を利用するだけで ID トークンによる認証が可能です.たとえば,Google Cloud と連携する場合,以下のようなワークフローの記述で google-github-actions/auth を利用できます.

- uses: google-github-actions/auth@v2
  with:
    workload_identity_provider: 'projects/<プロジェクト番号>/locations/global/workloadIdentityPools/<プールID>/providers/<プロバイダID>'
    service_account: '<サービスアカウントのメールアドレス>'

このステップにより,ID トークンを利用した認証情報が生成され,以降のステップで gcloud コマンドを実行した時にその認証情報が用いられるようになります.<プールID><プロバイダID> は Workload Identity 連携で作成したプールとプロバイダの ID になります.詳しくは後述します.

3. ID トークンの検証

クラウドプロバイダは,渡された ID トークンが信頼する OIDC プロバイダから発行されたものなのかを検証する必要があります.ID トークンの形式は <ヘッダ>.<ペイロード>.<ヘッダとペイロードを署名鍵で署名したもの> となっており,署名の検証のために OIDC プロバイダが公開している検証鍵が用いられます.検証鍵を公開するエンドポイントは OpenID Connect Discovery 1.0 [4] という仕様で定められており,OIDC プロバイダ(iss クレームで指定された URI)の /.well-known/openid-configuration で取得できる JSON の jwks_uri で指定されたエンドポイントにアクセスすることで JWK 形式の検証鍵を取得できます.

前述の通り,外部ネットワークからの通信を遮断している GHES インスタンスを運用している場合は,この検証鍵を取得することができずに連携が失敗してしまいます.後述しますが,クラウドプロバイダによっては検証鍵を事前にアップロードすることができ,その場合はこの制約を突破できます.

自分の環境の GHES では, https://<ghes-host>/_services/token/.well-known/openid-configuration で OIDC プロバイダに関する情報が取得でき, jwks_urihttps://<ghes-host>/_services/token/.well-known/jwks となっていました.

4. ID トークンとロールの紐付け

ID トークンが信頼している OIDC プロバイダのものと検証できたら,次は ID トークンとロール(権限)の紐付けを行います.信頼している OIDC プロバイダの ID トークンであっても,全く関係ない組織やリポジトリで発行された ID トークンを認証で使えてしまうと危険なため,どのような条件の ID トークンをどのようなロールに紐づけるのか定義する必要があります.

先ほど説明したように,GitHub の OIDC プロバイダが発行する ID トークンにはワークフローに関する様々な情報が格納されています.紐付けの条件によく利用されるのは sub クレームや aud クレームなどです.これらの情報を用いて,特定のリポジトリの特定のブランチへの push で発火したワークフローのみにリソースの権限を借用したりできます.

ちなみに,sub クレームに含まれる情報はワークフローがどのような条件でトリガーされたかによって変わります.たとえば,あるブランチを push した時に発火したワークフローであれば repo:<orgName>/<repoName>:ref:refs/heads/<branchName> で, pull_request イベントで発火した場合は repo:<orgName>/<repoName>:pull_request となります.詳細な仕様を書いたドキュメントは見つからなかったのですが,いくつかの例が GitHub のドキュメントに記載されているので,そちらを参照してください.また,sub クレームは GitHub の REST API によって格納する情報をカスタマイズすることも可能です[5]

5. リソースへのアクセス

ID トークンの検証とロールとの紐付けがうまくいけば,クラウドプロバイダはロールに対応する権限を持ったアクセストークンを発行し,ワークフローはこのアクセストークンを使って目当てのリソースへのアクセスを行います.このアクセストークンの有効期限は十分に短く,GitHub 側に保存する必要もなくなるため,ローテーションが不要となり漏洩のリスクも少なくなります.

Google Cloud における OIDC Trust (Workload Identity 連携)

前の章では GHES (or GitHub.com) の GitHub Actions とクラウドプロバイダ間での OIDC 連携の流れを説明しました.ですが,この記事では GHES の GitHub Actions と Google Cloud の OIDC 連携について説明しようとしているので,Google Cloud における OIDC Trust を設定できる Workload Identity 連携についても説明しておこうと思います(繰り返しになりますが,設定手順を見たい人はこの章もスパッと飛ばしてください).

Workload Identity 連携にはプールとプロバイダというエンティティが存在し,これらのエンティティを用いてサービスアカウントの権限を借用できます.Workload Identity 連携の権限借用の流れは少し複雑なので,図を用いて簡単に説明したいと思います(ところどころ省略した図なので何となく概念を理解するために見てください).

GitHub Actions のワークフローは, OIDC プロバイダから ID トークンを取得したあと,Google Cloud の Security Token Service に対して ID トークンを送り,サービスアカウントの権限を借用してリソースにアクセスするための STS トークンを取得します.

ID トークンを渡された Security Token Service は,ID トークンの内容と対応する Workload Identity プールの設定から STS トークンを生成しワークフローに返します.Workload Identity プールはひとつ以上の Workload Identity プロバイダを保持しており,Workload Identity プロバイダにはどの IdP (Identity Provider; OIDC プロバイダも含まれる) を信頼するかや ID トークンと STS トークンの属性マッピングのルールなどを設定します.

ID トークンと STS トークンの属性マッピングについてもう少し詳しく説明すると,STS トークンには属性が付与されているらしく,属性マッピングとは「ID トークンの属性と STS トークンの属性の対応付け」となります.このルールに沿って ID トークンから STS トークンに属性が付与されます.STS トークンの属性は google.subjectgoogle.groupsattribute.<NAME> (<NAME> は任意の文字列) の3種類が存在し[6],以下のような形で ID トークンの属性と紐付けます(ID トークンの属性は assertion.<NAME> のように指定します).

ワークフローが STS トークンを受け取ったら,STS トークンを用いてサービスアカウントの権限を借用し,その権限で Google Cloud のリソースにアクセスします.上記の図では省略していますが,実際には STS トークンを用いてサービスアカウントの権限相当をもつアクセストークンを発行してもらい(これが権限借用),そのアクセストークンを用いてリソースにアクセスするという流れになっています.

サービスアカウントの権限を借用するためには,Workload Identity プールにサービスアカウントの Workload Identity ユーザーロール(roles/iam.workloadIdentityUser)を付与する必要があります.Workload Identity プールのプリンシパルは生成される STS トークンの属性で表現できます.

具体的には,

principal://iam.googleapis.com/projects/<プロジェクト番号>/locations/global/workloadIdentityPools/<プールID>/subject/<google.subject の値>

principalSet://iam.googleapis.com/projects/<プロジェクト番号>/locations/global/workloadIdentityPools/<プールID>/attribute.<属性名>/<属性値>

などの形式を取ります[7]

上記のようなプリンシパルの表現は,コンソール上でアクセス権付与する場合はあまり意識する必要はありませんが,gcloudコマンドや Terraform でアクセス権を付与する場合は理解しておく必要があります.

Workload Identity プロバイダに GHES の JWK をアップロードして GHES の GitHub Actions と OIDC 連携をする

大変前置きが長くなりましたが,ここからやっと本題に移ります.前々章で GHES では ID トークンの検証時にクラウドプロバイダが GHES の検証鍵を取得できないケースが存在することを述べました.しかし,Google Cloud の Workload Identity では,ID トークンの検証に用いる検証鍵を事前にアップロードする機能[1:1]が提供されています.そこで,以降は Workload Identity に GHES の検証鍵をアップロードし,その鍵を用いて OIDC 連携する手順を説明します.

サービスアカウントの作成

まずは GitHub Actions のワークフローで必要となる(クラウドプロバイダの)権限をもったサービスアカウントを用意します.今回はデモのために,ワークフローの中で Cloud Storage のあるバケットのオブジェクト一覧を取得したいので,Cloud Storage の閲覧権限をもつ cloud-storage-viewer というサービスアカウントを作成しました.

Workload Identity プール・プロバイダの作成

OIDC 連携をするための設定として Workload Identity のプールとプロバイダを作成します.

Workload Identity プールは↓のように名前・ID と説明を入力します.

次に Workload Identity プロバイダを作成します.

プロバイダの設定時には,「プロバイダの選択」を「OpenID Connect (OIDC)」,「発行元(URL)」を「https:://<ghes-host>/_services/token (<ghes-test> は GHES のホスト名,もしくはドメイン名) 」,「オーディエンス」は「デフォルトのオーディエンス」[8]を指定してください.

そして,一番重要なのは赤枠で囲んだ「JWK ファイル(JSON)」の項目です.この項目に GHES から取得した JWK ファイルをアップロードすれば,ID トークンの検証時にリモートアクセスして取得した JWK ファイルではなく,アップロードした JWK ファイルを用いて ID トークンの検証をしてくれるようになります.

取得するべき JWK は,GHES の https:://<ghes-host>/_services/token/.well-known/jwks というエンドポイントから取得できます.このファイルを手元のファイルに保存し,アップロードしてください.ただし,自分の環境では GHES から取得した JWK には x5cx5t という属性が含まれていましたが,2024年1月25日現在の Workload Identity プロバイダはこれらの属性をサポートしておらず[9],そのまま JWK ファイルをアップロードするとプロバイダの作成時にエラーが発生します.このため,以下のようなスクリプトを実行してサポートしていない属性を除去してください.

curl -s "https:://<ghes-host>/_services/token/.well-known/jwks" | jq 'del(.keys.[].x5c,.keys.[].x5t)' > ghes-jwk.json

最後にプロバイダの属性を構成します.

ここで,ID トークンと STS トークンの属性のマッピングの設定を行います.STS トークンは google.subjectgoogle.groupsattribute.<NAME>で属性を指定し,ID トークンは assertion.<NAME> で属性を指定します.上記のスクリーンショットでは,STS トークンの google.subject と ID トークンの repository クレーム(assertion.repository) をマッピングしています.詳しくはドキュメントを参照してください.

また,「属性条件」を設定することで,このプロバイダで認証する ID トークンを制限することも可能です.特定の条件の属性を持つ ID トークンのみを認証したい場合はこの設定に条件を追加していくことで実現できます.サービスアカウントの権限借用でも同じようなことはできますが,権限借用は単一の属性の条件しか記述できないのに対し,こちらの属性条件は複雑な条件を設定することが可能です.詳しくはドキュメントを参照してください.

サービスアカウントの権限借用の設定

Workload Identity プールの作成が完了したら,次は Workload Identity プールが目当てのサービスアカウントの権限借用ができるように,アクセス権を付与します.

まず,Workload Identity プールの詳細画面の上部にある「アクセスを許可」をクリックします.

次に,Workload Identity プールにサービスアカウントの権限借用のアクセス権を付与します.

「サービスアカウント」には,権限を借用したいサービスアカウントを選択し,「プリンシパルの選択」にはプロバイダでマッピングした属性とその属性の値を指定します.自分はここで少し混乱したのですが,ざっくりいうとプールとプロバイダで設定し認証した外部 IdP のアイデンティティに対して,どのアイデンティティにどのサービスアカウントの権限を借用するかを設定している感じです.

上記のスクリーンショットでは,先ほど作成したサービスアカウントを選択し,google.subjectkeisuke-emi/github-actions-playground になるプリンシパルを指定しています.つまり,GHES から渡された ID トークンの repository クレームが keisuke-emi/github-actions-playground となる ID トークンに対してのみ権限を借用することになり,それは GHES の keisuke-emi/github-actions-playground リポジトリで実行されたワークフローに対してのみ権限を借用することを意味します.

この設定で保存をするとサービスアカウントに対するアクセス権が付与されます.保存時に「アプリケーションの構成」のモーダルウィンドウが開きますが,必要なければ「非表示」を押してしまって大丈夫です.保存後にサービスアカウントの権限を見ると,Workload Identity プールに Workload Identity ユーザーのロールが付与されていることがわかります.

GitHub Actions のワークフローで Google Cloud のリソースにアクセス

ここまで来たらあとはワークフローを書いて実行するだけです.Workload Identity 連携を利用して認証するためには google-github-actions/auth というアクションを利用してください.あとは gcloud コマンドを実行すれば勝手に認証情報を参照してくれます.

↓は今回の例で設定した Workload Identity 連携のエンティティを用いて認証し,Cloud Storage にアクセスするワークフローです.

name: Playground

on: push
permissions:
  id-token: write

jobs:
  list-objects:
    runs-on: self-hosted
    steps:
      - uses: google-github-actions/auth@v2
        with:
          workload_identity_provider: 'projects/<projectNumber>/locations/global/workloadIdentityPools/ghes-oidc/providers/ghes-oidc'
          service_account: '<サービスアカウントのメールアドレス>'
	  
      - uses: google-github-actions/setup-gcloud@v2

      - run: gcloud storage ls gs://ghes-oidc-test

設定がうまくできていれば,問題なく GHES の GitHub Actions でも OIDC 連携が成功し,リソースにアクセスできるはずです.

JWK を事前にアップロードする時の注意点

JWK ファイルを事前にアップロードした場合,当然 GHES の署名鍵・検証鍵が更新されると ID トークンの検証ができなくなり,ワークフローが失敗し続けることになります.更新時の失敗は免れませんが,アップロードした JWK と実際に公開されている JWK に差分が発生していないかを定期実行のワークフローで確認すれば,問題の早期発見と原因特定が楽になると思います.

ちなみに,GHES の署名鍵・検証鍵の更新タイミングについて GitHub サポートに問い合わせてみましたが,そもそも GHES での OIDC 連携は jwks_uri エンドポイントの公開が必須要件であり,クラウドプロバイダへの JWK の事前アップロードはサポート対象外とのことでした.JWK のアップロードを用いて OIDC 連携にチャレンジされる皆様はこの点をご留意ください.

おわりに

ということで GHES の GitHub Actions で Google Cloud と OIDC 連携してみようという記事でした.本題と関係ない前置きをたくさん書いてしまいましたが,初めて GitHub Actions の OIDC 連携を学んだ時にややこしくて何もよくわからんと思ってた自分に向けて復習も兼ねて書かせていただきました.Workload Identity 連携に関してはいまだにちょっと概念をちゃんと正確に捉えられてるか心配なところもあるので,怪しいところあればコメントでご指摘いただけると嬉しいです.みんなで長生きクレデンシャルを撲滅していきましょう.

脚注
  1. https://cloud.google.com/iam/docs/workload-identity-federation#oidc-credential-security ↩︎ ↩︎

  2. GitHub.com の場合の ID トークンのサンプルはこちらを参照してください ↩︎

  3. https://docs.github.com/ja/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#updating-your-actions-for-oidc ↩︎

  4. https://openid.net/specs/openid-connect-discovery-1_0.html ↩︎

  5. https://docs.github.com/ja/actions/deployment/security-hardening-your-deployments/about-security-hardening-with-openid-connect#customizing-the-subject-claims-for-an-organization-or-repository ↩︎

  6. https://cloud.google.com/iam/docs/workload-identity-federation#mapping ↩︎

  7. 詳しくはこちらのドキュメントも参照してください ↩︎

  8. 「オーディエンス」の項目は,どのような aud クレームを持つIDトークンを許可するかという設定です.「許可するオーディエンス」を指定すると許可リストを自由に定義できますが,google-github-actions/authaudience オプションをデフォルトのまま利用する場合は,「オーディエンス」の項目は「デフォルトのオーディエンス」を指定します.audience をカスタマイズしたい方はアクションの README を参照してください. ↩︎

  9. APIドキュメントによると,The JWK must use following format and include only the following fields: { "keys": [ { "kty": "RSA/EC", "alg": "", "use": "sig", "kid": "", "n": "", "e": "", "x": "", "y": "", "crv": "" } ] } とあります ↩︎

サイボウズ 生産性向上チーム 💪

Discussion