LCL Engineers' Blog

バス比較なび・格安移動・バスとりっぷを運営する LCLの開発者ブログ

AWS SES クロスアカウント送信で遭遇した2つの落とし穴 — IAM ポリシーの Resource 指定と Sandbox モードの罠

こんにちは。インフラエンジニアの小林です。

本日は、AWS SES(Simple Email Service)でアカウントをまたいだメールを送信した時に発生したハマりごとについて共有いたします。

はじめに

AWS SES(Simple Email Service)でクロスアカウントメール送信を構築した際、2つの AccessDenied エラーに遭遇しました。どちらもドキュメントを読んだだけでは気づきにくく、解決に大変時間が掛かる問題でした。

本記事では、以下の構成でクロスアカウント SES 送信を実装する際に遭遇した問題と解決策を共有します。

  1. IAM ポリシーの Resource 指定の不一致 — SES が IAM 評価時に組み立てるリソース ARN が、直感に反する形式になる問題
  2. Sandbox モードの適用範囲の誤解 — SES ドメインを管理するアカウントではなく、API 呼び出し元アカウントに Sandbox 制限が適用される問題

対象読者: - クロスアカウントで SES メール送信を構築するエンジニア - SES の Sending Authorization を初めて使う方 - IAM ポリシーで AccessDenied に悩んでいる方

構成の概要

  • アカウント A(ECS): ECS 上のアプリケーションがメールを送信する
  • アカウント B(SES): SES でドメイン identity(example.com)を管理している
  • アプリケーションは SendRawEmail API の source_arn パラメータでアカウント B(SES)の identity を指定
  • アカウント B(SES)の SES Identity Policy(Sending Authorization)でクロスアカウント送信を許可済み

落とし穴 1: IAM ポリシーの Resource が一致しない

発生したエラー

IAM ポリシーと SES Identity Policy を設定してメール送信をテストしたところ、以下のエラーが返されました。

User `arn:aws:sts::111111111111:assumed-role/my-ecs-task-role/...'
is not authorized to perform `ses:SendRawEmail'
on resource `arn:aws:ses:ap-northeast-1:111111111111:identity/noreply@example.com'

ここで注目すべきは、エラーメッセージに含まれるリソース ARN です。

  • source_arn で指定したのは: arn:aws:ses:ap-northeast-1:222222222222:identity/example.com(アカウント B(SES)のドメイン)
  • エラーに表示されたのは: arn:aws:ses:ap-northeast-1:111111111111:identity/noreply@example.com(アカウント A(ECS)のメールアドレス)

アカウント ID も identity の種類も全く異なっています。

原因

SES は IAM ポリシーの権限チェック時、独自のルールでリソース ARN を組み立てます。

arn:aws:ses:{REGION}:{呼び出し元アカウントID}:identity/{Fromメールアドレス}

つまり:

  • source_arn パラメータは SES Identity Policy(Sending Authorization)の評価にのみ使われる
  • IAM ポリシーの評価には source_arn は使われない
  • IAM 評価では、SES が自動的に呼び出し元アカウント IDFrom メールアドレスからリソース ARN を組み立てる

これにより、IAM ポリシーの Resource に設定していた値と、SES が IAM に渡す実際のリソースが以下のように不一致になっていました。

IAM ポリシーの Resource(設定値):
  arn:aws:ses:ap-northeast-1:222222222222:identity/example.com
                              ^^^^^^^^^^^^         ^^^^^^^^^^^
                              アカウント B(SES)    ドメイン identity

SES が IAM に渡す実際のリソース:
  arn:aws:ses:ap-northeast-1:111111111111:identity/noreply@example.com
                              ^^^^^^^^^^^^         ^^^^^^^^^^^^^^^^^^^^^^
                              アカウント A(ECS)    メールアドレス identity

アカウント ID、identity 種別(ドメイン vs メールアドレス)、identity 名のすべてが不一致でした。

裏付け

この挙動は AWS ドキュメントからも確認できます。

  • AWS ドキュメント「Identity and access management in Amazon SES」の IAM ポリシー例はすべて "Resource": "*" を使用しています。これは、SES が IAM 評価時に組み立てるリソース ARN が直感的でないため、ワイルドカードを使わざるを得ないことを示唆しています。
  • source_arnAPI リファレンスには "This parameter is used only for sending authorization"(このパラメータは Sending Authorization にのみ使用される)と明記されており、IAM 評価とは無関係であることが裏付けられます。

解決策

IAM ポリシーの Resource に、呼び出し元アカウント(アカウント A(ECS))の identity をワイルドカードで指定します。

Terraform での修正例

# ECS タスクロールの IAM ポリシー
resource "aws_iam_role_policy" "ecs_ses_send" {
  name = "ses-send-email"
  role = aws_iam_role.ecs_task_role.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Effect = "Allow"
        Action = [
          "ses:SendEmail",
          "ses:SendRawEmail"
        ]
        # SES が IAM 評価時に使用するリソース ARN は
        # 呼び出し元アカウントの identity になるため、
        # アカウント A(ECS)の identity をワイルドカードで指定
        Resource = [
          "arn:aws:ses:${data.aws_region.current.name}:${data.aws_caller_identity.current.account_id}:identity/*"
        ]
        # セキュリティは Condition で担保する
        Condition = {
          # 注意: ForAllValues は対象キーが空(Recipients なし)の場合 true を返すため、
          # SES の API 仕様上 Recipients が必ず存在する前提で使用しています。
          # より厳密にしたい場合は StringLike に変更してください。
          "ForAllValues:StringLike" = {
            "ses:Recipients" = [
              "*@example.com",
              "*@corp.example.com"
            ]
          }
        }
      }
    ]
  })
}

ポイント: - Resource はアカウント A(ECS)の identity をワイルドカードで指定(identity/*) - セキュリティは Condition ブロックの ses:Recipients で宛先を制限することで担保 - AWS ドキュメントの例に合わせて Resource: "*" とするパターンも可能(Condition で十分に制限されていれば同等のセキュリティ)

落とし穴 2: Sandbox モードが呼び出し元にも適用される

発生したエラー

IAM ポリシーを修正して AccessDenied は解消されましたが、今度は別のエラーが発生しました。

Email address is not verified.
The following identities failed the check in region AP-NORTHEAST-1: noreply@example.com

アカウント B(SES)では SES ドメイン identity が検証済みで、本番モード(Sandbox 解除済み)です。にもかかわらず、「メールアドレスが検証されていない」というエラーが出ました。

原因

SES の Sandbox 制限は API 呼び出し元アカウントに適用されます。

アカウント A(ECS)はこれまで SES を直接使っていなかったため、SES が Sandbox モードのままでした。Sandbox モードでは、FROM アドレスと TO アドレスの両方が呼び出し元アカウントで検証された identity である必要があります。

アカウント B(SES)が本番モードかどうかは関係なく、API を呼び出すアカウント A(ECS)側の Sandbox 制限が適用されるのです。

解決策

2つのアプローチがあります。今回は stage 環境での事象だったため、Sandbox 解除の承認を待たずに即座に対応できるアプローチ 2 を採用しました。

アプローチ 1: アカウント A(ECS)で SES の本番モードをリクエスト

AWS コンソールまたは API でアカウント A(ECS)の SES Sandbox 解除をリクエストします。承認されれば Sandbox 制限がなくなり、任意のアドレスにメール送信可能になります。本番環境ではこちらが推奨です。

アプローチ 2: アカウント A(ECS)でもドメイン identity を作成・検証(今回採用)

stage 環境では Sandbox 解除の承認プロセスを待つよりも、呼び出し元アカウントにドメイン identity を作成・検証する方が迅速に対応できます。アカウント A(ECS)にもドメイン identity を作成して検証します。

# アカウント A(ECS)で SES ドメイン identity を作成
resource "aws_ses_domain_identity" "sender" {
  domain = "example.com"
}

# DKIM 設定
resource "aws_ses_domain_dkim" "sender" {
  domain = aws_ses_domain_identity.sender.domain
}

# ドメイン検証用 DNS レコード
resource "aws_route53_record" "ses_verification" {
  zone_id = data.aws_route53_zone.main.zone_id
  name    = "_amazonses.example.com"
  type    = "TXT"
  ttl     = 600
  records = [aws_ses_domain_identity.sender.verification_token]
}

# DKIM 用 DNS レコード
resource "aws_route53_record" "ses_dkim" {
  count   = length(aws_ses_domain_dkim.sender.dkim_tokens)
  zone_id = data.aws_route53_zone.main.zone_id
  name    = "${aws_ses_domain_dkim.sender.dkim_tokens[count.index]}._domainkey.example.com"
  type    = "CNAME"
  ttl     = 600
  records = ["${aws_ses_domain_dkim.sender.dkim_tokens[count.index]}.dkim.amazonses.com"]
}

DNS レコードの共存について:

アカウント A(ECS)とアカウント B(SES)でそれぞれ SES ドメイン identity を作成すると、_amazonses.example.com の TXT レコードに2つの検証トークンが必要になります。Route 53 の TXT レコードは複数値を設定できるため、両方のトークンを含めれば共存可能です。

resource "aws_route53_record" "ses_verification" {
  zone_id = data.aws_route53_zone.main.zone_id
  name    = "_amazonses.example.com"
  type    = "TXT"
  ttl     = 600
  records = [
    aws_ses_domain_identity.account_a.verification_token,  # アカウント A(ECS)用
    "existing-token-from-account-b"                         # アカウント B(SES)用(既存)
  ]
}

クロスアカウント SES の権限レイヤーまとめ

クロスアカウント SES 送信では、以下の 3つの権限レイヤーをすべて通過する必要があります。

レイヤー 評価されるアカウント source_arn の扱い
IAM ポリシー アカウント A(ECS) 使われない
SES Identity Policy アカウント B(SES) 評価に使用
SES Sandbox アカウント A(ECS) 関係なし

おわりに

教訓

  1. SES の IAM 評価は source_arn を無視する — IAM ポリシーの Resource にはアカウント B(SES)の ARN を書いても意味がなく、SES が自動組立するアカウント A(ECS)の identity ARN にマッチさせる必要がある
  2. Sandbox 制限は API 呼び出し元アカウントに適用される — SES ドメインを管理するアカウント B(SES)が本番モードでも、呼び出し元のアカウント A(ECS)が Sandbox なら制限を受ける
  3. エラーメッセージをよく読む — エラーに含まれる ARN が source_arn と異なることに気づけば、原因の手がかりになる

参考リンク