Amazon SNS の Email サブスクリプションを自動で確認できるようにしてみました

今回は、Amazon SNS のトピックに Email サブスクリプションを作成した際の「サブスクライブの確認」を自動化してみました。併せて、解除を防ぐ設定も入れこみます。

特にこんなケースに有用かと思います。

* AWS Organizations を用いてアカウント自動作成を行うフロー内で Amazon SNS の設定もしたい
 * フロー関係なしに自動的にサブスクライブの確認をしたいでもOK 
* Amazon SNS のサブスクライブの設定を誰かに解除されないように設定したい

それでは、始めましょう！

## 準備
今回は以下の2つの Lambda 関数と1つの Microsoft PowerAutomate のフローを用意します。
Amazon SNS のトピック自体はこの記事では作成しないので、必要に応じて作成ください。
既存のトピックにサブスクリプションを追加していくような環境向けで記載しています。

* Amazon SNS のサブスクリプションの作成用の Lambda 関数
* Amazon SNS のサブスクライブの確認用の Lambda 関数
* Amazon SNS のサブスクライブの確認を行うために調整や処理を行う PowerAutomate フロー

また、Microsoft PowerAutomate を自動実行するために適宜、メールソフトの振り分け設定などを行っておいてください。

以降のコードなどはエラーハンドリング無しもしくは最低限となっていますので、必要に応じて改修してください。

### Amazon SNS のサブスクリプションの作成用のコード
Python3.9で作成しています。

以下の環境変数を設定し利用しています。
```topic_arn```： サブスクリプションを作成する Amazon SNS のトピックの ARN

また、元々が AWS StepFunctions と AWS Organizations を用いたアカウント自動作成のフロー内で実行している関数なので、イベントオブジェクトでサブスクリプション用のメールアドレスを受け付けています。適宜、イベントオブジェクトに入れこむか、コードを改修してください。

```python:createSubscriptionToTipic
import json
import boto3
import os

topic_arn=os.environ['topic_arn']

def create_new_subscription(mail_address):
    sns=boto3.client('sns')
    response=sns.subscribe(
        TopicArn=topic_arn,
        Protocol='email',
        Endpoint=mail_address
    )

def lambda_handler(event, context):
    mail_address=event['mail_address']
    result=create_new_subscription(mail_address)
    
    return 
```

### Amazon SNS のサブスクライブの確認用のコード
こちらも、Python3.9 で作成しています。
この Lambda 関数は Microsoft PowerAutomate から呼び出し、実行させます。

そのため、**AWS Lambda Function URLs** という Amazon API Gateway を省略して、AWS Lambda 関数に付与された URL から直接実行できる機能を使用しています。

認証タイプは、```AWS_IAM``` と ```NONE``` が選べます。今回は、Microsoft PowerAutomateからの実行なので、設定自体は ```NONE``` としています。
ただ、コードの中で Basic 認証を実装して、誰でもアクセス可能な状態を防ぐようにしています。

Microsoft PowerAutomate側は、BASE64でエンコードした認証情報を authorization ヘッダーに埋め込んでいまので、AWS Lambda 関数側でデコードし、環境変数で持っているユーザー名やパスワードと一致するか確認します。ほかにもよいやり方はありますが、今回はお試しということで、この形態をとっています。

その他、認証に際して、Microsoft PowerAutomateからの実行であることが前提なので各種リクエストヘッダーの存在も確認しています。このあたりは適宜変更してみてください。
また、エラーハンドリングは最低限のため、適宜追加、変更をお願いします。

環境変数は以下の通り。前述の Basic 認証用のユーザー名とパスワードを設定します。
```username```：認証用のユーザー名
```password```：認証用のパスワード
```topic_arn```：Amazon SNS トピックのARN

```python:confirmSubscription
import json
import base64
import os
import boto3

def confirm_subscription(sns_token_url):
    sns=boto3.client('sns', region_name='us-east-1')
    response=sns.confirm_subscription(
        TopicArn=os.environ['topic_arn'],
        Token=sns_token_url,
        AuthenticateOnUnsubscribe='true'
    )
    return response

def lambda_handler(event, context):
    try:
        if not "authorization" in event['headers']:
            return {
                'statusCode': 403,
                'body': '{"message":"Forbidden"}'
            }
    
        if not "sns-token" in event['headers']:
            return {
                'statusCode': 403,
                'body': '{"message":"Forbidden"}'
            }
    
        if not "azure-logic-apps" in event['headers']['user-agent']:
            return {
                'statusCode': 403,
                'body': '{"message":"Forbidden"}'
            }
        
        ecodedCreds=event['headers']['authorization'].split(' ')[1]
        plainCreds=base64.b64decode(ecodedCreds).decode().split(':')
        username=plainCreds[0]
        password=plainCreds[1]
        
        if username == os.environ['username']:
            if password == os.environ['password']:
                sns_token_url=event['headers']['sns-token']
                result=confirm_subscription(sns_token_url)
            else:
                return {
                    'statusCode': 403,
                    'body': '{"message":"Forbidden"}'
                }
        else:
            return {
                'statusCode': 403,
                'body': '{"message":"Forbidden"}'
            }

    except:
        return {
            'statusCode': 403,
            'body': '{"message":"Forbidden"}'
        }
    
    
    return {
        'statusCode': 200,
        'body': json.dumps(result, indent=2)
    }
```

### Amazon SNS のサブスクライブの確認を行うための PowerAutomate フロー
**【大前提】Amazon SNS のトピックをサブスクライブするメールを受信できること。**
**【推奨】そのメールアドレスに届くサブスクライブ用のメールだけを振り分けるように Outlook の設定をしておくこと**

フローの全体としては以下の通り。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/548623/81200ef3-d473-001e-800b-59d0dbaf3917.png)

各ステップについて簡単に解説していきます。

#### 新しいメールが届いたとき(V3)
部品名：Office 365 Outlook の新しいメールが届いたとき(V3)
Amazon SNS のトピックをサブスクライブするメールが届いたら、このフローが動き出すようにします。

#### Html からテキスト
部品名：Content Conversion のHtmlからテキスト
届いたメールをテキスト形式に変換します

#### 対象の AWS アカウントであるか確認
部品名：テキスト関数のテキストの位置の検索
メール本文内に サブスクライブするメールを送信してきた AWS のアカウントIDが含まれているか確認する。

#### 対象の AWS のアカウントでなければ終了
部品名：コントロールの条件
「対象の AWS アカウントであるか確認」の結果、アカウントIDが含まれていれば0より大きい値が返るので、判定を行う。異なる場合（値が0）は、ここで終了とする。

#### Token の位置を取得
部品名：テキスト関数のテキストの位置の検索
メール本文に記載されている、サブスクライブするためのURL内の``Token=``がどのあたりにあるのかを取得する。
※確認した環境ではHTMLエンコードがされているので検索文字列は「Token%3D」としています。
※実環境でどのような文字列が来ているかご確認ください。

#### Token 以降の文字列を取得
部品名：テキスト関数の部分文字列
「Token の位置を取得」で判明した``Token=``の位置を開始位置として、文字列を取得する。

#### Token 区切りを取得
部品名：テキスト関数のテキストの位置の検索
取得した文字列の区切り文字（&＝%26）の位置を取得する。
※確認した環境ではHTMLエンコードがされているので検索文字列は「%26」としています。
※実環境でどのような文字列が来ているかご確認ください。

#### Token 文字列を取得する
部品名：テキスト関数の部分文字列
Token以降の文字列の頭(0)から区切り文字の位置までの値を文字列の長さとして、取得します。

#### Token の値を取得する
部品名：変数の変数を初期化する
以下の様に設定し、「Token 文字列を取得する」で取得した値から、「Token%3D」を削除しています。

>　変数名：任意の変数名を指定（本例ではsns-tokenとしています）
　種類：文字列
　値：```replace(body('Token文字列を取得する'),'Token%3D','')```

#### HTTP
部品名：HTTP部品のHTTP
以下の様に設定し、AWS Lambda関数を実行する

> 　方法：Get
　URI：AWS Lambda Function URLs で払い出された URI
　ヘッダー：以下はキー:バリューの形式で記載
　　sns-token: 「Token の値を取得する」の値を設定する
　　authorization：UserIdとパスワードの文字列をBase64でエンコードした値

## まとめ
サブスクリプション登録までは自動実行し、手作業で有効にすることは少なくないと考えます。
Microsoft 365 を利用している環境であれば、本記事の様に自動的にサブスクライブできるようになり、運用負荷、構築負荷をさげることができます。
また、SNSから発信されるメッセージには通常、サブスクライブの解除のURLが記載されており、（本来は問題行動ではありますが）無邪気にリンクをクリックして解除するという事故が起こりえます。
この フローの様に、サブスクライブ解除の URL が入らないようにすることでそういった事故を未然に防ぎ、運用時の問題発生リスクをさげることにもつなげられます。

ーーー
記載されている会社名、製品名、サービス名、ロゴ等は各社の商標または登録商標です。