S3の からにこもる ぼうぎょりょくが 100あがった

こんにちは。インフラエンジニアの光野です。

AWS re:Invent 2017で次々と新発表があり、ワクワクがとまりません。また最近はACMがDNS検証で証明書を発行できるようになったり、SpotFleetがELB Auto Attachできるようになったり、個人的に嬉しいアップデートが続いています。来年もますますAWSのファンになりそうです。

さて、そんなキラキラ(?)した話題は一旦おいて、本記事では泥臭くAmazon S3の権限管理について考えたいと思います。

f:id:vasilyjp:20171130132135j:plain

S3と権限管理

ご存知の通り、S3は99.999999999%の耐久性と実質無限の容量を持つオブジェクトストレージです。弊社では主にファッションアイテムの画像とコーデ画像の保存先として利用しています。

保存は俺に任せろと言わんばかりのS3ですが、一方でオブジェクトの権限管理は利用者に委ねられています。 「誰が・どのオブジェクトに対して・何ができるか」を適切に把握し管理しない場合、重要なデータがインターネットから閲覧可能になっていた。といったインシデントが発生しかねません。

本記事では、実際に運用することをイメージしつつ権限を設定してみたいと思います。

権限管理

本記事では以下の4要素を使ってS3の権限を操作します。

  1. S3 Bucketポリシー
  2. VPCエンドポイントポリシー
  3. インスタンスプロファイル(IAMロール)
  4. IAMユーザ

状況設定

さて、このような構成を考えます。

f:id:vasilyjp:20171130175146p:plain

要件は次の通りです。

  1. インスタンスはS3へのPut権限を持つ
  2. 画像の参照はCloudFront経由に限定する
  3. 画像の更新はVPCエンドポイント経由に限定する
  4. VPC内からアクセスできるS3 Bucketを限定する
  5. IAMユーザは、運用作業でデータを削除することがある
  6. データは暗号化する

1つずつ設定していきたいと思います。

インスタンスはS3へのPut権限を持つ

手始めに、インスタンスに対してPutObjectの権限を与えます。

f:id:vasilyjp:20171130181351p:plain

設定箇所:インスタンスプロファイル

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "s3:PutObject"
            ],
            "Resource": [
                "arn:aws:s3:::a-service-static/*"
            ]
        }
    ]
}

データを保存するだけであればPutObject1つで可能です。

            "Resource": [
                "arn:aws:s3:::a-service-static/*"
            ]

Bucket以下の任意の要素に対して権限を付与してやります。

画像の参照はCloudFront経由に限定する

次に保存された画像を参照することを考えます。

f:id:vasilyjp:20171130181406p:plain

S3においた画像を公開する場合、オブジェクトのACLをPublicとすることもできますが、ここではCloudFrontを経由して配信します。 OAI(Origin Access Identity)を使って、オブジェクトに対してCloudFront経由でのアクセスを許可します。

設定箇所:S3 Bucketポリシー

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ABCDEFGHIJKLMN"
            },
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::a-service-static/*"
        }
    ]
}

OAIはCloudFrontで事前に作成しておく必要があります。ABCDEFGHIJKLMNの部分がOAI毎のユニークなIDです。

OAIという要素(Principal)に対して権限を付与するので、オブジェクトのACLにPublicを設定する必要はありません。むしろ、Publicを指定してしまうとS3のURL(https://a-service-static.s3.amazonaws.com/...)でもアクセス可能になってしまいます。不要なアクセス経路が増えるのは望ましくありません。

ここで更にCloudFront以外からのアクセスを明示的に拒否してみます。

設定箇所:S3 Bucketポリシー

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "DenyGet",
            "Effect": "Deny",
            "NotPrincipal": {
                "AWS": "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ABCDEFGHIJKLMN"
            },
            "Action": [
                "s3:Get*",
                "s3:List*"
            ],
            "Resource": [
                "arn:aws:s3:::a-service-static",
                "arn:aws:s3:::a-service-static/*"
            ]
        },
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ABCDEFGHIJKLMN"
            },
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::a-service-static/*"
        }
    ]
}

NotPricipalを使って「OAI以外の要素からの参照」を全て却下します。実際の所、このままだとWebコンソールなどでBucketの中を覗くこともできなくなるため、後で少し変更します。

画像の更新はVPCエンドポイント経由に限定する

次に画像の更新をVPCエンドポイント経由に限定してみます。更新をVPCエンドポイント経由に限定することで、不正な更新・削除を防ぐ効果を期待します。

f:id:vasilyjp:20171130181425p:plain

設定箇所:S3 Bucketポリシー

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "DenyUpdate",
            "Effect": "Deny",
            "Principal": "*",
            "NotAction": [
                "s3:Get*",
                "s3:List*"
            ],
            "Resource": [
                "arn:aws:s3:::a-service-static",
                "arn:aws:s3:::a-service-static/*"
            ],
            "Condition": {
                "StringNotEquals": {
                    "aws:sourceVpce": "vpce-12345678"
                }
            }
        }
    ]
}

(参照をOAI経由に限定する部分は省略しています)

まずPrincipalです。

            "Principal": "*",

任意の要素を対象とします。 次にNotActionです。

            "NotAction": [
                "s3:Get*",
                "s3:List*"
            ],

参照系の否定で更新系を表現します。 最後にConditionです。VPCエンドポイントを使わない場合はNATなどのgIPを指定しますが、VPCエンドポイントを通す場合はVPCエンドポイントIDで管理します。

            "Condition": {
                "StringNotEquals": {
                    "aws:sourceVpce": "vpce-12345678"
                }
            }

つまり、任意の要素からの更新系についてアクセス元が特定のVPCエンドポイント以外であれば全て拒否するというポリシーになります。

VPC内からアクセスできるS3 Bucketを限定する

ここまでで、a-service-staticに対するアクセスは「参照:CloudFront経由」「更新:VPCエンドポイント経由」に限定できました。

しかし、これだけではVPC内からa-service-static以外のBucketに対して書き込み(持ち出し)されてしまうかもしれません。

f:id:vasilyjp:20171130190838p:plain

今度はこれを防止します。VPCエンドポイントを使う場合、VPCエンドポイントポリシーを使ってアクセス先Bucketを限定することが可能です。

f:id:vasilyjp:20171130181759p:plain

デフォルトのVPCエンドポイントポリシーは以下のように任意のアクセスを許可する形になっています。

{
    "Statement": [
        {
            "Action": "*",
            "Effect": "Allow",
            "Resource": "*",
            "Principal": "*"
        }
    ]
}

明示的にアクセス先を限定します。

{
    "Version": "2008-10-17",
    "Statement": [
        {
            "Effect": "Deny",
            "Principal": "*",
            "NotAction": [
                "s3:Get*",
                "s3:List*"
            ],
            "NotResource": [
                "arn:aws:s3:::a-service-static",
                "arn:aws:s3:::a-service-static/*"
            ]
        },
        {
            "Effect": "Deny",
            "Principal": "*",
            "Action": [
                "s3:Get*",
                "s3:List*"
            ],
            "NotResource": [
                "arn:aws:s3:::apt.mackerel.io",
                "arn:aws:s3:::apt.mackerel.io/*"
            ]
        },
        {
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:PutObject",
            "Resource": [
                "arn:aws:s3:::a-service-static/*"
            ]
        },
        {
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:GetObject",
            "Resource": [
                "arn:aws:s3:::apt.mackerel.io/*"
            ]
        }
    ]
}

突然、apt.mackerel.ioという要素が登場していますが、順を追って説明します。 まず、大まかな流れは以下のとおりです。

  1. 特定のBucket以外に対する更新系を禁止
  2. 特定のBucket以外に対する参照系を禁止
  3. 特定のBucketに対する更新系を明示的に許可
  4. 特定のBucketに対する参照系を明示的に許可

VPCエンドポイントポリシーには注意すべき点が2つ存在します。 1つ目は「明示的な許可」です。アクセスを許可する要素を明示的に指定しない場合、VPCの内側に存在する要素(例えばEC2インスタンス)が対象へのアクセス権限を持っていたとしてもアクセスが却下されてしまいます。 2つ目は「自己所有以外のS3 Bucketに対する考慮」です。弊社で採用している監視ツール:Mackerelを例にします。 MackerelのDebianパッケージはapt.mackerel.ioというドメインからダウンロードされますが、これの実体はS3 Bucketです。ポリシーで明示しておかないと、インストールに失敗します。

このように、VPCエンドポイントポリシーを設定する場合は自己所有以外のS3 Bucketへのアクセスも洗い出しておかないと、構成管理などで問題となります。これを踏まえて読み解きます。

  1. a-service-static以外への更新系を全て禁止
    • 事前に定められたBucket以外への書き込みを禁止
  2. apt.mackerel.io以外への参照系を全て禁止
    • 運用上必要なファイル以外の読み込みを禁止
  3. a-service-staticへの更新系を明示的に許可
  4. apt.mackerel.ioへの参照系を明示的に許可
とはいえ、あくまでもBucketへのアクセスだけなので、どこかの知らないサーバに対してWebAPIで持ち出し。といった不正な通信を防ぐ効果はありません。

IAMユーザは、運用作業でデータを削除することがある

最後はユーザ操作に対するフォローです。

f:id:vasilyjp:20171130181818p:plain

ここまでの設定で、アプリケーションに必要な権限は明示的に指定できました。しかし、実運用において、これだけではあまりにも窮屈すぎます。 WebコンソールからBucketの中を確認し、時には人の手で更新する運用が発生するかもしれません。そのため、管理者が触れる用にポリシーを変更します。

設定箇所:S3 Bucketポリシー

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "DenyUpdate",
            "Effect": "Deny",
            "NotPrincipal": {
                "AWS": [
                    "arn:aws:iam::123456789098:user/ops-user1",
                    "arn:aws:iam::123456789098:user/ops-user2"
                ]
            },
            "NotAction": [
                "s3:Get*",
                "s3:List*"
            ],
            "Resource": [
                "arn:aws:s3:::a-service-static",
                "arn:aws:s3:::a-service-static/*"
            ],
            "Condition": {
                "StringNotEquals": {
                    "aws:sourceVpce": "vpce-12345678"
                }
            }
        },
        {
            "Sid": "DenyGet",
            "Effect": "Deny",
            "NotPrincipal": {
                "AWS": [
                    "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ABCDEFGHIJKLMN",
                    "arn:aws:iam::123456789098:user/ops-user1",
                    "arn:aws:iam::123456789098:user/ops-user2"
                ]
            },
            "Action": [
                "s3:Get*",
                "s3:List*"
            ],
            "Resource": [
                "arn:aws:s3:::a-service-static",
                "arn:aws:s3:::a-service-static/*"
            ]
        }
    ]
}

わかりづらいですが、NotPrincipalにIAMユーザが追加されています。

            "Sid": "DenyUpdate",
            "Effect": "Deny",
            "NotPrincipal": {
                "AWS": [
                    "arn:aws:iam::123456789098:user/ops-user1",
                    "arn:aws:iam::123456789098:user/ops-user2"
                ]
            },

            "Sid": "DenyGet",
            "Effect": "Deny",
            "NotPrincipal": {
                "AWS": [
                    "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ABCDEFGHIJKLMN",
                    "arn:aws:iam::123456789098:user/ops-user1",
                    "arn:aws:iam::123456789098:user/ops-user2"
                ]
            },

あとはこのユーザがS3に対する適切な権限を持っていれば、Webコンソールから参照・更新が可能です。

設定箇所:IAMユーザ

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "s3:GetObject*",
                "s3:PutObject*",
                "s3:DeleteObject*",
                "s3:List*"
            ],
            "Resource": [
                "arn:aws:s3:::a-service-static/*",
                "arn:aws:s3:::a-service-static",
             ],
            "Effect": "Allow"
        },
        {
            "Action": [
                "s3:ListAllMyBuckets"
            ],
            "Resource": [
                "arn:aws:s3:::*"
            ],
            "Effect": "Allow"
        }
    ]
}
AssumeRoleを使うことで、ユーザ一人ひとりを列挙しない方法もありますが、本記事の範囲を外れるため割愛します

折角なのでもうひと工夫します。IAMユーザに対して権限を付与する場合、awscliによるアクセスについても検討する必要があります。 ユーザによって発行されたアクセストークンがあれば実際が誰であってもアクセスできるためです。トークンの発行を禁止するというのも1つの手段ですが、ここではIP制限をかけることでリスク低減を図ります。

設定箇所:S3 Bucketポリシー

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "DenyGet",
            "Effect": "Deny",
            "NotPrincipal": {
                "AWS": [
                    "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ABCDEFGHIJKLMN",
                    "arn:aws:iam::123456789098:user/ops-user1",
                    "arn:aws:iam::123456789098:user/ops-user2"
                ]
            },
            "Action": [
                "s3:Get*",
                "s3:List*"
            ],
            "Resource": [
                "arn:aws:s3:::a-service-static",
                "arn:aws:s3:::a-service-static/*"
            ]
        },
        {
            "Effect": "Deny",
            "Principal": {
                "AWS": [
                    "arn:aws:iam::123456789098:user/ops-user1",
                    "arn:aws:iam::123456789098:user/ops-user2"
                ]
            },
            "Action": "*",
            "Resource": [
                "arn:aws:s3:::a-service-static",
                "arn:aws:s3:::a-service-static/*"
            ],
            "Condition": {
                "NotIpAddress": {
                    "aws:SourceIp": "xx.xx.xx.xx/32"
                }
            }
        }
    ]
}

Denyポリシーを1つ増やしました。ユーザに対して、特定のIP以外からのアクセスを禁止するというものです。これをオフィスのgIPとすることで、執務エリア、もしくはVPNを貼った状態でなければアクセスできないという状態を作ることが可能です。

データは暗号化する

おまけで、データの暗号化についてもポリシーで強制してしまいましょう。

f:id:vasilyjp:20171130181836p:plain

S3はオブジェクトを保存する際に様々な手法でそれを暗号化することができます。幾つかの暗号化方式に対しては、ポリシーでそれの利用を強制することが可能です。ここでは、SSE-AES256を強制してます。

設定箇所:S3 Bucketポリシー

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Deny",
            "Principal": "*",
            "Action": "s3:PutObject",
            "Resource": "arn:aws:s3:::a-service-static/*",
            "Condition": {
                "StringNotEquals": {
                    "s3:x-amz-server-side-encryption": "AES256"
                }
            }
        }
    ]
}

暗号化用のヘッダがない場合、オブジェクトの保存が失敗するようになります。

設定内容まとめ

設定内容をまとめます。

設定箇所:S3 Bucketポリシー

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Deny",
            "Principal": "*",
            "Action": "s3:PutObject",
            "Resource": "arn:aws:s3:::a-service-static/*",
            "Condition": {
                "StringNotEquals": {
                    "s3:x-amz-server-side-encryption": "AES256"
                }
            }
        },
        {
            "Sid": "DenyUpdate",
            "Effect": "Deny",
            "NotPrincipal": {
                "AWS": [
                    "arn:aws:iam::123456789098:user/ops-user1",
                    "arn:aws:iam::123456789098:user/ops-user2"
                ]
            },
            "NotAction": [
                "s3:Get*",
                "s3:List*"
            ],
            "Resource": [
                "arn:aws:s3:::a-service-static",
                "arn:aws:s3:::a-service-static/*"
            ],
            "Condition": {
                "StringNotEquals": {
                    "aws:sourceVpce": "vpce-12345678"
                }
            }
        },
        {
            "Sid": "DenyGet",
            "Effect": "Deny",
            "NotPrincipal": {
                "AWS": [
                    "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ABCDEFGHIJKLMN",
                    "arn:aws:iam::123456789098:user/ops-user1",
                    "arn:aws:iam::123456789098:user/ops-user2"
                ]
            },
            "Action": [
                "s3:Get*",
                "s3:List*"
            ],
            "Resource": [
                "arn:aws:s3:::a-service-static",
                "arn:aws:s3:::a-service-static/*"
            ]
        },
        {
            "Effect": "Allow",
            "Principal": {
                "AWS": "arn:aws:iam::cloudfront:user/CloudFront Origin Access Identity ABCDEFGHIJKLMN"
            },
            "Action": "s3:GetObject",
            "Resource": "arn:aws:s3:::a-service-static/*"
        },
        {
            "Effect": "Deny",
            "Principal": {
                "AWS": [
                    "arn:aws:iam::123456789098:user/ops-user1",
                    "arn:aws:iam::123456789098:user/ops-user2"
                ]
            },
            "Action": "*",
            "Resource": [
                "arn:aws:s3:::a-service-static",
                "arn:aws:s3:::a-service-static/*"
            ],
            "Condition": {
                "NotIpAddress": {
                    "aws:SourceIp": "xx.xx.xx.xx/32"
                }
            }
        }
    ]
}

設定箇所:VPCエンドポイントポリシー

{
    "Version": "2008-10-17",
    "Statement": [
        {
            "Effect": "Deny",
            "Principal": "*",
            "NotAction": [
                "s3:Get*",
                "s3:List*"
            ],
            "NotResource": [
                "arn:aws:s3:::a-service-static",
                "arn:aws:s3:::a-service-static/*"
            ]
        },
        {
            "Effect": "Deny",
            "Principal": "*",
            "Action": [
                "s3:Get*",
                "s3:List*"
            ],
            "NotResource": [
                "arn:aws:s3:::apt.mackerel.io",
                "arn:aws:s3:::apt.mackerel.io/*"
            ]
        },
        {
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:PutObject",
            "Resource": [
                "arn:aws:s3:::a-service-static/*"
            ]
        },
        {
            "Effect": "Allow",
            "Principal": "*",
            "Action": "s3:GetObject",
            "Resource": [
                "arn:aws:s3:::apt.mackerel.io/*"
            ]
        }
    ]
}

設定箇所:インスタンスプロファイル

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": [
                "s3:PutObject"
            ],
            "Resource": [
                "arn:aws:s3:::a-service-static/*"
            ]
        },

設定箇所:IAMユーザ

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": [
                "s3:GetObject*",
                "s3:PutObject*",
                "s3:DeleteObject*",
                "s3:List*"
            ],
            "Resource": [
                "arn:aws:s3:::a-service-static/*",
                "arn:aws:s3:::a-service-static",
             ],
            "Effect": "Allow"
        },
        {
            "Action": [
                "s3:ListAllMyBuckets"
            ],
            "Resource": [
                "arn:aws:s3:::*"
            ],
            "Effect": "Allow"
        }
    ]
}

まとめ

本記事ではS3に対して、可能な限り明示的に権限管理を行いました。 権限管理は案件毎に要件が全く異なるため、本記事の内容がそのまま使えることはないと思いますが、記述の参考にでもなれば幸いです。

権限管理は、正直複雑ですし窮屈になりがちです。とはいえノーガードというわけにもいきません。 これからもより良いバランスを探していきたいと思います。

終わりに

VASILYでは安全かつ自由なインフラ管理に興味があるエンジニアを募集しています。 興味ある方は以下のリンクからご応募ください。