# SNSをalias/aws/snsで暗号化するとCloudWatchアラームが届かない

CloudWatchアラームの通知先のSNSトピックを`alias/aws/sns`で暗号化すると、アラームがALARMに遷移しても、アラームからのpublishをトピックは受理しない。同じトピックでも、KMSの権限を持つ同じアカウントのIAMの主体からのpublishは受理されるため、手動でpublishして試すと成功し、アラームの通知だけが届かない。購読者の数に関係なく、アラームの通知は誰にも配信されない。

自社の監視基盤ではアラームの通知が、構築から2週間以上、一度も配信されていなかった。トピックの購読者が0件だったため、当初は購読者を追加すれば解決すると考えた。追加する前に`NumberOfMessagesPublished`を確認すると、アラームからのpublish自体が記録されていなかった。

## アラームの通知とSNSの暗号化の仕組み

### 通知が届くまでの2段階

CloudWatchアラームの通知は2段階で届く。アラームが状態の変化をSNSトピックへpublishし、トピックが購読者（メール、Slack連携など）へ配信する。

```mermaid
flowchart LR
  A[CloudWatchアラーム] -->|1. publish| T[SNSトピック]
  T -->|2. 配信| S[購読者]
  T -.-|暗号化している場合| K[KMSキー]
```

購読者の追加や設定で直るのは2段階目だけである。今回の不具合は1段階目で起きていた。

### SNSのサーバー側暗号化

SNSのサーバー側暗号化（SSE）は、トピックにKMSキーを指定し、[受信したメッセージを暗号化して保存し、配信の時点で復号する](https://docs.aws.amazon.com/sns/latest/dg/sns-server-side-encryption.html)機能である。暗号化したトピックにpublishする主体には、そのKMSキーに対する[`kms:GenerateDataKey*`と`kms:Decrypt`の権限](https://docs.aws.amazon.com/sns/latest/dg/sns-key-management.html)が必要になる。KMSキーを使えない主体のpublishは、トピックポリシーで許可していても成功しない。

### KMSキーの種類

| 種類 | 例 | キーポリシー | 費用 |
| --- | --- | --- | --- |
| [AWS管理キー](https://docs.aws.amazon.com/kms/latest/developerguide/concepts.html) | `alias/aws/sns` | AWSのサービスが管理し、利用者は閲覧のみ。キーと同じアカウントのIAMの主体だけが利用できる | キーの保持は無料 |
| カスタマー管理キー | 自分で作成したキー | 利用者が編集でき、任意の主体に利用を許可できる | [キー1本あたり月1ドル](https://aws.amazon.com/kms/pricing/)とAPIリクエストの料金 |

### publishする主体

アカウントのIAMユーザーやIAMロールが`aws sns publish`を実行するとき、publishの主体はそのIAMの主体である。CloudWatchアラームが通知するときの主体は、サービスプリンシパルの`cloudwatch.amazonaws.com`で、アカウントのIAMの主体ではない。

以上を組み合わせると、`alias/aws/sns`で暗号化したトピックには、KMSの権限を持つ同じアカウントのIAMの主体はpublishでき、CloudWatchアラームはpublishできない。AWS管理キーのキーポリシーは編集できないため、AWS管理キーをサービスプリンシパルに使わせる手段もない。2つの主体の結果が分かれるのは、KMSのキーを使う段階と考えられる。

```mermaid
sequenceDiagram
  participant P as publishする主体
  participant T as SNSトピック<br/>（alias/aws/sns で暗号化）
  participant K as AWS管理キー
  participant S as 購読者
  alt 同じアカウントのIAMの主体
    P->>T: Publish
    T->>K: GenerateDataKey* / Decrypt（呼び出し元はIAMの主体）
    K-->>T: 許可
    T-->>P: 成功（NumberOfMessagesPublished が増える）
    T->>S: 配信
  else CloudWatchアラーム（cloudwatch.amazonaws.com）
    P->>T: Publish
    T->>K: GenerateDataKey* / Decrypt（呼び出し元はサービスプリンシパル）
    K-->>T: 拒否と考えられる（AWS管理キーは同じアカウントの主体しか使えない）
    Note over T,K: 推定。アラームの履歴とCloudTrailでは未確認
    T-->>P: 失敗（NumberOfMessagesPublished は増えない）
    Note over S: 購読者には何も届かない
  end
```

## 初期構築でalias/aws/snsを設定していた

トピックの暗号化は初期構築のTerraformで設定していた。

```hcl
resource "aws_sns_topic" "alerts" {
  name              = "alerts"
  kms_master_key_id = "alias/aws/sns"
}
```

Security Hubのコントロール[SNS.1](https://docs.aws.amazon.com/securityhub/latest/userguide/sns-controls.html)は、KMSキーで暗号化していないトピックを指摘する。`alias/aws/sns`は鍵を新規に作成せずにこの指摘を解消できる値で、設定の時点ではエラーにならない。`terraform apply`は成功し、トピックの属性にも異常は表示されない。コンソールで暗号化を有効にした場合も、[デフォルトで選択されるキーは`alias/aws/sns`](https://docs.aws.amazon.com/sns/latest/dg/sns-enable-encryption-for-topic.html)である。

自分のトピックが該当するかは、トピックの属性の`KmsMasterKeyId`で確認できる。

```bash
aws sns get-topic-attributes --topic-arn <topic-arn> \
  --query 'Attributes.KmsMasterKeyId' --output text
```

出力が`alias/aws/sns`かそのエイリアスのARNなら該当する。暗号化していないトピックには`KmsMasterKeyId`の属性がないため、`--output text`では`None`と表示される。

## NumberOfMessagesPublishedでpublishの成否を判別する

通知の不達は、トピックへのpublishか、トピックから購読者への配信の、どちらかの段階で発生する。SNSのメトリクス`NumberOfMessagesPublished`は、トピックが受理したメッセージの数である。冒頭の節の1段階目（アラームからトピックへのpublish）の成否を表す。アラームの遷移後もデータポイントが出なければ、トピックはメッセージを受理していない。アラームがpublishしていない場合（アクションの無効など）もここに含まれるので、後述の表で区別する。

アラームは`set-alarm-state`で手動で遷移させられる。[メトリクスアラームで通知のアクションが実行されるのは状態が変化したときだけ](https://docs.aws.amazon.com/AmazonCloudWatch/latest/APIReference/API_SetAlarmState.html)なので、すでにALARMのアラームは先にOKへ戻す。手動で設定した状態は次の評価で実際の状態に戻る。

```bash
aws cloudwatch set-alarm-state --alarm-name <alarm-name> \
  --state-value OK --state-reason "reset"
aws cloudwatch set-alarm-state --alarm-name <alarm-name> \
  --state-value ALARM --state-reason "delivery test"
```

遷移の数分後に、トピックのメトリクスを取得する。`<start-time>`と`<end-time>`には、遷移の前後を含む時間帯をISO 8601形式のUTCで指定する。

```bash
aws cloudwatch get-metric-statistics --namespace AWS/SNS \
  --metric-name NumberOfMessagesPublished \
  --dimensions Name=TopicName,Value=<topic-name> \
  --start-time <start-time> --end-time <end-time> \
  --period 60 --statistics Sum
```

観測結果は次のとおり。

| 暗号化 | publishの主体 | 操作 | NumberOfMessagesPublished |
| --- | --- | --- | ---: |
| `alias/aws/sns` | CloudWatchアラーム | アラームをOKからALARMに遷移させる | 0 |
| `alias/aws/sns` | 同じアカウントのIAMの主体 | `aws sns publish`を実行する | 1 |
| なし | CloudWatchアラーム | 暗号化の解除後に、同じアラームをOKからALARMに遷移させる | 1 |

同じアカウントのIAMの主体からのpublishは成功し、アラームからのpublishだけが記録されない。暗号化を外すと、同じアラームのpublishが記録された。

`NumberOfMessagesPublished`が増えない原因はKMS以外にもある。暗号化を外す前に、次の順で区別する。

```mermaid
flowchart TD
  A[アラームをOKからALARMに遷移させる] --> B{NumberOfMessagesPublished が増えたか}
  B -->|増えた| C[2段階目の配信を調べる<br/>購読の確認状態・購読先の設定]
  B -->|増えない| D{アラームの ActionsEnabled と<br/>AlarmActions のARNは正しいか}
  D -->|誤り| E[アラームの設定を直す]
  D -->|正しい| F{トピックポリシーで<br/>アラームのpublishを許可しているか}
  F -->|していない| G[トピックポリシーを直す]
  F -->|している| H{KmsMasterKeyId の値}
  H -->|AWS管理キー<br/>エイリアス・ARN・キーID| I[アラームの履歴で<br/>アクションの失敗を確認する]
  I --> I2[暗号化の解除か<br/>カスタマー管理キーへ]
  H -->|カスタマー管理キー| J[キーポリシーで<br/>CloudWatchのサービスプリンシパルに<br/>GenerateDataKey* と Decrypt を許可しているか確認]
  H -->|未設定| K[KMSは関係しない<br/>アラームの履歴で失敗の理由を確認する]
```

各項目の確認場所は次のとおり。

| 原因 | 確認する場所 |
| --- | --- |
| アラームのアクションが無効 | `describe-alarms`の`ActionsEnabled` |
| 通知先のARNが別のトピック、別のリージョン | `describe-alarms`の`AlarmActions` |
| トピックポリシーで許可していない | `get-topic-attributes`の`Policy` |
| KMSで拒否された | アラームの履歴（`describe-alarm-history --history-item-type Action`）。CloudTrailの`GenerateDataKey`のイベントにも拒否が記録される可能性がある（今回は未確認） |

今回はアラームの履歴とCloudTrailの記録を残していない。KMSを原因と判断したのは、上の対照の観測と、冒頭の節で説明したAWS管理キーのキーポリシーの仕組みによる。

## 観測結果と仕組みの対応

観測の表の結果は、冒頭の節の仕組みで説明できる。暗号化した状態のアラームは`cloudwatch.amazonaws.com`としてpublishし、AWS管理キーを使えずに失敗したと考えられる。IAMの主体からのpublishは、同じアカウントの主体が実行したため、AWS管理キーを利用できた。

SNSのドキュメントにある、AWSのサービスを暗号化したトピックのイベントソースにする手順は、[カスタマー管理キーの使用](https://docs.aws.amazon.com/sns/latest/dg/sns-key-management.html#compatibility-with-aws-services)が第1段階になっている。Budgetsの通知にも[同様の手順](https://docs.aws.amazon.com/cost-management/latest/userguide/budgets-sns-policy.html)があり、同じ条件で失敗すると考えられる。今回、暗号化した状態でのBudgetsの失敗は観測していない。暗号化の解除後には、Budgetsの予測超過の通知がトピックに記録された。

## 暗号化の解除とカスタマー管理キーへの変更

対処は2種類ある。

| 方法 | 必要なもの | 適する場合 |
| --- | --- | --- |
| 暗号化を解除する | `kms_master_key_id`の削除 | 通知の本文に機微な情報を含めない |
| カスタマー管理キーに変更する | キーの作成、キーポリシーの管理、キーの料金 | 通知の本文に機微な情報を含める、または組織の規定で暗号化が必須 |

カスタマー管理キーを使用する場合は、キーポリシーにサービスプリンシパルを追加する。次はSNSのドキュメントの例をCloudWatchアラームに当てはめたステートメントで、自分たちは適用していない。キーポリシーの`Statement`配列に追加し、アカウントのrootに管理を許可するデフォルトのステートメントは削除しない。

```json
{
  "Effect": "Allow",
  "Principal": { "Service": "cloudwatch.amazonaws.com" },
  "Action": ["kms:GenerateDataKey*", "kms:Decrypt"],
  "Resource": "*",
  "Condition": {
    "StringEquals": { "aws:SourceAccount": "<account-id>" }
  }
}
```

ほかのサービスから通知する場合も、そのサービスのプリンシパルについて同じステートメントを追加する。たとえばBudgetsは`budgets.amazonaws.com`、EventBridgeは`events.amazonaws.com`である。EventBridgeから暗号化したトピックへのpublishでは、KMSのポリシーで`aws:SourceAccount`や`aws:SourceArn`の条件を[使用できない](https://docs.aws.amazon.com/sns/latest/dg/sns-key-management.html#compatibility-with-aws-services)。EC2 Auto ScalingやAWS Configなどは、サービスプリンシパルではなくIAMロールをキーポリシーに指定する。

```mermaid
flowchart TD
  A{通知の本文に機微な情報を含めるか} -->|含めない| C{組織の規定で暗号化が必須か}
  A -->|含める| B[カスタマー管理キーに変更し<br/>サービスプリンシパルをキーポリシーに追加]
  C -->|必須| B
  C -->|必須ではない| D[暗号化を解除する]
```

今回は暗号化を解除した。SSEはSNSに保存中のメッセージ本文を暗号化する機能で、購読者には平文が届く。暗号化を外しても、SNSは[デフォルトでディスクの暗号化を使用してメッセージを保存する](https://docs.aws.amazon.com/securityhub/latest/userguide/sns-controls.html)。このトピックで送信するのはアラーム名、閾値、費用額、リソースIDで、保存中の内容をKMSのキーポリシーで保護する必要はない。月1ドルとキーポリシーの管理の負担に対して、得られる保護は限定的である。機微な内容を送信する必要が生じたら、カスタマー管理キーに変更する。

Security Hubを有効にした環境では、暗号化の解除によってSNS.1が失敗になる。このトピックは、理由を記録して指摘を抑制する扱いにする。IaCのスキャナも同様で、tfsecは暗号化していないトピックを[aws-sns-enable-topic-encryption](https://aquasecurity.github.io/tfsec/v1.28.13/checks/aws/sns/enable-topic-encryption/)で、AWS管理キーで暗号化したトピックを[aws-sns-topic-encryption-use-cmk](https://aquasecurity.github.io/tfsec/latest/checks/aws/sns/topic-encryption-use-cmk/)で指摘する。

## 暗号化の再設定をterraform testで禁止する

`alias/aws/sns`は、スキャナの指摘を解消する目的で再び設定されるおそれがある。後で誰かが再設定すると、同じ不達が再発する。そこで`terraform test`の不変条件で、トピックの暗号化の再設定を禁止した。

現在の不変条件は、`kms_master_key_id`が未設定でも`plan`の時点で値が確定しないと考えて、定義のファイルに`kms_master_key_id`という文字列があれば失敗にしている。この形ではカスタマー管理キーへの変更も禁止になる。AWS providerのこの属性は、[4.x以降のスキーマ](https://github.com/hashicorp/terraform-provider-aws/blob/main/internal/service/sns/topic.go)では値を算出しない属性で、未設定なら`plan`の時点で`null`になる。値で判定でき、次の形に置き換えられる。`terraform test`は[Terraform 1.6以降](https://developer.hashicorp.com/terraform/language/tests)で使える。

```hcl
run "alerts_topic_invariants" {
  command = plan

  assert {
    condition = (
      aws_sns_topic.alerts.kms_master_key_id == null ||
      !can(regex("alias/aws/sns$", aws_sns_topic.alerts.kms_master_key_id))
    )
    error_message = "The alerts topic must not use the AWS managed key alias/aws/sns; service principals cannot publish to it."
  }
}
```

この条件は、未設定と、`plan`の時点で値が確定しているカスタマー管理キーのARNを通し、`alias/aws/sns`とエイリアスのARNを失敗にする。同じ構成で作るキーのARNを`aws_kms_key.alerts.arn`のように参照すると、`plan`の時点では値が確定せず、`command = plan`の`assert`は評価できずに失敗する（`Unknown condition value`）。カスタマー管理キーへ変更するときは、この`run`を`mock_provider`（[Terraform 1.7以降](https://developer.hashicorp.com/terraform/language/tests/mocking)）のもとで`command = apply`にすると、参照先に仮の値が入り条件を評価できる。AWS管理キーをエイリアスではなくキーIDで直接指定した場合は検出できず、その書き方での再設定はテストを通過する。

## 通知の到達を定期的に試験する

SNSには購読者への配信の失敗数を示す`NumberOfNotificationsFailed`があり、これにもアラームを設定した。ただし通知先がこのトピックだけの間は、そのアラーム自体も同じトピックを通過する。publishの段階で失敗する今回の不具合では、配信の失敗数は増加せず、仮に増えても失敗のアラームの通知が同じ段階で止まる。

```mermaid
sequenceDiagram
  participant A as 監視対象のアラーム
  participant F as 配信失敗のアラーム<br/>（NumberOfNotificationsFailed）
  participant T as SNSトピック
  participant S as 購読者
  A->>T: Publish
  T--xA: 拒否（KMS、推定）
  Note over T,F: 配信まで進まないので<br/>NumberOfNotificationsFailed は増えない
  opt 仮に NumberOfNotificationsFailed が増えた場合
    F->>T: Publish（同じトピックを通る）
    T--xF: 拒否（KMS、推定）
  end
  Note over S: どちらの通知も届かない
```

このため四半期に一度、アラームを手動でALARMに遷移させ、通知の到達を人が確認する運用にした。この頻度では、不達に気付くまで最大で約3か月かかる。通知が来なければ、前述の`NumberOfMessagesPublished`の確認と原因の区別の表を使って、失敗した段階を判別する。
