こしあん
2025-02-03

特定VPCだけからアクセス可能なS3を作る


1{icon} {views}


ゲートウェイ型VPCエンドポイントを利用し、バケットポリシーで特定VPCのみS3にアクセスできる構成例を解説。誤設定によるアクセス不能リスクと、バケットポリシーの保険設定の重要性も書きました。

はじめに

  • 特定のVPCからのみアクセスできるS3を定義してみた
  • ゲートウェイ型のVPCエンドポイントとS3のバケットポリシーの併用
  • S3のバケットポリシーはミスるとルートアカウントでしか消せないのができるので注意が必要

アーキテクチャー

  • 2個VPCを定義する
  • プライベートサブネットにEC2をデプロイし、そこからS3にアクセスする。わかりやすいようにNATをつけておく
    • プライベートサブネットにしているのは、Session Managerがやりやすいため。テスト時にSession Manager経由で接続する
  • 各VPCにゲートウェイ型のVPCエンドポイントを追加し、ルートテーブルに紐づける。そこからS3への通信をオフロード
  • S3のバケットポリシーで、VPC1のVPCエンドポイントからの接続のみ許可することで、VPC1からのみ接続できるバケットを実現
  • ただし、バケットポリシーはミスるとアクセス不能なバケットができあがるため、保険としてIAMユーザーやルートユーザーは許可しておく(実験用の設定)

重要な注意

バケットポリシーは拒否の条件を間違えると消せないバケットができあがります。今回はVPCエンドポイントからしか許可していないため、それが消えてしまうとバケットポリシーも編集できずにルートユーザーから操作するしかなくなります。保険のためにルートユーザーが使えるアカウント(例:個人アカウント)でやるのをおすすめします

参考:アクセス不能になったS3バケットのバケットポリシーを削除する方法

今回はこの対策として、IAMユーザーは許可しておくというのをやっていますが、SSOの場合だと少し変わるので、この記事のように特定IPレンジ(例:自分のIP)を許可するは結構わかりやすいやり方かなと思います。

VPCエンドポイントの作り方

以前検証したこちらの記事を参照。S3の場合は、このようにGateway型のエンドポイントを定義してルートテーブルに追加すればOK。Gateway型はDynamoDBとS3で使えて無料。

VPCゲートウェイエンドポイントでS3への通信をオフロードする

resource "aws_vpc_endpoint" "s3_vpc1" {
  vpc_id            = module.vpc1.vpc_id
  service_name      = "com.amazonaws.${data.aws_region.current.name}.s3"
  vpc_endpoint_type = "Gateway"
  # ここではプライベートサブネットのルートテーブルに関連付け
  route_table_ids = [module.vpc1.private_route_table_id]
}

バケットポリシー

  • ホワイトリストをAllowで定義すると、EC2にS3FullAccessをもたせたときに貫通してしまうので、IAMポリシー同様に「Deny~NotEquals~」でホワイトリストを追加するのが確実
  • ただ、ミスったときにバケットがアクセス不能になる(削除すら不能)なのは実験時にいろいろ面倒なので、IAMユーザーは許可してみた。ここをIP条件で許可するのも良いと思う。
    • ただし、IAMポリシーと異なり、:users/*の書き方はできない。IAMユーザーを個別に追加する必要がある
  • ちなみにどうしようもなくなったらルートユーザーでやればバケットポリシーは消せる。ただこれは最終手段。
data "aws_iam_policy_document" "s3_bucket_policy" {
  statement {
    sid    = "DenyRequestsNotFromVPC1EndpointOrAllowedUser"
    effect = "Deny"

    # 保険としてIAMユーザーとルートユーザーは許可しておく。SSO経由の場合は違うので注意
    not_principals {
      type        = "AWS"
      identifiers = [
        "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root",
        "arn:aws:iam::${data.aws_caller_identity.current.account_id}:user/${var.your_iam_user_name}"
      ]
    }

    actions = [
      "s3:*"
    ]

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

    condition {
      test     = "StringNotEquals"
      variable = "aws:sourceVpce"
      values   = [aws_vpc_endpoint.s3_vpc1.id]
    }
  }
}

resource "aws_s3_bucket_policy" "dummy_policy" {
  bucket = aws_s3_bucket.dummy.bucket
  policy = data.aws_iam_policy_document.s3_bucket_policy.json
}

結果

VPC1からアクセス→ファイル取得可能

[root@ip-10-0-5-167 ~]# aws s3 ls
2025-02-02 16:45:39 dummy-bucket-001fca5c5e5d
[root@ip-10-0-5-167 ~]# aws s3 ls s3://dummy-bucket-001fca5c5e5d
2025-02-02 16:40:35         17 dummy.txt
[root@ip-10-0-5-167 ~]# aws s3 cp s3://dummy-bucket-001fca5c5e5d/dummy.txt .
download: s3://dummy-bucket-001fca5c5e5d/dummy.txt to ./dummy.txt
[root@ip-10-0-5-167 ~]# cat dummy.txt
Hello dummy file![root@ip-10-0-5-167 ~]#

VPCからアクセス→バケットポリシーに拒否られてファイル取得不可能

  • ec2_s3access_roleはS3FullAccessを持たせているので、S3のバケットポリシーで拒否られている
[root@ip-10-1-5-190 ~]# aws s3 ls
2025-02-02 16:45:39 dummy-bucket-001fca5c5e5d
[root@ip-10-1-5-190 ~]# aws s3 ls s3://dummy-bucket-001fca5c5e5d

An error occurred (AccessDenied) when calling the ListObjectsV2 operation: User: arn:aws:sts::123456789012:assumed-role/ec2_s3access_role/i-064cf8349ce31f6fc is not authorized to perform: s3:ListBucket on resource: "arn:aws:s3:::dummy-bucket-001fca5c5e5d" with an explicit deny in a resource-based policy

マネジメントコンソールから確認

バケットポリシーはこうなっている

アップロードしたファイルはマネジメントコンソールからは見れる

全体コード

VPCの定義はこちらのリポジトリのmodules/vpcを利用

vpc_clusters.tf

data "aws_region" "current" {}

data "aws_caller_identity" "current" {}

##############################
# VPCモジュールのインスタンス #
##############################

module "vpc1" {
  source         = "./modules/vpc"
  vpc_name       = "vpc1"
  vpc_cidr_block = "10.0.0.0/20"
  # availability_zonesはmodules/vpc/variables.tfのdefaultが利用されます
}

module "vpc2" {
  source         = "./modules/vpc"
  vpc_name       = "vpc2"
  vpc_cidr_block = "10.1.0.0/20"
}

######################################
# 各VPCに対するS3ゲートウェイエンドポイント #
######################################

resource "aws_vpc_endpoint" "s3_vpc1" {
  vpc_id            = module.vpc1.vpc_id
  service_name      = "com.amazonaws.${data.aws_region.current.name}.s3"
  vpc_endpoint_type = "Gateway"
  # ここではプライベートサブネットのルートテーブルに関連付け
  route_table_ids = [module.vpc1.private_route_table_id]
}

resource "aws_vpc_endpoint" "s3_vpc2" {
  vpc_id            = module.vpc2.vpc_id
  service_name      = "com.amazonaws.${data.aws_region.current.name}.s3"
  vpc_endpoint_type = "Gateway"
  route_table_ids   = [module.vpc2.private_route_table_id]
}

###############################
# S3バケットとダミーファイルアップロード #
###############################

# 一意のバケット名にするため、random_idを使用
resource "random_id" "bucket_id" {
  byte_length = 6
}

resource "aws_s3_bucket" "dummy" {
  bucket        = "dummy-bucket-${random_id.bucket_id.hex}"
  force_destroy = true
}

# ローカルに配置している "dummy.txt" ファイルをアップロード
resource "aws_s3_object" "dummy_file" {
  bucket  = aws_s3_bucket.dummy.bucket
  key     = "dummy.txt"
  content = "Hello dummy file!"
}

#########################################################
# S3バケットポリシー: vpc1のS3エンドポイントからのアクセスのみ許可 #
#########################################################

data "aws_iam_policy_document" "s3_bucket_policy" {
  statement {
    sid    = "DenyRequestsNotFromVPC1EndpointOrAllowedUser"
    effect = "Deny"

    # 保険としてIAMユーザーとルートユーザーは許可しておく。SSO経由の場合は違うので注意
    not_principals {
      type        = "AWS"
      identifiers = [
        "arn:aws:iam::${data.aws_caller_identity.current.account_id}:root",
        "arn:aws:iam::${data.aws_caller_identity.current.account_id}:user/${var.your_iam_user_name}"
      ]
    }

    actions = [
      "s3:*"
    ]

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

    condition {
      test     = "StringNotEquals"
      variable = "aws:sourceVpce"
      values   = [aws_vpc_endpoint.s3_vpc1.id]
    }
  }
}

resource "aws_s3_bucket_policy" "dummy_policy" {
  bucket = aws_s3_bucket.dummy.bucket
  policy = data.aws_iam_policy_document.s3_bucket_policy.json
}

ec2.tf

###############################################
# 共通のIAMロール/インスタンスプロファイル作成 #
###############################################

resource "aws_iam_role" "ec2_role" {
  name = "ec2_s3access_role"

  assume_role_policy = jsonencode({
    Version = "2012-10-17",
    Statement = [{
      Effect    = "Allow",
      Principal = { Service = "ec2.amazonaws.com" },
      Action    = "sts:AssumeRole"
    }]
  })
}

# Session Manager用のマネージドポリシー
resource "aws_iam_role_policy_attachment" "ssm" {
  role       = aws_iam_role.ec2_role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}

# S3フルアクセスのマネージドポリシー
resource "aws_iam_role_policy_attachment" "s3" {
  role       = aws_iam_role.ec2_role.name
  policy_arn = "arn:aws:iam::aws:policy/AmazonS3FullAccess"
}

resource "aws_iam_instance_profile" "ec2_profile" {
  name = "ec2_s3access_profile"
  role = aws_iam_role.ec2_role.name
}

#############################################
# Security Group (SSH を使わず Session Manager 接続想定なのでインバウンドは最小限)
#############################################
resource "aws_security_group" "ec2_vpc1_sg" {
  name   = "ec2-vpc1-sg"
  vpc_id = module.vpc1.vpc_id

  egress {
    from_port        = 0
    to_port          = 0
    protocol         = -1
    cidr_blocks      = ["0.0.0.0/0"]
    ipv6_cidr_blocks = ["::/0"]
  }
}

resource "aws_security_group" "ec2_vpc2_sg" {
  name   = "ec2-vpc2-sg"
  vpc_id = module.vpc2.vpc_id

  egress {
    from_port        = 0
    to_port          = 0
    protocol         = -1
    cidr_blocks      = ["0.0.0.0/0"]
    ipv6_cidr_blocks = ["::/0"]
  }
}

#############################################
# EC2インスタンス作成 (各VPCのプライベートサブネット) #
#############################################

# Amazon Linux 2023 の最新AMIを取得
data "aws_ami" "amazon_linux" {
  most_recent = true
  owners      = ["137112412989"] # AmazonのAMI所有者ID

  filter {
    name = "name"
    # Amazon Linux 2023 AMIの名前パターン。minimumを除外する
    values = ["al2023-ami-2023*-kernel-*-x86_64"]
  }

  filter {
    name   = "architecture"
    values = ["x86_64"]
  }

  filter {
    name   = "virtualization-type"
    values = ["hvm"]
  }
}
resource "aws_instance" "ec2_vpc1" {
  ami                         = data.aws_ami.amazon_linux.id
  instance_type               = "t3.micro"
  subnet_id                   = element(module.vpc1.private_subnet_ids, 0)
  iam_instance_profile        = aws_iam_instance_profile.ec2_profile.name
  vpc_security_group_ids      = [aws_security_group.ec2_vpc1_sg.id]
  associate_public_ip_address = false

  tags = {
    Name = "ec2-vpc1"
  }
}

resource "aws_instance" "ec2_vpc2" {
  ami                         = data.aws_ami.amazon_linux.id
  instance_type               = "t3.micro"
  subnet_id                   = element(module.vpc2.private_subnet_ids, 0)
  iam_instance_profile        = aws_iam_instance_profile.ec2_profile.name
  vpc_security_group_ids      = [aws_security_group.ec2_vpc2_sg.id]
  associate_public_ip_address = false

  tags = {
    Name = "ec2-vpc2"
  }
}

所感

  • 理屈的には簡単なんだけど、思ったよりバケットポリシーに対して神経をすり減らすなという印象


Shikoan's ML Blogの中の人が運営しているサークル「じゅ~しぃ~すくりぷと」の本のご案内

技術書コーナー

北海道の駅巡りコーナー


Add a Comment

メールアドレスが公開されることはありません。 が付いている欄は必須項目です