エムスリーエンジニアリンググループ AI・機械学習チームの笹川です。 趣味はバスケと筋トレで、このところはNBAはオフシーズンですが、代わりにユーロバスケが盛り上がっていて、NBAに来ていない良いプレーヤーがたくさんいるんだなーと思いながら見ています。
今回は、パブリッククラウドへの認証に必要な秘密情報をGitLab自体に格納することなく、安全に認証する方法について紹介します。
- CI/CDの実行時のパブリッククラウドに対する認証
- ナイーブな手法とその問題点
- OpenID Connectを用いた認証
- Terraformでパブリッククラウド側の設定を記述する
- GitLab CI/CDで認証する
- まとめ
- We are hiring!
CI/CDの実行時のパブリッククラウドに対する認証
弊社ではコード管理にGitLabを利用し、CI/CDなどは GitLab CI/CD で実行しています。 それらの実装には様々な工夫をしています。
CI/CDの実行の際には、Google Cloudや、AWSなどのパブリッククラウドへの認証/認可が必要なケースがあり、具体的には以下のようなケースがあります。
- Terraformのplanやapply
- ECS, GKEなどへのアプリケーションのリリース
上記のようなケースでは、比較的強い権限が必要なこともあり、認証のために用いる秘密情報の管理はとても重要です。
ナイーブな手法とその問題点
ナイーブな方法として、Google Cloudでいえば、service accountを発行し、そのjson keyを GitLab CI/CD variables に登録して、CI実行時に認証に利用する方法が考えられます。 例えば、Terraformの実行のケースでは、以下のような実装になるでしょう。
plan:dev: stage: plan allow_failure: true before_script: - echo ${GOOGLE_KEY_DEV} > /etc/key.json - export GOOGLE_APPLICATION_CREDENTIALS=/etc/key.json script: - cd terraform/service/service_name/dev - | tfenv install terraform init terraform plan
以下の部分でvariableに登録した GOOGLE_KEY_DEV
を参照し、認証に利用しています。
- echo ${GOOGLE_KEY_DEV} > /etc/key.json
この手法では、パブリッククラウドの外にjson keyなどの秘密情報を取り出して利用することなるため、それが漏洩することは大きな問題に繋がります。 具体的な例として、何かの拍子にログに出力される、組織変更などで権限を持っているべきでない人物が当該情報を見てしまうなどがあります。
定期的に秘密情報をローテーションして被害を減らす方法もありますが、手間がかかる上に、最大でローテーション期間分の時間はその情報を使い放題ということになり、本質的に問題が解決されていません。 また、ローテーション自体を実行できる人も絞るべきであり、特定のroleの人にワークロードが集中しがちになってしまうこともデメリットです。
OpenID Connectを用いた認証
上記の問題を解決するため、GitLabのOpenID Connect (OIDC) 機能を利用し、秘密情報をGitLabに格納せずに認証/認可する仕組みを構築します。
仕組みを構築する際に注意する点として、この仕組みに利用するGitLabのpredefined variableである CI_JOB_JWT_V2
はalpha statusの機能であり、今後のupdateで使えなくなる可能性があるということがあります(このissueが解決されればGAになるとの記載もあり、個人的には楽観視しています)。
すでに弊社の一部のチームで、この仕組みを構築しCI/CDの機能を本番に対しても使っていますが、利用する際は仕様変更などに気をつけてください。
認証時のGitLabとパブリッククラウドのやりとりは以下のようになっています。
具体的なステップとしては、
- 対象のGitLabをOIDC providerとしてクラウド側に登録
- 適切に権限を絞ったroleをクラウド側に用意
- CIジョブが
CI_JOB_JWT_V2
を付与してクラウド側の認証APIに対してリクエストを送る - クラウド側がトークンを検査して、問題なければ、一時的な認証情報がレスポンスとして返される
となっています。
以降では、上記のOIDC認証をGoogle CloudとAmazon Web Services (AWS) それぞれでどのように実現するかについて説明します。
Terraformでパブリッククラウド側の設定を記述する
OIDCの機能を使うため、パブリッククラウド側の設定をTerraformで書くことを考えます。 以下では、Google CloudとAWS それぞれの場合のサンプルを紹介します。
Google Cloudの場合
Google Cloudの場合は、Workload Identity Federation の仕組みを使います。 CI実行の都度、GitLabとGoogle Cloudの間でやりとりをし、特定のservice accountに対応する有効期限の短い認証情報を発行して認証します。 この仕組みを実現するために、以下の3つのresourceを構築する必要があります。
- Workload Identity Pool
- Workload Identity Pool Provider
- Workload Identityユーザーロールを付与したservice account
サンプルコードは以下です。
折りたたんだサンプルコードを表示する
以下のサンプルコードは 公式のサンプル をもとに一部を改変しています。
locals { gitlab_project_id = "xxxx" gitlab_url = "https://gitlab.example.com" project = "xxxxxxxxx" service_account_email = "service_account@xxxxxxxxx.iam.gserviceaccount.com" } # 1. Workload Identity Pool の作成 resource "google_iam_workload_identity_pool" "gitlab_pool" { workload_identity_pool_id = "gitlab" project = local.project } # 2. Workload Identity Pool Provider の作成 resource "google_iam_workload_identity_pool_provider" "gitlab_provider_jwt" { workload_identity_pool_id = google_iam_workload_identity_pool.gitlab_pool.workload_identity_pool_id workload_identity_pool_provider_id = "gitlab-jwt" project = local.project attribute_mapping = { "google.subject" = "assertion.sub", "attribute.aud" = "assertion.aud", "attribute.project_path" = "assertion.project_path", "attribute.project_id" = "assertion.project_id", "attribute.group_id" = "assertion.project_path.startsWith('group_a/') ? 'group_a' : ''" "attribute.ref" = "assertion.ref", } attribute_condition = oidc { issuer_uri = local.gitlab_url allowed_audiences = [local.gitlab_url] } } # 3. service accountへの Workload Identityユーザロールの付与 resource "google_service_account_iam_member" "gitlab_runner_oidc" { service_account_id = "projects/${local.project}/serviceAccounts/${local.service_account_email}" role = "roles/iam.workloadIdentityUser" # GitLab上の特定のプロジェクトからのみ利用可能なように絞っている member = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.gitlab_pool.name}/attribute.project_id/${local.gitlab_project_id}" }
上記コードを見ると、面白い点が2つあります。
まず最初に、Workload Identity Pool Providerの設定の中のattribute_mappingというブロックです。 ここではGitLabのJWTに含まれる属性と、Googleトークン属性のマッピングを設定します。 GitLabのJWTに含まれる属性は以下のドキュメントに示されています。
属性のマッピングには Common Expression Language (CEL) が利用できるので、 これを用いてカスタム属性を作ることができます。 上記の例では、以下の部分でその機能を使っています。
# group_a配下のプロジェクトであれば "group_a"、それ以外であれば空になる "attribute.group_id" = "assertion.project_path.startsWith('group_a/') ? 'group_a' : ''"
GitLabのJWT にgroup_idという属性はありませんが、project_pathの属性を利用し、 CELを使って「project_pathがgroup_a/で始まっている場合は、group_idをgroup_aとし、それ以外を空にする」という属性を作っています。 ここで設定したマッピングを使って、そのWorkload Identity Poolを利用できるかどうかを制限することもできます。詳細は以下のドキュメントをご覧ください。
2つ目に、service accountについて、特定の条件の下でしか認証情報を発行させないように制限している点です。 これは、以下の部分が該当します。このservice accountはGitLabの属性の中のproject_idを使って制限を実施しており、特定のプロジェクトのCIからの認証要求以外では認証できないようにしています。
# GitLab上の特定のプロジェクトからのみ利用可能なように絞っている member = "principalSet://iam.googleapis.com/${google_iam_workload_identity_pool.gitlab_pool.name}/attribute.project_id/${local.gitlab_project_id}"
上記はもちろん他の属性を使って制限をかけることもできるので、例えば、「本番プロジェクトのservice accountは、mainブランチからの認証要求でしか認証させない」などの制限で、より厳密な権限分割を実現できます。
AWSの場合
AWSのケースもGoogle Cloudとほぼ同様に実現できます。 サンプルコードは以下です。
折りたたんだサンプルコードを表示する
以下のサンプルコードは 公式のサンプルコード をもとに一部を改変しています。
locals { gitlab_project_id = "xxxx" gitlab_url = "https://gitlab.example.com" aud_value = "https://gitlab.example.com" match_field = "sub" match_value = ["project_path:group_a/*:ref_type:branch:ref:main"] assume_role_arn = ["arn:aws:iam::9999:policy/name_of_policy"] } data "tls_certificate" "gitlab" { url = local.gitlab_url } # 1. GitLabをOIDC providerとして登録 resource "aws_iam_openid_connect_provider" "gitlab" { url = local.gitlab_url client_id_list = [local.aud_value] thumbprint_list = ["${data.tls_certificate.gitlab.certificates.0.sha1_fingerprint}"] } # 2. roleのポリシーを作成 data "aws_iam_policy_document" "assume-role-policy" { statement { actions = ["sts:AssumeRoleWithWebIdentity"] principals { type = "Federated" identifiers = [aws_iam_openid_connect_provider.gitlab.arn] } condition { test = "StringEquals" variable = "${aws_iam_openid_connect_provider.gitlab.url}:${local.match_field}" values = local.match_value } } } # 3. ポリシーをロールに付与 resource "aws_iam_role" "gitlab_ci" { name_prefix = "GitLabCI" assume_role_policy = data.aws_iam_policy_document.assume-role-policy.json managed_policy_arns = local.assume_role_arn }
AWSの場合でも、Google Cloudのケースと同様の制限をかけることができます。
以下の部分では、GitLabの属性情報をもとにassume roleが可能な認証要求の条件を規定しています。
具体的には、match_valueで指定された "project_path:group_a/*:ref_type:branch:ref:main"
に制限しており、
group_a
配下のプロジェクトのmainブランチからキックされたCIジョブの認証要求のみに絞ってassume roleが可能になっています。
condition { test = "StringEquals" variable = "${aws_iam_openid_connect_provider.gitlab.url}:${local.match_field}" values = local.match_value }
GitLab CI/CDで認証する
ここまでで、パブリッククラウド側の準備ができたので、次はGitLab側のジョブ実行時に認証する方法について説明します。
Google Cloudの場合
事前に以下の情報をGitLab CI/CD varibalesなどに登録しておきます。
- WORKLOAD_IDENTITY_PROVIDER: workload identity providerのID
- SERVICE_ACCOUNT_EMAIL: service accountのemail
これらの情報は秘密情報ではなく、仮にこれらが流出してもこの情報だけでは認証することはできないので、GitLabへの登録は問題ありません (もちろんみだりに社外などに公開する必要はありません)。 それぞれをTerraformの設定の中でoutputしておくとapply時に出力されるので、コピペする時に便利です。
.gitlab-ci.ymlの中での認証ステップの実装は以下のとおりです。
auth: before_script: - echo ${CI_JOB_JWT_V2} > .ci_job_jwt_file - gcloud iam workload-identity-pools create-cred-config ${WORKLOAD_IDENTITY_PROVIDER} --service-account="${SERVICE_ACCOUNT_EMAIL}" --output-file=.gcp_temp_cred.json --credential-source-file=.ci_job_jwt_file - export GOOGLE_APPLICATION_CREDENTIALS=`pwd`/.gcp_temp_cred.json - gcloud auth login --cred-file="${GOOGLE_APPLICATION_CREDENTIALS}" script: - do something
gcloud iam workload-identity-pools コマンドに、先に定義したworkload identity pool provider、service accountの情報と、
predefined varibaleであるCI_JOB_JWT_V2
を渡して、認証情報を取得しています。
次に、作成された認証情報を利用してgcloud auth login コマンドで認証しています。
GOOGLE_APPLICATION_CRENDENTIALS
変数へのexportは、これをデフォルトで参照するツールが多いので、やっておくと後続の処理で楽をできたりします。
AWSの場合
AWSの場合は、事前に以下の情報を登録しておきます。
- ROLE_ARN: assume roleするroleのarn
こちらもoutputしておくと便利です。
.gitlab-ci.ymlの実装は以下のようになります。
auth: before_script: - STS=($(aws sts assume-role-with-web-identity --role-arn ${ROLE_ARN} --role-session-name "GitLabRunner-${CI_PROJECT_ID}-${CI_PIPELINE_ID}" --web-identity-token "${CI_JOB_JWT_V2}" --query 'Credentials.[AccessKeyId,SecretAccessKey,SessionToken]' --output text)) - export AWS_ACCESS_KEY_ID="${STS[0]}" - export AWS_SECRET_ACCESS_KEY="${STS[1]}" - export AWS_SESSION_TOKEN="${STS[2]}" script: - do something
上記では、aws sts assume-role-with-web-identity コマンドに、先に定義したrole arnと、CI_JOB_JWT_V2
を渡して、認証情報を取得しています。
--role-session-name
オプションにCI_PROJECT_ID
, CI_PIPELINE_ID
を渡しているのは、caller identityのarnを一意にするためです(これが重複するとエラーとなり認証できない)。
認証ステップの共通化
筆者が所属するAI・機械学習チームでは、上記のbefore_script部分をGitLabのinclude機能 を用いて共通化し、それぞれのプロジェクトで利用しています。 認証部分を必要な変数を定義するだけで簡単に利用でき、GitLabなどのアップデートで修正が必要になったケースでも多くの場合は共通化した処理のみの修正で済むという効果もあります。 include機能の詳細は以下の記事をご覧ください。
まとめ
今回はGitLab上でCI/CDを実行する際に、パブリッククラウドの認証情報をGitLabに格納することなく、安全に認証する方法について紹介しました。 パブリッククラウド側の設定についてもコード化されたことで手間も少なく、安心してCIを実行できるようになりました。
We are hiring!
エムスリーでは、セキュアでイケてるCI/CDを構築することに興味があるエンジニアを募集中です。
社内外問わず、システムの開発や施策の実施により世の中にインパクトを与える機会が多数ありますので、是非我こそは!という方はカジュアル面談、ご応募お待ちしています!