【pre:Invent新サービス】AWS Transfer Family web appsをTerraformで(無理矢理)構築する

こちらは[Japan AWS Ambassadors Advent Calendar 2024](https://qiita.com/advent-calendar/2024/japan-aws-ambassador) の15日目の記事です。

こんにちは。皆さん、re:Invent2024は楽しめましたか？
今年も、生成AIや機械学習、強力なDRの機能を持った新しいデータベースのアセットを中心に、まさに「顧客の課題を中心にとらえた」AWSらしい新サービスやアップデートがたくさん紹介されましたね。私も現地でKeyNoteを聞きながら盛り上がっていました。

一方、re:Inventの数週間前から怒涛のアップデートが発表されたpre:Inventについても魅力的なアップデートがたくさん来ています。

本記事では、pre:Inventの中で発表されたAWS Transfer Family web appsをお手軽に構築できるようTerraformのコードに書き起こしました。
AWS Transfer Family web appsは、まだAWS Providerからは提供されていないため、ここだけCLIで作りますが、それ以外の周辺リソースを一気に作り上げられるようにしています。

さらに、簡易的なコンテンツ管理システムを実装できるのではないかと利用ケースを考察しています。

# AWS Transfer Family web apps って何？
一言で言うと「開発者ではない運用者やサービス主幹の人がお手軽かつセキュアにシステムにファイルアップロードするためのWebインタフェースのマネージドサービスです。

システム開発のよくある要件として「運用者がファイルをアップロードできること。専用の認証と専用のWeb画面を具備すること」と書かれていたりしますよね？バックエンドの機能にコストをあまりかけたくないので「マネージメントコンソールからのアップロードさせてもらえないですかね……？」と相談しつつも利用部門から「ダメ」と言われて泣く泣くCognitoと簡易的なお手製の画面とAPIを用意した覚えがある人が多くいるかと思いますが、今後はそんなものは不要になります、

https://aws.amazon.com/jp/blogs/news/announcing-aws-transfer-family-web-apps-for-fully-managed-amazon-s3-file-transfers/

料金は、ユニット数(同時接続数)に対する1時間単位の従量課金です。
東京リージョンでは0.5USD/時間・ユニットで、ずっと起動していると激安とは言えない金額になります。

https://aws.amazon.com/aws-transfer-family/pricing/?nc=sn&loc=3

# 今回の構築対象の構成
それでは、コードを書いていきましょう。

![構成図.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/111314/7aee9133-9f7f-3145-3b49-66b4179da747.png)

一見するとシンプルですが、S3のアクセス制御にS3 Access Grantsを使ったりしていて、少し癖があります。

なお、以降に記載するHCLのコード例ではセキュリティ関連はあまり重視した記載になっていないため、実運用をする際はそのあたりを充分に強化したうえで利用してください。

なお、構成を作るにあたっては、AWS Transfer Familyのユーザーガイドを参考にしているため、あわせてご確認ください。2024/12/15現在、まだ日本語のドキュメントは提供されていません。

https://docs.aws.amazon.com/transfer/latest/userguide/web-app.html

# 事前準備(IAM Identity Centerの有効化)
まずは、IAM Identity Centerを有効しておきましょう。
アカウント共通の設定のため？なのか、この部分はTerraformでは触ることができません。

https://docs.aws.amazon.com/ja_jp/singlesignon/latest/userguide/get-set-up-for-idc.html

有効化後、以下のデータソースを使うことで参照ができます。

```HCL
data "aws_ssoadmin_instances" "example" {}
```

# Terraformのコードを書いていく
## IAMロール
AWS Transfer Familyと、S3 Access Grants用のIAMロールをそれぞれ用意します。

```HCL:AWS Transfer Family Web Apps用IAMロール
resource "aws_iam_role" "transfer" {
  name               = local.iam_transfer_role_name
  assume_role_policy = data.aws_iam_policy_document.transfer_assume.json
}

data "aws_iam_policy_document" "transfer_assume" {
  statement {
    effect = "Allow"

    principals {
      type = "Service"
      identifiers = [
        "transfer.amazonaws.com",
      ]
    }

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

resource "aws_iam_role_policy" "transfer" {
  name   = local.iam_transfer_policy_name
  role   = aws_iam_role.transfer.id
  policy = data.aws_iam_policy_document.transfer_custom.json
}

data "aws_iam_policy_document" "transfer_custom" {
  statement {
    effect = "Allow"

    actions = [
      "s3:GetDataAccess",
      "s3:ListCallerAccessGrants",
      "s3:ListAccessGrantsInstances",
    ]

    resources = [
      "*",
    ]
  }
}
```

S3 Access Grantsのロールは、Amazon S3のユーザーガイドを参考にします。

https://docs.aws.amazon.com/AmazonS3/latest/userguide/access-grants-location-register.html


```HCL:S3 Access Grants用ロール
resource "aws_iam_role" "access_grants_s3" {
  name               = local.iam_access_grants_s3_role_name
  assume_role_policy = data.aws_iam_policy_document.access_grants_s3_assume.json
}

data "aws_iam_policy_document" "access_grants_s3_assume" {
  statement {
    effect = "Allow"

    principals {
      type = "Service"
      identifiers = [
        "access-grants.s3.amazonaws.com",
      ]
    }

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

resource "aws_iam_role_policy" "access_grants_s3" {
  name   = local.iam_access_grants_s3_policy_name
  role   = aws_iam_role.access_grants_s3.id
  policy = data.aws_iam_policy_document.access_grants_s3_custom.json
}

data "aws_iam_policy_document" "access_grants_s3_custom" {
  statement {
    sid = "BucketLevelReadPermissions"

    effect = "Allow"

    actions = [
      "s3:ListBucket",
    ]

    resources = [
      "${aws_s3_bucket.example.arn}",
    ]

    condition {
      test     = "StringEquals"
      variable = "aws:ResourceAccount"
      values   = [data.aws_caller_identity.self.id]
    }

    condition {
      test     = "ArnEquals"
      variable = "s3:AccessGrantsInstanceArn"
      values   = [aws_s3control_access_grants_instance.example.access_grants_instance_arn]
    }
  }


  statement {
    sid = "ObjectLevelReadPermissions"

    effect = "Allow"

    actions = [
      "s3:GetObject",
      "s3:GetObjectVersion",
      "s3:GetObjectAcl",
      "s3:GetObjectVersionAcl",
      "s3:ListMultipartUploadParts",
    ]

    resources = [
      "${aws_s3_bucket.example.arn}/*",
    ]

    condition {
      test     = "StringEquals"
      variable = "aws:ResourceAccount"
      values   = [data.aws_caller_identity.self.id]
    }

    condition {
      test     = "ArnEquals"
      variable = "s3:AccessGrantsInstanceArn"
      values   = [aws_s3control_access_grants_instance.example.access_grants_instance_arn]
    }
  }

  statement {
    sid = "ObjectLevelWritePermissions"

    effect = "Allow"

    actions = [
      "s3:PutObject",
      "s3:PutObjectAcl",
      "s3:PutObjectVersionAcl",
      "s3:DeleteObject",
      "s3:DeleteObjectVersion",
      "s3:AbortMultipartUpload",
    ]

    resources = [
      "${aws_s3_bucket.example.arn}/*",
    ]

    condition {
      test     = "StringEquals"
      variable = "aws:ResourceAccount"
      values   = [data.aws_caller_identity.self.id]
    }

    condition {
      test     = "ArnEquals"
      variable = "s3:AccessGrantsInstanceArn"
      values   = [aws_s3control_access_grants_instance.example.access_grants_instance_arn]
    }
  }
}
```

## IAM Identity Center
IAM Identity Cneterでユーザを作成します。
```identity_store_id```は、先に作成したIAM Identity Centerのデータソースを参照します。

```HCL
resource "aws_identitystore_user" "example" {
  identity_store_id = tolist(data.aws_ssoadmin_instances.example.identity_store_ids)[0]

  display_name = "foo bar"
  user_name    = "example"

  name {
    given_name  = "foo"
    family_name = "bar"
  }

  emails {
    value = "hoge@example.com"
  }
}

resource "aws_ssoadmin_application_assignment" "example" {
  depends_on = [ terraform_data.sso_admin_list_applications ]

  application_arn = file("../output/application-arn.txt")
  principal_id    = aws_identitystore_user.example.user_id
  principal_type  = "USER"
}
```

## S3バケット
今回、上記したように、コンテンツ管理システム的な扱いができないかを考察するために、Amazon S3の静的ウェブサイトホスティング機能を有効化します。

:::note warn
以下の例では、静的ウェブサイトホスティングを簡易に実装するために```aws_s3_bucket_public_access_block```をすべて```false```にしていますが、セキュリティ面を考慮する場合、本来は推奨されない設定方法です。Amazon CloudFrontを前段に置いて、OACの設定でS3バケットへの直接アクセスをさせないようにする方が良いです。
:::

```HCL
resource "aws_s3_bucket" "example" {
  bucket = local.s3_bucket_name
}

resource "aws_s3_bucket_ownership_controls" "example" {
  bucket = aws_s3_bucket.example.id

  rule {
    object_ownership = "BucketOwnerEnforced"
  }
}

resource "aws_s3_bucket_public_access_block" "example" {
  bucket = aws_s3_bucket.example.id

  block_public_acls       = false
  block_public_policy     = false
  ignore_public_acls      = false
  restrict_public_buckets = false
}

resource "aws_s3_bucket_policy" "example" {
  bucket = aws_s3_bucket.example.id
  policy = data.aws_iam_policy_document.s3_bucket_policy.json
}

data "aws_iam_policy_document" "s3_bucket_policy" {
  statement {
    sid = "PublicReadGetObject"

    effect = "Allow"

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

    actions = [
      "s3:GetObject",
    ]

    resources = [
      "${aws_s3_bucket.example.arn}/*",
    ]
  }
}

resource "aws_s3_bucket_versioning" "example" {
  bucket = aws_s3_bucket.example.id

  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_website_configuration" "example" {
  bucket = aws_s3_bucket.example.id

  index_document {
    suffix = "index.html"
  }

  error_document {
    key = "error.html"
  }
}
```

## S3 Access Grantsの設定
以下のようにS3 Access Grantsを有効化します。
IAM Identity Centerに対して権限を付与するために、作成時に```aws_ssoadmin_instances```のデータソースを参照してください。

```HCL
resource "aws_s3control_access_grants_instance" "example" {
  identity_center_arn = tolist(data.aws_ssoadmin_instances.example.arns)[0]
}

resource "aws_s3control_access_grants_location" "example" {
  depends_on = [aws_s3control_access_grants_instance.example]

  iam_role_arn   = aws_iam_role.access_grants_s3.arn
  location_scope = "s3://${aws_s3_bucket.example.bucket}"
}

resource "aws_s3control_access_grant" "example" {
  access_grants_location_id = aws_s3control_access_grants_location.example.access_grants_location_id
  permission                = "READWRITE"

  access_grants_location_configuration {
    s3_sub_prefix = "*"
  }

  grantee {
    grantee_type       = "DIRECTORY_USER"
    grantee_identifier = aws_identitystore_user.example.user_id
  }
}
```

## AWS Transfer Family web apps
AWS Transfer Family web appsをCLIで作成します。
ただし、データソースの参照を行えるようにするために、```terraform_data```(旧```null_resource```)を使います。

```IdentityCenterConfig```には、IAM Identity Centerのデータソースへの参照と、先に作成した、AWS Transfer用のIAMロールへの参照を行います。

なお、```aws transfer create-web-app```は以下の出力をします。

```JSON
{
  "WebAppId": "webapp-xxxxxxxxxxxxxxxxx"
}
```

WebAppIdは後で使用するため、値のみ抜き出すようjqで編集してファイルに出力しておきます。

```HCL
resource "terraform_data" "transfer_webapps" {
  provisioner "local-exec" {
    command = <<-EOT
      aws transfer create-web-app \
      --identity-provider-details IdentityCenterConfig={InstanceArn=${tolist(data.aws_ssoadmin_instances.example.arns)[0]},Role=${aws_iam_role.transfer.arn}} \
      --web-app-units Provisioned=1 \
      --tags Key=Name,Value=r-kubota \
      | jq -r ".WebAppId" \
      | head -c -1 \
      > ../output/web-app-id.txt
    EOT
  }
}
```

また、AWS Transfer Family web appsを作成すると、IAM Identity Centerのアプリケーションとして自動で登録がされます。後で、IAM Identity Centerで作成したユーザに紐づける必要があるため、このアプリケーションのARNを取得しておきます。

AWS Transfer Family web appsは```"TransferWebApp-[WebAppId]"```という一意な命名をされるため、```aws sso-admin list-applications```の```--filter```で絞り込みを行いたいのですが、残念ながらName属性での絞り込みは機能として存在しないので、jqのselectを使って自前フィルタを作ります。

:::note
Terraformの```file()```関数は、ファイルが存在しない状態でApplyしようとするとエラーになってしまうので、空ファイルをあらかじめ用意しておきましょう。中身が正しく設定されてから実行するよう、```depends_on```を使ってリソース作成順を制御します。
:::

```HCL
resource "terraform_data" "sso_admin_list_applications" {
  depends_on = [ terraform_data.transfer_webapps ]

  provisioner "local-exec" {
    command = <<-EOT
      aws sso-admin list-applications \
      --instance-arn ${tolist(data.aws_ssoadmin_instances.example.arns)[0]} \
      | jq -r '.Applications[] | select(.Name == "TransferWebApp-${file("../output/web-app-id.txt")}") | .ApplicationArn' \
      | head -c -1 \
      > ../output/application-arn.txt
    EOT
  }
}
```

## S3バケットのCORSの設定
先ほど作ったAWS Transfer Family web appsのエンドポイントを、S3バケットのCORSとして設定する必要があるため、以下のように設定します。

```HCL
resource "aws_s3_bucket_cors_configuration" "example" {
  depends_on = [ terraform_data.transfer_webapps ]

  bucket = aws_s3_bucket.example.id

  cors_rule {
    allowed_headers = ["*"]
    allowed_methods = ["GET", "PUT", "POST", "DELETE", "HEAD"]
    allowed_origins = [ "https://${file("../output/web-app-id.txt")}.transfer-webapp.ap-northeast-1.on.aws" ]
    expose_headers = [
      "last-modified",
      "content-length",
      "etag",
      "x-amz-version-id",
      "content-type",
      "x-amz-request-id",
      "x-amz-id-2",
      "date",
      "x-amz-cf-id",
      "x-amz-storage-class",
      "access-control-expose-headers",
    ]
    max_age_seconds = 3000
  }
}
```


## IAM Identity Centerのユーザとアプリケーションの紐づけ
IAM Identity Centerのユーザとアプリケーションを```aws_ssoadmin_application_assignment```のリソースで紐づけます。

```HCL
resource "aws_ssoadmin_application_assignment" "example" {
  depends_on = [ terraform_data.sso_admin_list_applications ]

  application_arn = file("../output/application-arn.txt")
  principal_id    = aws_identitystore_user.example.user_id
  principal_type  = "USER"
}
```

これで、準備は完了です。

# いざ、構築する！
上記の設定まで完了したら```terraform apply```をしましょう。
applyを実行した瞬間と、実際にリソースを作る瞬間で、ファイルで無理矢理参照しているデータのファイルタイムスタンプが違うためにエラーが発生して実行が停止する可能性がありますが、しつこく何回か実行すると全リソースの構築が完了します。

作成したAWS Transfer Family web appsのエンドポイントにアクセスすると、以下のようにバケット選択画面が表示されます。

![キャプチャ1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/111314/1166b63d-78c4-18b0-b66e-eee2cdb5e3e2.png)

ここから、アップロードをしてみましょう。

![キャプチャ2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/111314/33931e08-7988-aa32-efea-6f158fb3dd29.png)

![キャプチャ3.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/111314/2d9d0efa-7f7b-1833-2750-0574fc08d981.png)

![キャプチャ4.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/111314/4f99e94e-0b75-47b1-7f39-9ff51c63843b.png)

良い感じです！
これで、ファイアップローダを簡単に実装できるようになりました！

IAM Identity Centerでは、アイデンティティソースとして外部IDプロバイダを使うこともできます。
既にOktaやAuth0でのSSOをしている場合は、そこと統合したアップローダにすることも簡単にできます。
「よくある要件」をマネージドサービスに寄せることができる素晴らしいサービスだと思います。

# コンテンツ管理はできそうか？
さて、最後に、AWS Transfer Family web appsをコンテンツ管理システムとして扱えるかについての考察です。

結論から言うと、素のままでは扱えません。

いわゆるCMSにあるような予約・プレビュー・ロールバックという機能がないことは(そもそもの目的が違うため)仕方がないとして、AWS Transfer Family web appsでアップロードしたオブジェクトは、Content-Typeがapplication/octet-stream固定になってしまいます。
マネージメントコンソール用にファジーに判断してくれるわけではなさそうです。

このため、格納用バケットと公開用をバケットを分けてAmazon S3トリガーを使ってLambdaでContent-Typeを指定してオブジェクトをコピーするか、CloudFront FunctionsでレスポンスヘッダーのContent-Typeを書き換える(未検証)かが必要になります。

メタデータをアップロード時に指定できるようになると、さらにユースケースが拡がるかもしれません。