システム間連携をFTPSでcsvファイルを使って〜〜〜〜!?
3行まとめ
- 対向システム(非AWS)とFTPSを用いたファイル連携が必要になった
- AWS Transfer Familyを使って自AWSアカウントのS3バケットを共有する
- Terraformでコード化する
環境
AWS (特に記載がなければリージョンは全て ap-northeast-1)
Terraform v1.10.5
要件
- 対向システム(非AWS)からファイルを受け取れること
- 対向→自システムへの送信のみ
- 受信ファイルはテキスト(.csv)のみ
- 連携方式はFTPSであること
- SSL/TLS暗号化されたFTP
- 対向システムのIPアドレス以外からのアクセスが拒否できること
設計
今回はFTPSでのファイルの受信のためにAWS Transfer Familyを選択
通信プロトコルにFTPSを選択することで以下の制約が生まれます
-
Transfer Familyにアクセスするユーザの認証機能が必要
今回はTransfer Family専用のユーザを作成することにした
かつ、構築段階では想定ユーザ数が多くないことから
Lambda+SecretManagerを使用したユーザ認証機能を作成することにしました -
Transfer Family サーバーのエンドポイントをVPCでホストする
Transfer Familyではインターネット(AWS外)からのアクセスを想定する場合
サーバーのエンドポイント自体をパブリックアクセス可能とするか
一旦サーバーはVPC内に配置してEIPを用いてインターネットからのアクセスを可能にするか の選択肢があります
が、FTP/ FTPSを利用する場合は前者が選択できないため 一旦VPCでホストします
残りの設計
- サーバーのドメインはRoute53で作成したカスタムドメインを使用
- SSL証明書はACMで発行する
- S3バケットを共有フォルダとする
EFSも選択肢にありますが 今回はS3を選択 - アクセス元IPアドレスのチェックはTransfer familyが配置されるVPCのセキュリティグループで実現する
今回他のAWSリソースとは別で、専用のVPCを作成したのでVPCのセキュリティグループでIPとプロトコルのホワイトリストを管理することにしました
それ以外のユーザ名やPWによる認証はLambdaで行います
今回作成が必要なリソース
- Transfer Family
- S3
- Route 53
- AWS ACM
- VPC
- IAM
- Lambda
ユーザの認証情報を作るよ
今回ユーザの認証情報は Secret Managerで管理します
Cognitoを利用する選択肢もありますが、今回は見込みのユーザ数が少数なので
実装コストの少ないSecret Managerを選択しました
今回(FTPSでの通信)必要な項目は
- Password
- Role(もしくはPolicy)
- HomeDirectoryDetails
の3つ
他にTransfer FamilyのServerIDとUserNameの2つが必要になりますが
これはSecret名に含める形で管理しています(ので実質変数は5つ)
Secert名は後の認証用Lambdaの実装コードと合わせて
aws/transfer/{Transfer FamilyのServer ID}/{User ID}
とします
User IDとPasswordについては自由に決めてOK
Role / Policy
Role/ PolicyはTranser Familyにアクセスし、認証が成功したユーザに付与する権限を指定します
今回はS3バケットを共有フォルダと扱うのでS3関連の権限が必要になります
またアクセスするユーザは我々から見て外部の人間になるので、指定したバケット以外にはアクセス権限は与えないように設定します
{
"Statement": [
{
"Action": [
"s3:ListBucket",
"s3:GetBucketLocation"
],
"Effect": "Allow",
"Resource": "arn:aws:s3:::{バケット名}"
},
{
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject"
],
"Effect": "Allow",
"Resource": "arn:aws:s3:::{バケット名}/*"
}
],
"Version": "2012-10-17"
}
HomeDirectoryDetails
まず 前提としてTransfer Familyサーバーにユーザがログインする際
ランディングディレクトリ(ログイン直後のpwd)をしてする必要があります
指定の方法には以下2種類あります
- HomeDirectory
- HomeDirectoryDetails
HomeDirectoryはS3バケットのルートディレクトリをランディングディレクトリとして扱うパターン
そのため値もS3のバケット名を指定します
一方今回使用する HomeDirectoryDetailsはS3バケット/指定ディレクトリをランディングディレクトリとして扱う際に利用するオプションです
複数ユーザの共有フォルダを単一バケット(内のユーザごとのディレクトリ)にする場合選択します
またHomeDirectoryDetailsを利用する場合は実態のS3のパスとユーザ側に表示するパス名のマッピングを指定できます
[{"Entry":"{ユーザ側に表示したいパス}", "Target":"/{バケット名}/{ディレクトリ}"}]
この辺りの詳細は [公式のドキュメント] > 有効な Lambda 値 を参考ください
(https://docs.aws.amazon.com/ja_jp/transfer/latest/userguide/custom-lambda-idp.html)
認証用のLambdaを作るよ
基本的には AWSのチュートリアル のサンプルコードをベースにしました
import base64
import json
import os
import boto3
from botocore.exceptions import ClientError
def lambda_handler(event, context):
if "username" not in event or "serverId" not in event or "password" not in event:
print("Incoming username or serverId missing - Unexpected")
return {}
if not event.get("protocol", "") == "FTPS":
return {}
input_username = event["username"]
input_serverId = event["serverId"]
input_password = event["password"]
user_secret = get_secret("aws/transfer/" + input_serverId + "/" + input_username)
# secretに対象のユーザ情報がない場合はエラー
# secretの中身はresp_dictに格納される
if user_secret is not None:
user_secret_dict = json.loads(user_secret)
else:
print("Secrets Manager exception thrown")
return {}
return authenticate(user_secret_dict, input_password)
def authenticate(user_secret_dict, input_password):
response = {}
# resp_dictの内容とinputの内容が一致するか確認する
if input_password != user_secret_dict["Password"]:
return {}
if "Role" in user_secret_dict:
response["Role"] = user_secret_dict["Role"]
else:
response["Role"] = ""
# HomeDirectoryDetails: 論理的なディレクトリマッピングを定義
# - 仮想ディレクトリパスとS3バケットの実際のパスをマッピング
# - JSONの配列形式で指定: [{"Entry": "/", "Target": "/bucket_name/path"}]
# - HomeDirectoryTypeを"LOGICAL"に設定する必要がある
# - ユーザーは仮想パスのみを参照可能で、実際のS3パスは隠蔽される
#
# 注意: HomeDirectoryDetailsとHomeDirectoryは排他的な関係
# セキュリティ上、可能な限りHomeDirectoryDetailsの使用を推奨
if "HomeDirectoryDetails" in user_secret_dict:
response["HomeDirectoryDetails"] = user_secret_dict["HomeDirectoryDetails"]
response["HomeDirectoryType"] = "LOGICAL"
else:
return {}
return response
def get_secret(id):
client = boto3.session.Session().client(
service_name="secretsmanager", region_name="ap-northeast-1"
)
try:
resp = client.get_secret_value(SecretId=id)
if "SecretString" in resp:
return resp["SecretString"]
else:
return base64.b64decode(resp["SecretBinary"])
except ClientError as err:
print(
"Error Talking to SecretsManager: "
+ err.response["Error"]["Code"]
+ ", Message: "
+ str(err)
)
return None
Terraformでコード化するよ
# Transfer Family サーバーの作成
resource "aws_transfer_server" "ftps_server" {
protocols = ["FTPS"]
# 認証タイプにLambdaを選択
identity_provider_type = "AWS_LAMBDA"
function = lambda_function.arn // 認証用Lambdaのarnを指定
endpoint_type = "VPC"
force_destroy = true
security_policy_name = "TransferSecurityPolicy-2020-06"
# 共有フォルダの配置先にS3を指定
domain = "S3"
certificate = aws_acm_certificate.acm.arn // ドメイン認証に用いる証明書(ACM)のarnを指定
# 今回は簡易的にシングルAZにしている
endpoint_details {
vpc_id = aws_vpc.dpb-transfer.id
subnet_ids = [aws_subnet.public-1a.id] // マルチAZにするならここに追加
security_group_ids = [aws_security_group.transfer_sg.id]
address_allocation_ids = [aws_eip.transfer_eip_1a.id] // AZの数分eipが必要
}
}
################################
# Transfer Family用のIAMロールとポリシー
################################
resource "aws_iam_role" "transfer-family-role" {
name = "transfer-family-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Action = "sts:AssumeRole"
Effect = "Allow"
Principal = {
Service = "transfer.amazonaws.com"
}
}]
})
}
# S3アクセス用のポリシー
resource "aws_iam_policy" "transfer_s3_access" {
name = "transfer-family-s3-access"
description = "Allow Transfer Family to access S3 bucket"
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Action = [
"s3:ListBucket",
"s3:GetBucketLocation"
]
Effect = "Allow"
Resource = "arn:aws:s3:::xxxxxxx" // Transfer Familyの共有フォルダにするS3バケットを指定
},
{
Action = [
"s3:PutObject",
"s3:GetObject",
"s3:DeleteObject",
"s3:PutObjectAcl"
]
Effect = "Allow"
Resource = "arn:aws:s3:::xxxxxxx/*" // Transfer Familyの共有フォルダにするS3バケットを指定
}
]
})
}
# IAMロールにポリシーをアタッチ
resource "aws_iam_role_policy_attachment" "transfer_s3_attachment" {
role = aws_iam_role.transfer_role.name
policy_arn = aws_iam_policy.transfer_s3_access.arn
}
################################
# FTPSで認証したユーザー用のIAMロールとポリシー
################################
resource "aws_iam_role" "transfer_user_role" {
name = "transfer-user-role"
assume_role_policy = jsonencode({
Version = "2012-10-17",
Statement = [
{
Effect = "Allow",
Principal = {
Service = "transfer.amazonaws.com"
},
Action = "sts:AssumeRole"
}
]
})
}
resource "aws_iam_policy" "transfer_user_policy" {
name = "transfer-user-policy"
description = "Allow Transfer Family User to read and write to the S3 bucket"
policy = jsonencode({
Version = "2012-10-17",
Statement = [
{
Effect = "Allow",
Action = [
"s3:ListBucket",
"s3:GetBucketLocation"
],
Resource = "arn:aws:s3:::xxxxxxx" // Transfer Familyの共有フォルダにするS3バケットを指定
},
{
Effect = "Allow",
Action = [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject"
],
Resource = "arn:aws:s3:::xxxxxxx/*" // Transfer Familyの共有フォルダにするS3バケットを指定
}
]
})
}
resource "aws_iam_role_policy_attachment" "transfer_s3_access_policy_attachment" {
role = aws_iam_role.transfer_s3_access_role.name
policy_arn = aws_iam_policy.transfer_s3_access_policy.arn
}
以下のリソースの記述は省略
- VPC
- Route53
- Lambda
- ACM
- S3
完成!
Terraform Applyで無限にコケたので二度とコケないためにも記事にしました
サーバを構築するだけでも約3万/月吹き飛ぶリッチなサービスですが可愛がっていこうと思います