AWS Copilot + Phoenix + S3 + STS でサーバー負荷なく画像をアップロードする

## はじめに

:::note warn
今回はかなりややこしく、ニッチな内容です
ほぼ自分用のメモになっています
:::

Web サービスからファイルをアップロードし、 S3 に保管したいことがあると思います

特に ECS などで立ち上げたコンテナの場合、コンテナ上のストレージはあくまでも一時的なもの、いつ消えても良いものなので、必ず S3 のような別の場所に移動して永続化することになります

```mermaid
graph TD
  Frontend -- ファイルアップロード --> Server -- ファイルアップロード --> S3
```

このとき、当然 S3 には誰でもアクセスできてはいけないので、 S3 へのアクセスを制限しておいて、 Server のコンテナだけに S3 への PutObject 等の権限を付与します

アップロードしたいファイルが少量である場合、このような実装でなんの問題もありません

しかし、例えば数百、数千、数万の画像をアップロードするようなシステムの場合、これを毎回サーバー経由で実行すると、メモリやストレージを圧迫してしまう危険性があります

本記事では STS を使って、サーバー負荷なくファイルを S3 へアップロードする方法を記載します

また、これを AWS Copilot でデプロイする場合の権限設定方法について実装例を示します

## Phoenix でサーバー経由の S3 アップロード

例えば Elixir の Phoenix LiveView の場合、以下のように実装します

フロントエンドでは `Phoenix.Component.live_file_input` を使用してファイル入力を作ります

そして、 form の送信時（`phx-submit`） に `save` イベントを発生させます

詳細は公式ドキュメントを参照

https://hexdocs.pm/phoenix_live_view/uploads.html

フロントエンド側
```heex
<.simple_form
  for={@form}
  id="sample-form"
  phx-target={@myself}
  phx-change="validate"
  phx-submit="save"
>
  <.live_file_input upload={@uploads.avatar} />
  <.button>アップロード</.button>
</.simple_form>
```

バックエンド側では画面マウント時に `allow_upload` でファイル入力できるようにしておき、必要に応じて拡張子やファイル数などを指定します

そして `save` イベントを受け取ったとき `consume_uploaded_entries` 内で `ExAws.S3` を使ってアップロードします

バックエンド側
```elixir
defmodule AppWeb.SampleLive.Index do
  use AppWeb, :live_view
...
  @impl true
  def mount(_params, _conn, socket) do
    socket
    |> allow_upload(:avatar, accept: ~w(.jpg .jpeg .png), max_entries: 1)
    |> then(&{:ok, &1})
  end
...
  def handle_event("save", _params, socket) do
    consume_uploaded_entries(socket, :avatar, fn %{path: path}, _entry ->
      path
      |> ExAws.S3.Upload.stream_file()
      |> ExAws.S3.upload(<S3のバケット名>, <アップロード先のS3キー>)
      |> ExAws.request!()
    end)
  end
...
end
```

一点注意すべきなのは `consume_uploaded_entries` による処理の終了後、サーバー上からはファイルが消える、ということです

`.live_file_input` を使った時の処理の流れ
- ユーザーが `.live_file_input` でファイルを選択する
- この時点でサーバー上の一時ディレクトリーにファイル自体はアップロードされている
- `consume_uploaded_entries` を実行すると、サーバー上の一時ディレクトリー内にあるファイルを参照し、それぞれに対してコピーやアップロードなどの処理を施して永続化する
- 各ファイルに対する処理の実行後、一時ディレクトリー内のファイルは自動的に削除される

## Phoenix で STS を使用した S3 アップロード

STS は一時的な AWS 認証情報を作るための仕組みです

https://docs.aws.amazon.com/ja_jp/IAM/latest/UserGuide/id_credentials_temp.html

これを使うと、フロントエンドに対して一時的に S3 へのアップロード権限を委任することができます

当然、ユーザー認証を実装していたり、委任する範囲（アクションと S3 バケット、 S3 キーなど）を限定することが条件です

ざっくり、以下のような流れで実装します

```mermaid
graph TD
  Frontend -- 1.アップロード要求 -->  Server
  Server -- 2.一時的な認証情報の要求 --> STS -- 3.一時的な認証情報 --> Server
  Server -- 4.一時的な認証情報 --> Frontend
  Frontend -- 5.ファイルアップロード --> S3
```

:::note warn
説明のため、実際に動かしたコードを最小限に削っています
このままで動く保証をするものではありません
:::

まず、 STS で与える権限の大元となるマスター用 IAM ロールを準備します
この IAM ロールには大きめの権限を与えておきます
この辺りの定義は後述します

フロントエンド側では通常のファイル入力を作ります
ボタンの方でファイル入力の ID をパラメータとして送信します（ファイル入力が複数ある場合のため）

フロントエンド側
```heex
<input type="file" id="file-input" />
<.button phx-click="save" phx-value-input_id="file-input">アップロード</.button>
```

バックエンド側は `save` イベントを受け取けて一時認証情報を発行します
このとき、 STS にマスター用 IAM ロールを指定し、更に詳細な権限と有効期限を指定します
マスター用 IAM ロールでは S3 バケット上の全体のプレフィックスを指定しておき、こちらではユーザー毎やグループ毎などで細かく指定するイメージです

取得した認証情報をフロントエンド側に `got_tmp_portraits_credentials` イベントで通知します

バックエンド側
```elixir
defmodule AppWeb.SampleLive.Index do
  use AppWeb, :live_view
...
  @impl true
  def handle_event("save", %{"input_id" => input_id}, socket) do
    credentials = assume_role()

    socket
    |> push_event("got_tmp_portraits_credentials", %{
      input_id: input_id,
      tmp_credentials: credentials
    })
    |> then(&{:noreply, &1})
  end

  defp assume_role() do
    doc = assume_role_request().body

    %{
      "accessKeyId" => doc.access_key_id,
      "secretAccessKey" => doc.secret_access_key,
      "sessionToken" => doc.session_token,
      "expiration" => doc.expiration
    }
  end

  defp assume_role_request() do
    ExAws.STS.assume_role(<ロールのARN>, <一意のセッション ID>, get_policy())
    |> ExAws.request!()
  end

  defp get_policy(group_id, photo_studio_id) do
    [
      duration: <有効期間　>,
      policy: %{
        "Version" => "2012-10-17",
        "Statement" => [
          %{
            "Action" => [
              "s3:PutObject"
            ],
            "Effect" => "Allow",
            "Resource" => [<操作を許可する S3 キー>]
          }
        ]
      }
    ]
  end
end
```

フロントエンド側では `got_tmp_portraits_credentials` イベントをフックで受け、取得した認証情報を使って S3 にアップロードします

フロントエンド側（フック用の JS）
```javascript
const UploaderHook = {
  mounted () {
    this.handleEvent('got_tmp_portraits_credentials', async (params) => {
      await uploadFiles(params)
    })

    const uploadFiles = async (params) => {
      const s3Config = {
        credentials: {
          accessKeyId,
          secretAccessKey,
          sessionToken
        },
        params: {
          Bucket: <S3バケット名>
        },
        region: <AWSリージョン名>
      }
      const files = Array.from(document.getElementById(params.input_id).files)
      await Promise.all(files.map(async (file) => {
        await uploadFile(file, s3Config)
      })
    }

    const uploadFile = (files, s3Config) => {
      const putObjectParams = {
        Bucket: <S3バケット名>,
        Key: <S3キー>,
        Body: file
      }
      const s3 = new S3(s3Config)
      return s3.putObject(putObjectParams).promise()
    }
  }
}

export default UploaderHook
```

## 権限設定

:::note info
実のところ、ここまでは前置きです
ここからが AWS Copilot を使ったときに私がハマったポイントで、この記事を書く理由です
ただ、同じことにハマる人がいるとは思えないため、ほぼ自分のためのメモになります
:::

STS を使ってコンテナから一時認証情報を作成する場合、以下のようなリソース、設定が必要になります

```mermaid
graph TD
  STSのマスター用IAMロール　-- アクセス可能 --> S3
  STSのマスター用IAMロール　-- 一時認証情報を発行することを許可 --> 各コンテナに割り当てたIAMユーザー
  各コンテナに割り当てたIAMユーザー -- 発行 --> 一時認証情報　-- アクセス可能 --> S3
```

AWS Copilot は自動的に IAM ロールを生成し、コンテナ起動時にはそのロールから委任した IAM ユーザーを使用しています
例えばコンテナから S3 へアクセスする場合、この IAM ユーザーに S3 へのアクセス権限を付与することになります
コンテナへのアクセス権限（IAMポリシー）追加は、アドオンの設定ファイルに以下のように定義します

<サービス名>/addons/container-policy.yml
```yml
Parameters:
  App:
    Type: String
    Description: Your application's name.
  Env:
    Type: String
    Description: The environment name for the service.
  Name:
    Type: String
    Description: The name of the service.

Resources:
  ContainerPolicy:
    Type: AWS::IAM::ManagedPolicy
    Properties:
      PolicyDocument:
        Version: 2012-10-17
        Statement:
          - Sid: S3Access
            Effect: Allow
            Action:
              - s3:PutObject
            Resource:
              - !Sub "arn:${AWS::Partition}:s3:::<S3バケット名>/*"

Outputs:
  ContainerPolicyArn:
    Description: "The ARN of the ManagedPolicy to attatch to the task role."
    Value: !Ref ContainerPolicy
```

しかし、 STS のロールを使って一時認証情報を作成することを許可する場合、コンテナ側（Copilot の設定ファイル）ではなく、 STS 用のロールの設定で「誰ならこのロールを使って一時認証情報を発行できるか」を設定することになります

Terraform を使う場合、STSのマスター用IAMロールは以下のように定義します

```terraform
resource "aws_iam_role" "sts-master-role" {
  name               = "<ロール名>"
  description        = "STS master"
  assume_role_policy = data.aws_iam_policy_document.sts-master-iam-assume-role-policy.json
  tags               = local.common-tags
}

resource "aws_iam_policy" "sts-master" {
  name   = "<ポリシー名>"
  policy = data.aws_iam_policy_document.sts-master-role-policy.json
}

resource "aws_iam_role_policy_attachment" "sts-master" {
  role       = aws_iam_role.sts-master-role.name
  policy_arn = aws_iam_policy.sts-master.arn
}

data "aws_iam_policy_document" "sts-master-iam-assume-role-policy" {
  statement {
    sid    = "IAMAssumeRole202310051100"
    effect = "Allow"

    principals {
      type        = "AWS"
      identifiers = ["*"]
    }

    condition {
      test     = "StringLike"
      variable = "aws:userid"
      values   = ["<AWS Copilot で生成される IAM ロールのロールID>:*"]
    }

    actions = [
      "sts:AssumeRole",
    ]
  }
}

data "aws_iam_policy_document" "sts-master-role-policy" {
  statement {
    sid    = "S3Basic202309261600"
    effect = "Allow"

    actions = [
      "s3:PutObject",
    ]

    resources = [
      "<許可するS3キー>",
    ]
  }
}
```

ここで `assume_role_policy` として、以下の指定をしているところが肝です

```terraform
    condition {
      test     = "StringLike"
      variable = "aws:userid"
      values   = ["<AWS Copilot で生成される IAM ロールのロールID>:*"]
    }
```

コンテナに割り当てられる IAM ユーザーはコンテナが起動されるたびに変化するため、直接的にユーザーを指定できません
そのため、許可するときには `StringLike` で `aws:userid` を `<AWS Copilot で生成される IAM ロールのロールID>:*` として、ワイルドカードを使って指定します
ユーザー ID は IAM ユーザー名や IAM ロール名とは別の値であることに注意してください
ユーザー ID は IAM ユーザーや IAM ロールが生成されるときに自動的に設定される値であ理、こちらからは設定できない値であるため、作成後に参照する必要があります

Copilot で作成したロールは以下のコマンドで確認できます

```bash
$ copilot svc show --resources
...
    AWS::IAM::Role                             <ロール名>
```

取得したロール名を使って、ロールIDを取得します

```bash
$ aws iam get-role --role-name <ロール名>
{
    "Role": {
        "Path": "/",
        "RoleName": "<ロール名>",
        "RoleId": "<ロールID>",
...
```

このようにして取得したロールIDを Terraform に変数経由で渡すことで、コンテナから一時認証情報が設定されるようになります

## まとめ

フロントエンドと S3 で直接通信することで、ファイルアップロード処理のサーバー負荷を最小化することができます

　また、 `assume_role_policy` を適切に設定することで、特定のコンテナにのみ一時認証情報発行の権限を設定することができます