CircleCIからもAWS APIへアクセスキーなしでリクエストできる仕組みをTerraformで構築する

こんにちは。

メディアサービス開発部バックエンド開発グループのフサギコ(髙﨑)です。

Ruby on Railsによるバックエンドの実装運用と、AWSによるサービスインフラの設計構築を中心とした、いわゆるテックリードのような立ち位置で働いています。

CircleCIでもOpenID ConnectのIDプロバイダ機能がリリースされた

私が書いた一つ前の記事では、GitHub ActionsとAWS IAMをOpenID Connectを使って連携させ、TypeScriptで記述したアプリケーション(Next.js, Serverless Framework)のデプロイや、TerrafromのリポジトリにPRを出した際の自動planを永続的アクセスキー不要で行っているとお話ししました。

developers.bookwalker.jp

一方で、TypeScriptやTerraform以外のアプリケーション、例えばRuby on RailsやPlay Framework(Scala)で書かれたアプリケーションはCircleCIでCI/CDを行っていたため、まだCircleCIのcontextにIAMユーザのアクセスキーを格納する方法でデプロイを行っていました。

そこに先日、CircleCIでもOpenID ConnectのIDプロバイダ機能がリリースされたとの告知がありました!

それをうけて早速、CircleCIでもOpenID Connectを通じてAWSの一時アクセスキーを都度発行して操作する仕組みをTerraformで構築しましたので、本記事ではその手順についてお話します。

CircleCIのOpenID Connect連携をTerraformで構築する

この節では、AWSをTerraformで管理できるようになっていること、CircleCI configの書き方をある程度理解していることは前提として、OpenID ConnectによってCircleCIがAWS APIを操作できる状態をTerraformで構築する手順について述べます。

OpenID Connect Providerを作成する

下記のようにして、OpenID Connect Provider設定をAWS IAMに作成します。

locals {
  circle_ci_org_id                = "00000000-1111-2222-3333-4444444444444"
  circle_ci_project_id            = "55555555-6666-7777-8888-9999999999999"
}

data "http" "circle_ci_openid_configuration" {
  url = "https://oidc.circleci.com/org/${local.circle_ci_org_id}/.well-known/openid-configuration"
}

data "tls_certificate" "circle_ci" {
  url = jsondecode(data.http.circle_ci_openid_configuration.body).jwks_uri
}

resource "aws_iam_openid_connect_provider" "circle_ci_oidc_provier" {
  url             = "https://oidc.circleci.com/org/${local.circle_ci_org_id}"
  client_id_list  = [local.circle_ci_org_id]
  thumbprint_list = [data.tls_certificate.circle_ci.certificates[0].sha1_fingerprint]
}

local変数として定義しているcircle_ci_org_idはOrganization SettingsのContexts画面にて確認できるOrganization ID、circle_ci_project_idはProject SettingsのOverview画面で確認できるProject IDです。 contextを作成していないとOrganization IDは表示されませんので、適切な名前で作成しましょう。本記事の手順でも後ほど使います。

GitHub Actionsのときは固定値だったaws_iam_openid_connect_providerのclient_id_listがcircle_ci_org_idになっていますが、Terraformのaws_iam_openid_connect_providerリソースのドキュメントによれば、client_id_listにはOIDCのaud claimを指定するとあるため、CircleCIの場合はOIDCについてのドキュメントより、CIrcleCIのOrganization IDを指定すればよいことがわかります。

またGitHub Actionsの記事と内容が重複しますが、OpenID ConnectでAWS IAMへ一時アクセスキーの発行をリクエストする際には、OpenID ConnectのIDプロバイダ、すなわちここではCircleCIが署名したJWTをtokenとして添付します。 ここで作成しているOpenID Connect Provider設定は、そのJWTをAWS IAMが検証する際に用いるものです。

上の二つのデータソースでCircleCIがJWTの署名に用いる鍵の公開鍵を取得し、そのフィンガープリントをOpenID Connect Providerに設定しています。

何らかの理由によってCircleCIがJWTの署名に用いる鍵が変わると、このaws_iam_openid_connect_provider.github_actions_oidc_provierに差分が出ることになります。

OpenID ConnectはJWTの署名とその検証によって成り立つ仕組みのため、公開鍵のフィンガープリントをしっかり確認するのはセキュリティの根幹です。もし確認を怠ると、予期しない署名者を誤って信頼してしまう可能性があるため、これはしっかりやっておく必要があります。

2022/3/30時点でのフィンガープリントは9e99a48a9960b14926bb7f3b02e22da2b0ab7280でした。

IAMロールを作成する

下記のようにして、CircleCIのジョブが借り受けるIAMロールを作成します。

また、CircleCIのジョブで使いますので、aws_iam_role.example_roleのarnをAWS_ROLE_ARNという名前でCircleCI contextの環境変数に格納しておいてください。

resource "aws_iam_role" "example_role" {
  name = "circle_ci_open_id_connect_example_role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:AssumeRoleWithWebIdentity"
      Effect = "Allow"
      Principal = {
        Federated = [aws_iam_openid_connect_provider.circle_ci_oidc_provier.arn]
      }
      Condition = {
        StringLike = {
          "oidc.circleci.com/org/${local.circle_ci_org_id}:sub" = [
            "org/${local.circle_ci_org_id}/project/${local.circle_ci_project_id}/user/*",
          ]
        }
      }
    }]
  })
}

resource "aws_iam_role_policy" "example_role_policy" {
  name   = "example_role_policy"
  role   = aws_iam_role.example_role.id
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Action = "sts:GetCallerIdentity"
      Effect = "Allow"
      Resource = "*"
    }]
  })
}

GitHub ActionsとはOpenID ConnectのJWTの内容が違いますから、当然assume_role_policyのCondition節の条件もそれに合わせて変える必要があります。 CircleCIのOIDCについてのドキュメントによれば、subは"org/<organization-id>/project/<project-id>/user/<user-id>"との構成になっているので、上記のStringLikeでCircleCIのプロジェクト、すなわちGitHubにおけるリポジトリ単位までは制限できることになります。

一方で、gitのrefなどはIDトークンに含まれていませんので、「本番環境にデプロイできるIAMロールはmainブランチから起動されたジョブしかassumeできない」といったような制限の仕方は今のところできなさそうです。そのあたりは今後に期待ですね。

CircleCIのconfigを作成する

下記のようにして、CircleCIのconfigと、CircleCIのOpenID Connect IDトークンから一時アクセスキーを発行するシェルスクリプトを記述します。 シェルスクリプトはpermissionを744にして.circleciディレクトリの中にassume-role-with-oidc.shという名前でcommitしました。

version: 2.1
executors:
  minimal:
    docker:
      - image: cimg/base:stable
    resource_class: small
orbs:
  aws-cli: circleci/aws-cli@2.1.0
jobs:
  assume_role_with_oidc:
    executor: minimal
    steps:
      - checkout
      - aws-cli/install
      - run: |
          ./.circleci/assume-role-with-oidc.sh ${AWS_ROLE_ARN} circle_ci_open_id_connect_example ${CIRCLE_OIDC_TOKEN}
      - run: |
          source ./.circleci/use-temporary-aws-access-key.sh
          aws sts get-caller-identity
workflows:
  version: 2
  test_oidc:
    jobs:
      - assume_role_with_oidc:
          context: oidc-example
#!/usr/bin/env bash

set -euo pipefail

DURATION_SECONDS=$((60*15))

aws_sts_credentials=`aws sts assume-role-with-web-identity \
  --role-arn $1 \
  --role-session-name $2 \
  --web-identity-token $3 \
  --duration-seconds ${DURATION_SECONDS} \
  --query "Credentials" \
  --output "json"`

echo AWS_ACCESS_KEY_ID="$(echo $aws_sts_credentials | jq -r '.AccessKeyId')"

cat <<EOT > "$(dirname $0)/use-temporary-aws-access-key.sh"
export AWS_ACCESS_KEY_ID="$(echo $aws_sts_credentials | jq -r '.AccessKeyId')"
export AWS_SECRET_ACCESS_KEY="$(echo $aws_sts_credentials | jq -r '.SecretAccessKey')"
export AWS_SESSION_TOKEN="$(echo $aws_sts_credentials | jq -r '.SessionToken')"
EOT

参考にさせていただいた記事のシェルスクリプトとの差分は、

  • xオプションを外し、その代わりにアクセスキーIDだけechoするようにした
  • role session nameも外から渡せるようにした

あたりです。

さて、前述のTerraformをapplyしたうえでCircleCI configをGitHubにpushすると、CircleCIは指定したIAMロールの権限を持った有効期限付きのアクセスキーを借り受け、aws cliを通じてstsのget-caller-identityコマンドを実行します。その模様が下記のスクリーンショットです。

aws sts get-caller-identityコマンドの出力のArnのassumed-roleの次に指定したIAMロールのロール名である circle_ci_open_id_connect_example_roleが、その次にrole-session-nameで指定したcircle_ci_open_id_connect_exampleが入っていることから、OpenID Connectを通じて借り受けたアクセスキーを使っていることが確認できます。

このようにして、CircleCIでもOpenID Connectを通じて借り受けたアクセスキーを使ってAWSのAPIを操作できるようになりました。

aws-ecr Orbを使ってECRへpushする

これで永続的アクセスキーが不要になりました、めでたしめでたし…となればよかったのですが、そうなるまでにはもう一つハードルがありました。

本記事の冒頭でCircleCIでCI/CDしているとお話ししましたRuby on RailsやPlay Framework(Scala)で書かれたアプリケーションは、いずれもDockerイメージをECRにpushしてデプロイしています。

その際には、DockerイメージのbuildからECRへのpushを一括して行ってくれるCircleCI公式のaws-ecr Orbを利用しています。そのaws-ecr Orbが提供しているaws-ecr/build-and-push-imageジョブを用いるとECRへのログインからDockerイメージのbuild, pushまで一気通貫で行ってくれるので便利でしたが、そのジョブの内部で使用しているaws-ecr/ecr-loginコマンドがECRへdockerログインを行う際、従来の永続的アクセスキーを受け取ること前提の書き方になっている1ため、OIDCを通じて発行した一時アクセスキーではECRへのログインに失敗する状態になってしまいました。

そのため、下記のようにジョブとしては自前で定義し、途中のECRへのログインをするstepは直接書き、Dockerイメージのbuildとpushはaws-ecr Orbのbuild-imageコマンドとpush-imageコマンドを利用する、という形に落ち着きました。

      - run:
          name: login to ECR
          command: |
            source ./.circleci/use-temporal-aws-access-key.sh
            aws ecr get-login-password | docker login --username AWS --password-stdin $AWS_ECR_ACCOUNT_URL
      - aws-ecr/build-image:
          account-url: AWS_ECR_ACCOUNT_URL
          repo: repo_name
          tag: << parameters.image_tag >>
          extra-build-args: << parameters.extra-build-args >>
      - aws-ecr/push-image:
          account-url: AWS_ECR_ACCOUNT_URL
          repo: repo_name
          tag: << parameters.image_tag >>

このあたりはOpenID Connectがリリース直後すぎてOrbの対応が追い付いていないだけだと思いますので、先々のOrbのアップデートで対応されることに期待したいと思います。

まとめ

本記事では前記事のGitHub Actionsに続いてCircleCIでもOpenID Connectを通じてAWSのAPIをセキュアに操作する仕組みをTerraformで構築する手順と、aws-ecr Orbの7系を用いてAWC ECRへDockerイメージをbuild, pushしようとした際に期待通り動作しなかったこと、その暫定的対処についてお話しました。

これからCI/CDを整備しようというかたの参考になれば幸いです。

ブックウォーカーでは物理・電子・Web連載問わず漫画や本が好き、あるいはテストやデプロイなどの定型業務は自動化に任せ、よりサービスに注力できる状態を作り上げることに興味があるWebアプリケーションエンジニアを募集しています。

興味がありましたらぜひ、採用情報ページからご応募ください。

www.bookwalker.co.jp

参考文献


  1. aws-ecr Orbは最新の8系が先日リリースされており、今回試せたのは7系latestの7.3.0ですが、8系でも同じ書かれ方をしているため、同様にまだ未対応だと思われます