エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

秘密情報をGitLabに格納することなくGoogle Cloud / AWSに対して認証する

エムスリーエンジニアリンググループ AI・機械学習チームの笹川です。 趣味はバスケと筋トレで、このところはNBAはオフシーズンですが、代わりにユーロバスケが盛り上がっていて、NBAに来ていない良いプレーヤーがたくさんいるんだなーと思いながら見ています。

夜ご飯を催促するためデスク横で待機する犬氏(かわいい)

今回は、パブリッククラウドへの認証に必要な秘密情報をGitLab自体に格納することなく、安全に認証する方法について紹介します。

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_V2alpha statusの機能であり、今後のupdateで使えなくなる可能性があるということがあります(このissueが解決されればGAになるとの記載もあり、個人的には楽観視しています)。 すでに弊社の一部のチームで、この仕組みを構築しCI/CDの機能を本番に対しても使っていますが、利用する際は仕様変更などに気をつけてください。

認証時のGitLabとパブリッククラウドのやりとりは以下のようになっています。

認証時のGitLabとパブリッククラウドのやりとり(公式ドキュメントより引用)

具体的なステップとしては、

  1. 対象のGitLabをOIDC providerとしてクラウド側に登録
  2. 適切に権限を絞ったroleをクラウド側に用意
  3. CIジョブが CI_JOB_JWT_V2 を付与してクラウド側の認証APIに対してリクエストを送る
  4. クラウド側がトークンを検査して、問題なければ、一時的な認証情報がレスポンスとして返される

となっています。

以降では、上記の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を構築する必要があります。

  1. Workload Identity Pool
  2. Workload Identity Pool Provider
  3. 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に含まれる属性は以下のドキュメントに示されています。

docs.gitlab.com

属性のマッピングには 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を利用できるかどうかを制限することもできます。詳細は以下のドキュメントをご覧ください。

cloud.google.com

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などに登録しておきます。

これらの情報は秘密情報ではなく、仮にこれらが流出してもこの情報だけでは認証することはできないので、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の場合は、事前に以下の情報を登録しておきます。

こちらも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機能の詳細は以下の記事をご覧ください。

www.m3tech.blog

まとめ

今回はGitLab上でCI/CDを実行する際に、パブリッククラウドの認証情報をGitLabに格納することなく、安全に認証する方法について紹介しました。 パブリッククラウド側の設定についてもコード化されたことで手間も少なく、安心してCIを実行できるようになりました。

We are hiring!

エムスリーでは、セキュアでイケてるCI/CDを構築することに興味があるエンジニアを募集中です。

社内外問わず、システムの開発や施策の実施により世の中にインパクトを与える機会が多数ありますので、是非我こそは!という方はカジュアル面談、ご応募お待ちしています!

jobs.m3.com