【AWS CDK v2】SlackからS3のファイルを簡単に取得する


## はじめに

この記事は、[ミロゴス Advent Calendar 2023](https://qiita.com/advent-calendar/2023/milogos) 17日目の記事です。

弊社では、S3にCSVを格納し開発外の部署で取り扱うといったケースが多く存在します。
SlackからS3のCSVを直接取りに行けたら便利だな、という発想から`Slack ワークフロー → Chatbot → Lambda → S3`といった流れで、CSVを取得する仕組みをCDKで実装しました。S3からのファイルの取得には署名付きURLを使用します。
この方法であればSlackさえ使えれば、誰でも簡単にS3上のファイルを取得することが可能です。

![スクリーンショット 2023-12-15 0.49.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2412171/b125b82b-8964-5ad8-3e66-b9166264d1d6.png)

下記のような簡単な構成で作成できます。
なお、今回は既存のS3からファイルを取得するためCDKでバケットの作成はしません。

![qiita-structure.drawio.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2412171/f9ba7fe6-fbe8-6039-d3df-d38839ef2ba6.png)

## 環境

- AWS CDKは2.95.1を使用

```bash
cdk --version
2.95.1 (build ae455d8)
```

## 実装

### CDKの実装

S3は既存のバケットが利用し、それ以外のChatbot、Lambda、SNSのリソースをCDKで作成します。

#### 完成系

それぞれのリソース毎に解説していきます。

```main_stack.py
import aws_cdk.aws_iam as iam
from aws_cdk import Stack
from aws_cdk import aws_chatbot as chatbot
from aws_cdk import aws_lambda as _lambda
from aws_cdk import aws_lambda_python_alpha as python_lambda
from aws_cdk import aws_sns as sns
from constructs import Construct


class MainStack(Stack):
    def __init__(
        self,
        scope: Construct,
    ) -> None:
        super().__init__(scope, "sample-main-stack")

        self.scope = scope

        topic = self._create_sns_topic()
        func = self._create_python_lambda(topic=topic)

        self.create_chatbot_slack_configuration(topic=topic, func=func)

    def _add_lambda_role(self, role: iam.Role, topic: sns.Topic) -> None:
        role.add_to_policy(
            iam.PolicyStatement(
                actions=["SNS:Publish"],
                resources=[
                    topic.topic_arn,
                ],
                effect=iam.Effect.ALLOW,
            ),
        )

    def _create_python_lambda(
        self,
        topic: sns.Topic,
    ) -> python_lambda.PythonFunction:
        func = python_lambda.PythonFunction(
            self,
            "function",
            entry="src/sample_function",
            runtime=_lambda.Runtime.PYTHON_3_11,
            index="main.py",
            handler="lambda_handler",
            function_name="sample-function",
            environment={
                "TOPIC_ARN": topic.topic_arn,
            },
        )
        self._add_lambda_role(func.role, topic=topic)
        return func

    def _create_sns_topic(self) -> sns.Topic:
        return sns.Topic(self, "sns-topic")

    def _add_chatbot_role(
        self,
        role: iam.Role,
        func: python_lambda.PythonFunction,
    ) -> iam.Role:
        role.add_to_policy(
            iam.PolicyStatement(
                actions=["lambda:InvokeFunction"],
                resources=[
                    func.function_arn,
                ],
                effect=iam.Effect.ALLOW,
            ),
        )
        return role

    def create_chatbot_slack_configuration(
        self,
        topic: sns.Topic,
        func: python_lambda.PythonFunction,
    ) -> chatbot.SlackChannelConfiguration:
        config = chatbot.SlackChannelConfiguration(
            self,
            "chatbot-slack-configuration",
            slack_channel_configuration_name="sample-chatbot-slack-configuration",
            slack_workspace_id="XXXXXXXXXXX",  # SlackのワークスペースID
            slack_channel_id="XXXXXXXXXXX",  # SlackのチャンネルID
            guardrail_policies=[
                iam.ManagedPolicy(
                    self,
                    "sample-chatbot-invoke-function-guardrail-policy",
                    statements=[
                        iam.PolicyStatement(
                            actions=["lambda:InvokeFunction"],
                            resources=[func.function_arn],
                            effect=iam.Effect.ALLOW,
                        ),
                    ],
                ),
            ],
            notification_topics=[topic],
        )

        self._add_chatbot_role(role=config.role, func=func)

        return config

```

#### SNSトピックの作成

SNSトピックを作成します。
後に記載するChatbotのSlack連携に必要となります。
参考：[[CFn]AWS Chatbotを利用してSlackに通知を行う](https://zenn.dev/devcamp/articles/f96b69ed326bde)
CDKリファレンス：[Topic](https://docs.aws.amazon.com/cdk/api/v2/python/aws_cdk.aws_sns/Topic.html#topic)

```main_stack.py
    def _create_sns_topic(self) -> sns.Topic:
        return sns.Topic(self, "sns-topic")
```

#### Lambdaの作成

S3の署名付きURLを発行するLambdaを作成します。
LambdaのRoleには先ほど作成したトピックに対する`SNS:Publish`の権限を付与しています。
LambdaからChatbot経由でSlackへメッセージを送る際に、先ほどのトピックが必要となります。

参考：[署名付き URL を使用したオブジェクトの共有](https://docs.aws.amazon.com/ja_jp/AmazonS3/latest/userguide/ShareObjectPreSignedURL.html)
CDKリファレンス：[PythonFunction](https://docs.aws.amazon.com/cdk/api/v2/python/aws_cdk.aws_lambda_python_alpha/PythonFunction.html#pythonfunction)

```main_stack.py
    def _add_lambda_role(self, role: iam.Role, topic: sns.Topic) -> None:
        role.add_to_policy(
            iam.PolicyStatement(
                actions=["SNS:Publish"],
                resources=[
                    topic.topic_arn,
                ],
                effect=iam.Effect.ALLOW,
            ),
        )

    def _create_python_lambda(
        self,
        topic: sns.Topic,
    ) -> python_lambda.PythonFunction:
        func = python_lambda.PythonFunction(
            self,
            "function",
            entry="src/sample_function",
            runtime=_lambda.Runtime.PYTHON_3_11,
            index="main.py",
            handler="lambda_handler",
            function_name="sample-function",
            environment={
                "TOPIC_ARN": topic.topic_arn,
            },
        )
        self._add_lambda_role(func.role, topic=topic)
        return func

```

#### Chatbotの作成

最後に、SlackからLambdaを呼び出す役割とLambdaからSNS経由でSlackへメッセージを送信する役割を担うChatbotを作成します。
ここでも先ほど作成したトピックを紐づける必要があり、ChatbotのRoleにはLambdaの実行権限を付与しています。
`chatbot.SlackChannelConfiguration`はChatbotのSlack連携設定を作成するクラスで、`通知先となるSlackのワークスペースID、チャンネルID`が必要となります。

参考：[Slack URL または ID を確認する](https://slack.com/intl/ja-jp/help/articles/221769328-Slack-URL-%E3%81%BE%E3%81%9F%E3%81%AF-ID-%E3%82%92%E7%A2%BA%E8%AA%8D%E3%81%99%E3%82%8B)
参考：[SlackのチャンネルIDを調べる方法(Webブラウザとアプリで確認)](https://auto-worker.com/blog/?p=132)
CDKリファレンス：[PythonFunction](https://docs.aws.amazon.com/cdk/api/v2/python/aws_cdk.aws_lambda_python_alpha/PythonFunction.html#pythonfunction)

```main_stack.py
    def _add_chatbot_role(
        self,
        role: iam.Role,
        func: python_lambda.PythonFunction,
    ) -> iam.Role:
        role.add_to_policy(
            iam.PolicyStatement(
                actions=["lambda:InvokeFunction"],
                resources=[
                    func.function_arn,
                ],
                effect=iam.Effect.ALLOW,
            ),
        )
        return role

    def create_chatbot_slack_configuration(
        self,
        topic: sns.Topic,
        func: python_lambda.PythonFunction,
    ) -> chatbot.SlackChannelConfiguration:
        config = chatbot.SlackChannelConfiguration(
            self,
            "chatbot-slack-configuration",
            slack_channel_configuration_name="sample-chatbot-slack-configuration",
            slack_workspace_id="XXXXXXXXXXX",  # SlackのワークスペースID
            slack_channel_id="XXXXXXXXXXX",  # SlackのチャンネルID
            guardrail_policies=[
                iam.ManagedPolicy(
                    self,
                    "sample-chatbot-invoke-function-guardrail-policy",
                    statements=[
                        iam.PolicyStatement(
                            actions=["lambda:InvokeFunction"],
                            resources=[func.function_arn],
                            effect=iam.Effect.ALLOW,
                        ),
                    ],
                ),
            ],
            notification_topics=[topic],
        )

        self._add_chatbot_role(role=config.role, func=func)

        return config

```

### Lambdaの中身の実装

続いて、Lambdaの中身の実装になります。
処理としてはboto3を使いS3とSNSのクライアントを作成し、ファイルの取得から署名付きURLの発行とSNSへの通知を送信しています。

参考：[boto3 S3 Client](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/s3.html)
参考：[boto3 SNS Client](https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/sns.html)

```main.py
import json
import os

import boto3

TOPIC_ARN = os.environ["TOPIC_ARN"]


def lambda_handler(event: dict, context) -> None:  # noqa: ARG001
    try:
        target_bucket = event["target_bucket"]
        output_path = event["output_path"]
        print(
            {
                "target_bucket": target_bucket,
                "output_path": output_path,
            },
        )
        presigned_url = _make_presigned_url(bucket=target_bucket, path=output_path)
        print({"presigned_url": presigned_url})
        _send_message(target_bucket=target_bucket, output_path=output_path, url=presigned_url)
    except KeyError:
        print({"message": "Invalid parameters.", "event": event})
        raise
    except Exception:
        print("Something error is happened.")
        raise

    print({"Success sending presigned url to slack."})


def _make_presigned_url(bucket: str, path: str) -> str:
    s3 = boto3.client("s3")
    return s3.generate_presigned_url("get_object", Params={"Bucket": bucket, "Key": path}, ExpiresIn=3600)


def _send_message(target_bucket: str, output_path: str, url: str) -> None:
    client = boto3.client("sns")
    request_message = _convert_sns_message_event(
        target_bucket=target_bucket,
        output_path=output_path,
        url=url,
    )

    client.publish(TopicArn=TOPIC_ARN, Message=request_message)


def _convert_sns_message_event(target_bucket: str, output_path: str, url: str) -> str:
    message = f"""
対象S3バケット: *{target_bucket}*
ファイルパス: *{output_path}*

URL: *<{url}|ダウンロード>*
"""
    return json.dumps(
        {
            "version": "1.0",
            "source": "custom",
            "content": {
                "title": "【ファイルのDLリンクが出力されました:mega:】",
                "description": message,
            },
        },
    )
```

### バケットポリシーの設定

最後に取得対象のバケットポリシーにLambdaからのアクセス許可をしてあげる必要があります。
上記で作成したLambda RoleのARNを下記のように設定します。

```bucket-policy.json
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::XXXXXXXXXX:role/sample-function-role"
            },
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::csv-sample-bucket/*"
        }
    ]
}
```

## Slackワークフロービルダーの作成

※Chatbotの呼び出しにはSlackにAWSアプリを入れる必要があります。
ここまで来ればLambdaを実行することでファイルの取得が可能になっているはずです。

ここからはSlackからLambdaを呼び出すために、Slackワークフローを用いてChatbotからLambdaを呼び出すようにします。
今回は下記のようなワークフローを作成しました。

参考：[Slack ワークフロービルダーガイド](https://slack.com/intl/ja-jp/help/articles/360035692513-Slack-%E3%83%AF%E3%83%BC%E3%82%AF%E3%83%95%E3%83%AD%E3%83%BC%E3%83%93%E3%83%AB%E3%83%80%E3%83%BC%E3%82%AC%E3%82%A4%E3%83%89)

![スクリーンショット 2023-12-15 0.24.49.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2412171/9c68cf65-d679-0fc1-8707-4082b6fa7fb5.png)

Chatbotを呼び出すコマンドは下記の通りです。

```
@aws lambda invoke --payload {"target_bucket": "ワークフローからの入力値", "output_path": "ワークフローからの入力値"} --function-name sample-function --region ap-northeast-1
```

## 使い方

これで全ての準備が整ったので実際にファイルを取得してみます。
上記で作成したワークフローのリンクを用意し、実行します。

![スクリーンショット 2023-12-15 0.25.38.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2412171/18545b99-f268-6c84-2cef-394db71639fd.png)

バケット名とオブジェクトパスを入力しSubmitを押すとSlackのAWSアプリでコマンドが読み込まれ、実行の許可を求められます。

![mosaic_20231215003011.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2412171/860e8112-0933-23f1-ef48-e1e334c04cde.png)

`[Run] command`を押下すると、Lambdaが呼び出されます。
その後、ファイルの署名付きURLが発行されます。

![スクリーンショット 2023-12-15 0.49.37.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2412171/5dd4ef28-c674-abc7-823c-4e9ce7e44fe6.png)

## まとめ

元々は開発部でない方向けに作成した機能ですが、わざわざAWSコンソールを開かなくてもS3からファイルを取得できるのは自分にとってもありがたいことでした。
取得対象のバケットは限られたものになりますが、使い勝手は良いと思います。
S3のファイルを頻繁に取得する方の参考になれば幸いです。