【解説編】CircleCIからOIDCを用いて安全にGoogle Cloudにアクセスする

SREチーム(新卒)の市川恭佑です。これはカヤックSRE連載の2月号です。 よく見ると投稿日が3月になっていますが、どちらかと言うと2月が28日までしかない方に問題があるので、大丈夫です。(何が?)

ということで、2023年も滑り出し好調のカヤックSRE連載ですが、前回の記事ではCircleCIからGoogle CloudにOIDCでアクセスする方法について、 ちゃんと動く(はずの)ソースコードをサクッと紹介いたしました。

techblog.kayac.com

さて、Google CloudとCircleCIをお使いの皆様、もうOIDC対応は完了しましたか?

安心してください。私のプロジェクトでも一部未完遂です。(おい)

ということで今回は、前回紹介したソースコードを深掘りして解説します。 私と同じように、途中でなんか面倒になって一旦塩漬けにしたら正直忘れかけてる長い道のりの途中にいる皆様の、OIDC対応が完遂へと導かれることを願って____。

概念の解説

まず、ソースコードの裏にある、諸種の概念について解説をします。知っているところは適宜読み飛ばしてください。

OpenID Connect 1.0 (OIDC)とは

まず、認証(Authentication)と認可(Authorization)の違いは前提知識としたいところですが、喩えるなら、認証は「どなた?ああ......市川さんね」といった調子で、認可は「市川さんって今日の飲み会に出席する権利ある?ないよね。帰ってくれない?」です。ああ市川さん、可哀想に......。

閑話休題、「そもそもOIDCって何だっけ?」という疑問への最もシンプルな回答は「OAuth 2.0という認可の仕様に対して、ID Tokenによる認証機能を追加したもの」です。

なお、世の中には “OAuth認証” を名乗る機構が出回っていますが、OAuth 1.0a/2.0は本質的に認可の仕様です“OAuth認証” と呼ばれているものは、(誤植でなければ)OAuth 1.0a/2.0の実装を独自に拡張して認証機能を追加したものを指します1

これに注意すると、OIDCの一番大事なところは認証に用いるID Tokenの発行だと分かります。

では、ID Tokenとは何でしょうか?答えは「JSONを署名した後に暗号化したもの」です。 署名や暗号化に際してBase64URLへのデコード処理と、デコード済みの文字列をドットで繋ぐ処理が付随します。 これはJSON Web Token (JWT)の一種ですが、JWTにも色々あるので気になる方はぜひDeep Diveしてみてください2

そして、暗号化や署名をする前のJSONにはエンドユーザーの認証に関する情報が含まれます。 OIDCの仕様ではJSONに含まれる個々のKey-Valueの対を(JWTに準じて)Claimと呼びますが、最も大切なClaimはsubです。subは"subject"の略で、エンドユーザーの一意の識別子を指します。つまり「誰がログインしたか」という情報で、ID TokenのIDたる部分です。

実はOIDC自体が一枚岩ではなく、複数の構成要素から成り立つ大きな規格なのですが、中心的な仕様はOpenID Connect Core 1.0にまとまっています。他にも様々な構成要素の仕様を公式ページから辿れて楽しいです。

OIDCを用いてCircleCIからGoogle Cloudにアクセスする仕組み

OIDCの概念が分かったところで、次に知りたいのは「実際に本ブログの目的を達成するために必要な要素は何か」です。 これを考える上で、Google Cloudから提供されている以下の動画が大変参考になります。

www.youtube.com

しかし、本ブログにおいてCircleCIとなる部分が、動画では"On-prem / other cloud applications"として抽象化されています3

そこで、上記の動画に出てきた内容を日本語に訳すとともに、CircleCIに合わせた形で以下の図に整理しました。 とにかく明快な図なので(自画自賛)、何はともあれ図をご覧ください。

いかがでしたか?......と言ってこの章を締めてしまいたい衝動に駆られていますが、それでは不親切が過ぎるので以下に補足します。

OpenID Providerについて

OpenID Providerという用語の意味は、OpenID Connect Core 1.0の仕様にある定義のとおりです。簡単に言うと「ID Tokenの提供者」です。

CircleCIのOpenID Providerは組織ごとに存在して、URLはhttps://oidc.circleci.com/org/${組織ID}という形式を取ります。 このOpenID Providerが、(Contextを利用している場合)ジョブの実行ごとにID Tokenを発行し、$CIRCLECI_OIDC_TOKENという名前の環境変数で渡します。

Workload Identity Poolについて

そもそも、章の冒頭で挙げた動画はWorkload Identity Federationの解説でした。 Federationは日本語では「連携」と訳されますが、いずれにしても、これは実体のリソースではなく、概念であることに注意が必要です。

Workload Identity Federationという概念を達成するための要となるリソースがWorkload Identity Poolです。 Poolという単語から連想できるように、これは外部サービスから提供されるIDを溜めておくところです。

実際にWorkload Identity Poolが外部サービスのIDを受け入れるためには、IDの提供元を"Provider"として登録する必要があります。 この登録情報がWorkload Identity Pool Providerというリソースです。 今回の文脈では、CircleCIのOpenID Providerがこれに該当します。

Workload Identity Poolに蓄積されたIDとして認証するためには、Security Token Service(STS)にID Tokenなどの情報を添えてリクエストする必要があります。 これによってアクセストークンを得ることができますが、初期状態では特に何もできません。 Google Cloudのリソースにアクセスするためには適切な権限を与える必要がありますが、そのために「Service Accountの権限借用」というアクションを取ります4

Service Accountの権限借用について

まず、Service Accountに対する「権限借用」の詳細はこちらにあります。 AWSで言うところのAssumeRoleのようなもので、対象のServiceAccountの権限を引き継ぐことができます。

ちなみに英語では"impersonation"(直訳: なりすまし)と表記されていますが、本質的には対象のService Accountになりすまして、有効期限付きのアクセストークンを発行します。 このアクセストークンこそが、最終的にGoogle Cloudリソースにアクセスする際に使われる情報です。

なお、権限借用自体にもIAMによる許可が必要です。 これは、借用対象となるService Accountへのroles/iam.workloadIdentityUserという事前定義ロールを「特定のIDまたはその集合」に対して付与することで簡単に実現可能です。 「特定のIDまたはその集合」の指定方法および権限借用に関する詳細については、公式ドキュメントをご覧ください。

サンプルコード解説

概念の解説が済んだところで、サンプルコードの解説に入ります。

概念について理解のある状態だと、いくらか読みやすいと思います。

Terraformのサンプルコード解説

Terraformのサンプルコードは情報量の偏りがないので、前回の記事で紹介したものと完全に同じ内容を畳み込み形式で再掲します。

サンプルコード全体

locals {
  circleci_oidc_domain = "oidc.circleci.com"

  # 下準備でメモしたIDを埋めてください
  circleci_organization_id = "AAAA1234-BB56-CC78-DD90-EEEEEE123456"
  circleci_project_id      = "FFFF7890-GG12-HH34-II56-JJJJJJ789012"
}

resource "google_iam_workload_identity_pool" "circleci" {
  provider                  = google-beta
  workload_identity_pool_id = "circleci"
}

# fooという名前はプロジェクト名(GitHubと連携している場合はリポジトリ名と同じ)を想定しています。
# このままでも動作しますが、適宜書き換えてください。
resource "google_iam_workload_identity_pool_provider" "circleci_foo_project" {
  provider                           = google-beta
  workload_identity_pool_id          = google_iam_workload_identity_pool.circleci.workload_identity_pool_id
  workload_identity_pool_provider_id = "foo-project"

  attribute_mapping = {
    "google.subject"       = "assertion.sub"
    "attribute.project_id" = "assertion['oidc.circleci.com/project-id']"
  }

  attribute_condition = "attribute.project_id==\"${local.circleci_project_id}\""

  oidc {
    allowed_audiences = [local.circleci_organization_id]
    issuer_uri        = "https://oidc.circleci.com/org/${local.circleci_organization_id}"
  }
}

# 既存のService Account情報を取得します。
# emailの "@" より前の部分が "deploy" という名前になっていることを仮定した記述になっています。
# 実際に利用するService Accountに合わせてください。
# もし「CircleCIパイプラインから利用したい権限」を持っているService Accountが存在しない場合は、作成してください。
data "google_service_account" "deploy" {
  account_id = "deploy"
}

resource "google_service_account_iam_member" "circleci_impersonate_deploy" {
  service_account_id = data.google_service_account.deploy.name
  role               = "roles/iam.workloadIdentityUser"
  member             = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.circleci.name}/attribute.project_id/${local.circleci_project_id}"
}

属性マッピング(Attribute Mapping)について

Terraformのソースコードで最も注目すべきはWorkload Identity Pool Providerのattribute_mappingです5。 これは、外部から提供されたID Tokenの属性(OIDC文脈ではClaim)を、Google Cloud内部で取り扱う属性に変換するための設定です。 最も詳細な仕様はAPI Referenceにありますが、以下にサンプルコードを抜粋し、今回のケースにおける用途について紹介します。

  attribute_mapping = {
    "google.subject"       = "assertion.sub"
    "attribute.project_id" = "assertion['oidc.circleci.com/project-id']"
  }

まず、右辺に共通するassertionキーワードは、CircleCIが発行したID Tokenの各属性にアクセスするための接頭辞です。 ID Tokenの属性(=Claim)は、CircleCI側のドキュメントに列挙されているとおりです。

また、左辺に見られるgoogleキーワードはGoogle Cloudが規定・要求する属性を表し、attributeキーワードはユーザーが任意に設定できる属性を表します。 これらを踏まえて、個々のKey-Valueを見ていきましょう。

google.subjectはGoogle CloudがIDとして認識する属性です。これに対して、CircleCI側のsubというClaimはorg/${組織ID}/project/${プロジェクトID}/user/${ユーザーID}という形式の一意な識別子です。OIDCの仕様を鑑みると当然のことですが、これらを対応させることでGoogle Cloudは正しくIDを認識できます。google.subjectは127文字以下という制限がありますが、どうやらピッタリになるようです。不思議ですね6

attribute.project_idという属性に対応させるのは、CircleCI側のoidc.circleci.com/project-idというClaimで提供される付随情報としての純粋なプロジェクトIDです。 これは単純に使い勝手と好みの問題なので、設定しなくても同様のことは実現可能です。 ただし、その場合はサンプルコードを適切に変更する必要性が発生するので、頑張ってください。

属性条件(Attribute Condition)について

属性条件は、Workload Identity Poolでの受け入れを制限するための設定です。 具体的には、属性マッピングによって得られた情報を、Common Expression Language (CEL)によって評価した結果がtrueであるときのみ受け入れます。

ピンとこなかったら、やはりコードを読むのが早いです。

  attribute_condition = "attribute.project_id==\"${local.circleci_project_id}\""

上記は、属性マッピングで取得したattribute.project_idについて、意図したプロジェクトIDと一致しているかどうかを評価しています。 つまり、同じCircleCI組織でも、別のプロジェクトで実行されているJobの場合はWorkload Identity Poolで受け入れない(認証を却下する)ことになります。

ごく自然な制約ですが、「フロントエンドとバックエンドでプロジェクトが別れていて、そのどちらからもアクセスしたい」といったこともあるでしょう。 そういった場合は以下のように変更してください。

  attribute_condition = "attribute.project_id==\"${local.circleci_frontend_project_id}\" || attribute.project_id==\"${local.circleci_backend_project_id}\""

また、この設定は必須ではないので、状況によっては削除しても良いでしょう。

権限借用の許可について

以下の部分は権限借用の許可を担っています。

resource "google_service_account_iam_member" "circleci_impersonate_deploy" {
  service_account_id = data.google_service_account.deploy.name
  role               = "roles/iam.workloadIdentityUser"
  member             = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.circleci.name}/attribute.project_id/${local.circleci_project_id}"
}

Google CloudのIAMについて基本的な理解がある方でも、memberについてのみ引っ掛かるかもしれません。 これは、「概念の解説」の章でも軽く触れましたが、Workload Identity Federation特有の記法です。

詳しい説明は公式ドキュメントに譲るとして、今回のmember設定は「CircleCI用のWorkload Identity Poolが受け入れたIDのうち、project_idという属性が意図した値になっているもの」です。

なお、project_idの絞り込みを取り除いて、以下のようにしても動作します。

  member             = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.circleci.name}/*"

そもそも先ほど紹介した属性条件でもプロジェクトIDを指定しているので、もとのサンプルコードは多少冗長です。 しかし、例えば「別のCircleCIプロジェクトからもアクセスしたい」という要望が発生したときに、Workload Identity Poolを同居させるならともかく、同一のService Accountの権限を借用させるのは健全でないケースが多いです。 ゆえに、もし冗長な記述を好まない場合は属性条件の記述を削除した方が良いでしょう。

CircleCIのサンプルコード解説

Terraformとは打って変わって、CircleCIのサンプルコードは(YAMLということもあって)情報量に偏りがあるので、中核となるスクリプトの部分だけ抜き出します。 フルサイズは前回の記事をご参照ください。(もちろん無料です)

また、そのスクリプト部分について、<< parameters.~~~ >>となっている部分は、それぞれのParameterをデフォルト値として変換します。 これは、読みやすさのための試みであり、 Parameter宣言の詳細については公式ドキュメントをご覧ください。

echo $CIRCLE_OIDC_TOKEN > /home/circleci/oidc_token.json
gcloud iam workload-identity-pools create-cred-config \
    "projects/${GOOGLE_PROJECT_NUMBER}/locations/global/workloadIdentityPools/${GOOGLE_WIP_ID}/providers/${GOOGLE_WIP_PROVIDER_ID}" \
    --output-file="/home/circleci/gcp_cred_config.json" \
    --service-account="${GOOGLE_SERVICE_ACCOUNT_EMAIL}" \
    --credential-source-file=/home/circleci/oidc_token.json
echo "export GOOGLE_APPLICATION_CREDENTIALS='/home/circleci/gcp_cred_config.json'" | tee -a "$BASH_ENV"
gcloud auth login --brief --cred-file "/home/circleci/gcp_cred_config.json"

ポイントは2行目のgcloud iamから始まるコマンドです。

このコマンドはローカルにCredentials Configuration Fileなるものを生成します。 生成されたファイルは秘匿情報ではありませんが、--credential-source-file=と指定した先にある情報、つまり$CIRCLECI_OIDC_TOKENは秘匿情報です。 扱いには注意してください。

そして、当該コマンドによって生成されたCredentials Configuration Fileを$GOOGLE_APPLICATION_CREDENTIALSという環境変数に指定することで、Google Cloud SDKを利用するアプリケーション(Terraformなど)は自動で一時的なアクセストークンを取得してくれるようになります。便利ですね。

3つ目のコマンドはまさに$GOOGLE_APPLICATION_CREDENTIALS環境変数を後続ステップで読み込むための記述です。 ただ、わざわざBashに依存する書き方をする必要もないので、別の方法で後続ステップに環境変数を適切に渡してあげられる場合、それで十分です7

最後にgcloud auth loginコマンドを叩いているのは、後続のステップでgcloudコマンド群(gsutilbqを含む)によるアクセスも可能にするためです。 $GOOGLE_APPLICATION_CREDENTIALS環境変数を用いてApplication Default Credentialsを設定していても、gcloudコマンド群は(SDKとは違って)これを拾わないので、明示的に認証情報を指定してログインさせる必要があります。 もちろん、後続ステップでgcloudコマンド群を使う予定がない場合はgcloud auth loginを削って構いません。

また、そもそもgcloudを入れずにCI実行時間を削減したい場合は以下の2つのHTTPリクエストを再現できるように頑張ってください。

  1. STS APIのtokenメソッドで認証をリクエスト
  2. Service Account Credentials APIのgenerateAccessTokenメソッドで借用をリクエスト

"有効期限"って、いつですか?

「えっ......ここまで長々と解説してきて、まだ言うことあるんかい......」という読者の方々の心の声が聞こえてきます。 すみません、大事な話です。

OIDC連携に対応してもなお、今後もしCircleCIで同様のインシデントが発生した場合、「対応作業が完全に不要」ということにはなりません

なぜかというと、有効期限付きのアクセストークンは有効期限内であれば利用できるからです!

......トートロジーですね。でも、確かに見落としがちな穴です。

有効期限付きのアクセストークンだから安全......とは言いますが、いざ「その"有効期限"って、具体的にどのくらいの長さなんですか?」と聞かれると、答えに詰まる方も少なくないと思います。

"有効期限"に関する仕様の確認

理屈を詰めていきましょう。まず、CircleCIの公式ドキュメントによれば、ID Tokenの有効期限は発行から1時間後です。 時刻情報としてID TokenのexpというClaimに記載されます。 なお、発行のタイミングとは、Contextを利用している各ジョブの発火です。

次に、STS APIのtokenメソッドにて(Workload Identity Poolへの確認を経て)発行されるアクセストークンの期限は、挙動を見ている限りID Tokenのexp Claimに記載された時刻情報と一致するようです8。 つまり、ID Tokenの発行から1時間後です。

最後にService Account Credentials APIがgenerateAccessTokenメソッドにて発行される権限借用済みのアクセストークンの期限はデフォルト1時間です......が、なんと最大12時間を指定可能です。

漏洩時のリスク範囲

もしCircleCのID Tokenが(サービス起因またはユーザーの実行したスクリプト起因で)漏洩した場合、最大で約13時間は高いリスクに晒されることが分かりました。 また、漏洩する可能性があるものはID Tokenだけではありません。STS APIが払い出すアクセストークンが漏洩した場合も、ほとんど等しいリスクがあります。

用意周到なハッカーにとって、約13時間という"有効期限"は、悪さの限りを尽くすのに十分すぎる時間とも言えます。 しかも、多くのセキュリティインシデントは、そもそも発生したという事実が判明するまでに短くない時間を要するものです。

さらに被せるなら、権限借用済みのアクセストークンが漏れただけであっても、被害が小さな範囲に限られているとは言い切れません。

たとえば、借用対象のService AccountがIAMに関する強い権限を持っていた場合には、別の認証を作成されて被害が拡大する可能性があります。 それ以外にも、Cloud LoggingやCloud Storageのデータが窃取されることによる二次被害なども考慮しなければいけません。

「OIDC連携に対応したから安全だよね!」と言って安心しきっている状態は、あまり安全ではありません。

さらなる対策

驚かすようなことを言ってしまいましたが、事実は事実です。 取りうる対策を実施する以外にありません。

たとえば、最大で12時間にもなるService Accountのアクセストークンの有効期限は、Google CloudのOrganization Policyによって制限できます。 当然これだけで安心できるものではないですが、長いプロセスを実行する予定がなければ検討するのも悪くないでしょう。

セキュリティ対策は多面的に取り組む必要があり、完全性を見出すことは極めて困難ですが、だからこそ事後対応への備えも重要です。 先ほど行ったような、インシデント発生時のリスク範囲を特定する試みにも大変な意義があります。 緊急メンテナンスや監査ログの分析など、より実践的な訓練を通して段取りを確認しておくのも良いでしょう。

まとめ

今回は、SRE連載1月号で紹介したソースコードを深掘りしました。

実のところ、この記事は「1月号を書いてみたら長すぎたので後半を分割した」という経緯で生まれたので、ベースの文章は1月に書いたものなのですが、 時間が経つと自分でもビックリするほど忘れているものですね。

言葉にして、永続化することの意義を痛感します。あと、ちゃんとOIDC対応を完遂したいと思います。

カヤックではIdentityをいい感じにFederationしたいエンジニアも募集しています

hubspot.kayac.com


  1. Twitterがサードパーティ製アプリを禁止する動きを見せていますが、これによってOAuth 1.0aや “OAuth認証” の登場するシーンは大きく減るかもしれません。
  2. RFCを読んでいると、何か高尚なことをしている気分になれるのでオススメです。
  3. 動画の内容からして当然のことで、Google Cloudに非があるという意味ではございません。
  4. 他の方法でも権限を与えられるような気はしていますが、公式ドキュメントを含めて解説や事例が一切見当たらないので検証していません。
  5. 執筆時点では、CircleCIの公式ドキュメントで、属性マッピングの設定値についての記載の誤りがあります。ゆえに、この部分については注意が必要と言えます。
  6. OpenID Connect Core 1.0の仕様でsubについて"MUST NOT exceed 255 ASCII characters in length"との記述がありますが、2で割った値に揃えられているのは不思議です。
  7. $BASH_ENVはインタラクティブモードでは自動で読み込まれません。もしCI設定がうまく行かなくても'Rerun job with SSH'を実行して「環境変数が渡っていないじゃないか!」と怒らないように注意してください。
  8. ID Tokenの改竄が可能な状況であれば、48時間に限りなく近い期限を指定できますが、これは電子署名に必要な情報が漏洩しているか、署名アルゴリズムに対する攻撃が成功している状態なので、いずれにしても次元の異なるインシデントです。