理系学生日記

おまえはいつまで学生気分なのか

CodePipeline から別アカウントの ECR ソースを参照する

CodePipeline から別アカウント上の ECR を Source Action から指定したい。 これはそれなりに存在するユースケースではないでしょうか。

この実現にマジで苦しんだので、葛藤の記録を残しておきます。

概観

ポイントとなるのは以下の 2 点でした。

  1. CodePipeline から ECR へのアクセスはクロスアカウントアクセス。このため、CodePipeline は ECR source action 実行時に assume-role が必要
  2. CodePipeline で生成されるアーティファクトは S3 バケット(Artifact Store)上で暗号化される

結果として、以下の対応が必要になります。

  1. Account A 上の ECR のバケットポリシーで Account B からのアクセスを許可
  2. Account B 上の CodePipeline から、Account A で定義したクロスアカウント用 Role を assume-role するよう設定
  3. Account A 上でクロスアカウントアクセス用の Role を定義。以下のポリシーを付与する必要がある。
    • Account A の ECR からイメージを PULL できる
    • Account B 上の Artifact Store を読み書きできる
    • Account B 上の CMK を利用できる

バケットポリシー

まず、ECR のバケットポリシーは以下のようなイメージになります。これにより、アカウント B からイメージの PULL を実行できるようになります。

data "aws_iam_policy_document" "cross_account_pull_policy" {
  statement {
    actions = [
      "ecr:GetDownloadUrlForLayer",
      "ecr:BatchCheckLayerAvailability",
      "ecr:BatchGetImage"
    ]
    principals {
      type        = "AWS"
      identifiers = ["arn:aws:iam::[AWSアカウントBのID]:root"]
    }
  }
}

CodePipelie からクロスアカウントアクセス用の Role を引き受ける

わかりにくいのは 3. の CodePipeline からの assume-role の指定方法です。

実は CodePipeline の各 action では role_arn が指定可能です。 ここでクロスアカウント用の Role を指定することにより、CodePipeline が一時的に別アカウントの Role を引き受けることができます。

resource "aws_codepipeline" "this" {
  name     = var.name
  role_arn = aws_iam_role.codepipeline.arn

  (snip)

  stage {
    name = "Source"

   # デプロイ対象のコンテナイメージをECRから読み取る
    action {
      name             = "ContainerImageDefinition"
      category         = "Source"
      owner            = "AWS"
      provider         = "ECR"
      version          = "1"
      output_artifacts = [local.image_artifacts_name]
      run_order        = 2
      role_arn         = var.cross_account_codepipeline_access_role_arn # クロスアカウントアクセス用 Role の ARN を指定

      configuration = {
        RepositoryName = var.repository_name
        ImageTag       = var.image_tag
      }
    }

Account A 上でのクロスアカウントアクセス用ロールの定義

では、CodePipeline が引き受ける、Account A 上の Role はどのようなものでしょうか。 以下は当然の要件です。

  • Account B を Principal に指定している
  • Account A 上の ECR から Image Pull できるポリシーを付与

わかりづらいのは以下の 2 点です。

  • Account B 上の Artifact Store へ読み書きできるポリシーを付与
  • Account B 上の CMK の利用を可能にするポリシーを付与

Artifact Store へ読み書きするポリシーが必要なのは、CodePipeline が ECR の情報を書き込まないといけないためです。CodePipeline は各 Action の出力(Artifact)を S3 Bucket (Artifact Store)経由でやりとりします。

当然 ECR の情報も CodePipeline が Artifact Store へ書き込むことになります。 CodePipeline は Account A の Role を引き受けているため、この Role 自身が Account B の Artifact Store への書き込み権限を必要とします。

さらに話をややこしくするのは、CodePipeline は Artifact Store へ書き込むデータを暗号化することです。

CodePipeline 内のデータは、サービス所有の KMS キーを使用して保管時に暗号化されます。コードアーティファクトはカスタマー所有の S3 バケットに保存され、デフォルトの AWS マネージド SSE-KMS 暗号化キーまたはカスタマーマネージド SSE-KMS キーで暗号化されます。

https://docs.aws.amazon.com/ja_jp/codepipeline/latest/userguide/data-protection.html#encryption-at-rest

多くの場合、S3 bucket の暗号化は S3 所有の KMS キーで暗号化します。しかし、Account A は Account B の KMS キーを知り得ません。 そのため、Account B 上で CMK[^1] を明示的に作成し、Artifact Store に設定し、その CMK を Account A で利用可能にする必要があります。

^1: Customer Managed Key

Artifact Store への CMK 設定は、aws_codepipeline リソースの artifact_store block で行えます。

resource "aws_codepipeline" "this" {
  name     = var.name
  role_arn = aws_iam_role.codepipeline.arn

  artifact_store {
    type     = "S3"
    location = aws_s3_bucket.artifact_store.bucket
    # CMK の明示的な設定
    encryption_key {
      id   = var.artifact_store_encryption_key_arn   
      type = "KMS"
    }
  }

CMK を別アカウントに公開するためには、CMK のキーポリシーを明示的に設定する必要があります。 CodePipeline が当該の CMK を使えるようにすることも合わせると、以下のような設定になりました。

data "aws_caller_identity" "current" {}

data "aws_iam_policy_document" "cross_account_key_policy" {
  statement {
    sid    = "RootUserKeyManagement"
    effect = "Allow"
    # actions を "kms.*" にすると以下のエラーが発生する
    # "The new key policy will not allow you to update the key policy in the future."
    actions = [
      "kms:Create*",
      "kms:Describe*",
      "kms:Enable*",
      "kms:List*",
      "kms:Put*",
      "kms:Update*",
      "kms:Revoke*",
      "kms:Disable*",
      "kms:Get*",
      "kms:Delete*",
      "kms:TagResource",
      "kms:UntagResource",
      "kms:ScheduleKeyDeletion",
      "kms:CancelKeyDeletion"
    ]
    resources = ["*"]

    principals {
      type = "AWS"
      identifiers = [
        format("arn:aws:iam::%s:root", data.aws_caller_identity.current.account_id),
      ]
    }
  }

  # see: https://aws.amazon.com/jp/premiumsupport/knowledge-center/cross-account-access-denied-error-s3/
  statement {
    sid    = "EnableUseOfArtifactStoreEncryptionKey"
    effect = "Allow"
    actions = [
      "kms:Encrypt",
      "kms:Decrypt",
      "kms:ReEncrypt*",
      "kms:GenerateDataKey*",
      "kms:DescribeKey"
    ]
    resources = ["*"]

    principals {
      type = "AWS"
      identifiers = [
        format("arn:aws:iam::%s:root", var.accountA_id), # Delivery環境からartifact storeへのアクセスのために必要
        var.codepipeline_service_role_arn # CodePipelineからのartifact storeへのアクセスに必要
      ]
    }
  }

  statement {
    sid    = "EnableGrantOfArtifactStoreEncryptionKey"
    effect = "Allow"
    actions = [
      "kms:CreateGrant",
      "kms:ListGrants",
      "kms:RevokeGrant"
    ]
    resources = ["*"]

    principals {
      type = "AWS"
      identifiers = [
        format("arn:aws:iam::%s:root", var.accountA_id),
        var.codepipeline_service_role_arn
      ]
    }
    condition {
      test     = "Bool"
      variable = "kms:GrantIsForAWSResource"
      values   = [true]
    }
  }
}

参考文献