Amazon S3をprivateなモバイルアプリケーション・バックエンドストレージとして利用する方法

よく訓練されたアップル信者、都元です。mBaaS、流行ってますね! AWSもmBaaSへの取り組みを強めています。

AWSでmBaaS

さて mobile backend as a Service、略してmBaaS(えむばーす)ですが、要するにモバイルアプリケーションに対するバックエンドをサーバに独自実装するのではなく、サービスとして提供されているもののことを言います。

複数のモバイルデバイス間で共通のデータ領域を持ちたい場合や、モバイルデバイス内で処理させるには適切で無い *1処理を行いたい場合、旧来は、そのアプリケーション用のバックエンドサーバを用意し、サーバとモバイルアプリが通信を行って問題解決をしていました。

しかし、多くの一般的なモバイルアプリにおいて、バックエンドサーバの役割には共通点が多いのが一般的です。例えばベタなところで「デバイス間の写真共有アプリ」をイメージした場合、旧来は、写真の保存場所としてサーバを用意していたはずです。その上で、サーバのローカルディスク上に写真を保存します。もしくは、AWSを使っているのであれば、アップロードを受け付けたEC2は、その写真をS3に保存する、というアーキテクチャが良いですね。

2014-10-31_1400-a

ただ、これだけのために *224/365でEC2サーバを運用する必要がありました。たとえアクセスが少ない夜間でも、最低1台できれば2台のEC2が必要です。EC2はAWSの中では比較的高価なリソースであるため、コストの大部分はEC2が占めてしまいます。

そうではなく、画像(等のファイル)の保存であれば、モバイルアプリは「自前のサーバ」にアップロードするのではなく、「S3に直接」アップロードしてしまえばいいではないか。というのが、AWSをmBaaSとして利用する時の考え方です。仮に別途EC2サーバがあるにせよ、もしコレが可能なのであれば、わざわざEC2を経由させて、貴重なコンピューティングリソースを使いながらアップロードする必要が無いことも多いはずです。

2014-10-31_1400-b

CognitoとSTS

ただ、(脚注でも触れましたが)旧来構成のEC2が果たしていた大きな役割が、認証認可です。仮にS3のバケットに対して誰が書き込み(PutObject)してもよく、誰が読み込み(GetObject)してもよく、さらに、Put/Getしたのは誰なのかは判別できなくてよい、というのであれば簡単です。しかし、そんなゆるふわな要件はあまり聞いたことが無いですね。

というのを解決するのがCognitoとSTSです。詳しくは下記を御覧ください。

上記のエントリーでは、Web Identity(や未認証ゲスト)を判定して、AWSの一時キーを得る方法をご紹介しています。これによって「認証」は実現できました。

IAM Policyによるアクセス制御

次は「認可」ですね。

要件

仮の要件として、このアプリはCognitoによる認証を行う前提で、各ユーザはそれぞれpublic領域とprivate領域として自分専用の領域を持ち、それぞれのアクセス権が下記のようになっている、としてみましょう。

public領域 private領域
オーナーが オブジェクト一覧を取得できる? (ListBucket) Yes Yes
オブジェクトを読める? (GetObject) Yes Yes
オブジェクトを書ける? (PutObject/DeleteObject) Yes Yes
オーナー以外が オブジェクト一覧を取得できる? (ListBucket) Yes No
オブジェクトを読める? (GetObject) Yes No
オブジェクトを書ける? (PutObject/DeleteObject) No No

で、ディレクトリ構成はこんな感じにしました。

example-bucket
 ├ public
 │  ├ us-east-1:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa
 │  ├ us-east-1:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb
 │  │  ...
 │  └ userX
 └ private
    ├ us-east-1:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa
    ├ us-east-1:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb
    │  ...
    └ userX

IAMポリシーの実装

以上に沿ったIAMポリシーを書くとこんな感じです。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "AllowPublicHomeListingByAllUsers",
      "Effect": "Allow",
      "Action": [ "s3:ListBucket" ],
      "Resource": [ "arn:aws:s3:::example-bucket" ],
      "Condition": { "StringLike": { "s3:prefix": [ "public/*/*" ]}}
    },
    {
      "Sid": "AllowPrivateHomeListingByOwnerUsers",
      "Effect": "Allow",
      "Action": [ "s3:ListBucket" ],
      "Resource": [ "arn:aws:s3:::example-bucket" ],
      "Condition": { "StringLike": { "s3:prefix": [ "private/${cognito-identity.amazonaws.com:sub}/*" ]}}
    },
    {
      "Sid": "AllowPublicHomeObjectReadingByAllUsers",
      "Effect": "Allow",
      "Action": [ "s3:GetObject" ],
      "Resource": [
        "arn:aws:s3:::example-bucket/public",
        "arn:aws:s3:::example-bucket/public/*"
      ]
    },
    {
      "Sid": "AllowPrivateHomeObjectReadingByOwnerUsers",
      "Effect": "Allow",
      "Action": [ "s3:GetObject" ],
      "Resource": [
        "arn:aws:s3:::example-bucket/private/${cognito-identity.amazonaws.com:sub}",
        "arn:aws:s3:::example-bucket/private/${cognito-identity.amazonaws.com:sub}/*"
      ]
    },
    {
      "Sid": "AllowPublicHomeObjectWritingByOwnerUsers",
      "Effect": "Allow",
      "Action": [
        "s3:PutObject",
        "s3:DeleteObject"
      ],
      "Resource": [
        "arn:aws:s3:::example-bucket/public/${cognito-identity.amazonaws.com:sub}",
        "arn:aws:s3:::example-bucket/public/${cognito-identity.amazonaws.com:sub}/*"
      ]
    },
    {
      "Sid": "AllowPrivateHomeObjectWritingByOwnerUsers",
      "Effect": "Allow",
      "Action": [
        "s3:PutObject",
        "s3:DeleteObject"
      ],
      "Resource": [
        "arn:aws:s3:::example-bucket/private/${cognito-identity.amazonaws.com:sub}",
        "arn:aws:s3:::example-bucket/private/${cognito-identity.amazonaws.com:sub}/*"
      ]
    }
  ]
}

ちょっと複雑で長いポリシーかと思いますが、順に説明していきます。

AllowPublicHomeListingByAllUsers

まず、誰でも/public以下のオブジェクト一覧は見て構わないですね。従って、このステートメントで許可を行います。

ちなみに、このステートメントだと「ユーザの一覧」は取得できません。つまり、明示的にIdentityIDを指定しなければオブジェクト一覧を出すこともできません。もしユーザ一覧を取得できるべきであれば、

"Condition": { "StringLike": { "s3:prefix": [ "public/*/*" ]}}

"Condition": { "StringLike": { "s3:prefix": [ "public/*" ]}}

に変更することで、ユーザ一覧を取れるようにできます。

AllowPrivateHomeListingByOwnerUsers

次に、/private以下については、自分のIdentityIDのフォルダだけ、一覧できるようにしたのがこのポリシーです。S3に対するobject listing要求において、prefixパラメータがprivate/${cognito-identity.amazonaws.com:sub}/*のパターンにマッチしたら許可、という記述です。

${cognito-identity.amazonaws.com:sub}の部分は、CognitoのIdentityIDに置き換わります。

AllowPublicHomeObjectReadingByAllUsers

public/以下のオブジェクトは、誰でもダウンロードできる、という比較的明快なステートメントです。

AllowPrivateHomeObjectReadingByOwnerUsers

これも比較的明快ですね。private/${cognito-identity.amazonaws.com:sub}/以下のオブジェクトはオーナーのみがダウンロードできます。

AllowPublicHomeObjectWritingByOwnerUsersとAllowPrivateHomeObjectWritingByOwnerUsers

ここまで理解できればあとは特に説明する必要も無いと思います。

このポリシーの設定先

これを、通常はCognitoのAuth Roleに設定しておきます。が、今回は検証を楽に *3するために、Unauth Roleの方に設定してみます。

検証!

まず、Amazon Cognitoによる認証はSTSのweb identity federationとどう違うのか!?で挙げたサンプルコードを2回実行して、IdentityIDを2つ発行、そしてそれぞれのtemporary credentialsを入手します。

shellを2つ立ち上げ、それぞれにAWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_SESSION_TOKEN環境変数としてキーを設定します。ここでは1つ目のIdentityIDをus-east-1:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa、もう一つをus-east-1:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbbとします。

s3上には先に挙げたディレクトリ構造を作っておき、適当なコンテンツを上げておいてください。

まずはpublic直下のリスティング

aaaa$ aws s3 ls s3://example-bucket/public/

A client error (AccessDenied) occurred when calling the ListObjects operation: Access Denied
bbbb$ aws s3 ls s3://example-bucket/public/

A client error (AccessDenied) occurred when calling the ListObjects operation: Access Denied

上記の通り、どちらからもアクセスできませんでした。

次にユーザを明示したpublicのリスティング

aaaa$ aws s3 ls s3://example-bucket/public/us-east-1:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/
2014-10-31 16:21:33          0
2014-10-31 16:21:36          4 foo.txt
aaaa$ aws s3 ls s3://example-bucket/public/us-east-1:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/
2014-10-31 16:21:19          0
2014-10-31 16:21:24          4 foo.txt
bbbb$ aws s3 ls s3://example-bucket/public/us-east-1:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/
2014-10-31 16:21:33          0
2014-10-31 16:21:36          4 foo.txt
bbbb$ aws s3 ls s3://example-bucket/public/us-east-1:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/
2014-10-31 16:21:19          0
2014-10-31 16:21:24          4 foo.txt

相互にpublicの中身が見えていますね。

publicへの書き込み

aaaa$ echo bar >bar.txt
aaaa$ aws s3 cp bar.txt s3://example-bucket/public/us-east-1:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/
upload: ./bar.txt to s3://example-bucket/public/us-east-1:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/bar.txt
aaaa$ aws s3 cp bar.txt s3://example-bucket/public/us-east-1:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/
upload failed: ./bar.txt to s3://example-bucket/public/us-east-1:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/bar.txt A client error (AccessDenied) occurred when calling the PutObject operation: Access Denied
bbbb$ echo bar >bar.txt
bbbb$ aws s3 cp bar.txt s3://example-bucket/public/us-east-1:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/
upload failed: ./bar.txt to s3://example-bucket/public/us-east-1:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/bar.txt A client error (AccessDenied) occurred when calling the PutObject operation: Access Denied
bbbb$ aws s3 cp bar.txt s3://example-bucket/public/us-east-1:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/
upload: ./bar.txt to s3://example-bucket/public/us-east-1:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/bar.txt

それぞれ、publicであっても、自分のフォルダ以下でなければ書き込みはできませんでした。

private直下のリスティング

aaaa$ aws s3 ls s3://example-bucket/private/

A client error (AccessDenied) occurred when calling the ListObjects operation: Access Denied
bbbb$ aws s3 ls s3://example-bucket/private/

A client error (AccessDenied) occurred when calling the ListObjects operation: Access Denied

もちろん、どちらからもアクセスできませんでした。

次にユーザを明示したprivateのリスティング

aaaa$ aws s3 ls s3://example-bucket/private/us-east-1:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/
2014-10-31 16:20:59          0
2014-10-31 16:21:03          4 foo.txt
aaaa$ aws s3 ls s3://example-bucket/private/us-east-1:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/

A client error (AccessDenied) occurred when calling the ListObjects operation: Access Denied
bbbb$ aws s3 ls s3://example-bucket/private/us-east-1:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/

A client error (AccessDenied) occurred when calling the ListObjects operation: Access Denied
bbbb$ aws s3 ls s3://example-bucket/private/us-east-1:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/
2014-10-31 16:20:41          0
2014-10-31 16:20:51          4 foo.txt

自分のフォルダでなければリスティングはできません。

privateへの書き込み

aaaa$ echo bar >bar.txt
aaaa$ aws s3 cp bar.txt s3://example-bucket/private/us-east-1:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/
upload: ./bar.txt to s3://example-bucket/private/us-east-1:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/bar.txt
aaaa$ aws s3 cp bar.txt s3://example-bucket/private/us-east-1:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/
upload failed: ./bar.txt to s3://example-bucket/private/us-east-1:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/bar.txt A client error (AccessDenied) occurred when calling the PutObject operation: Access Denied
bbbb$ echo bar >bar.txt
bbbb$ aws s3 cp bar.txt s3://example-bucket/private/us-east-1:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/
upload failed: ./bar.txt to s3://example-bucket/private/us-east-1:aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa/bar.txt A client error (AccessDenied) occurred when calling the PutObject operation: Access Denied
bbbb$ aws s3 cp bar.txt s3://example-bucket/private/us-east-1:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/
upload: ./bar.txt to s3://example-bucket/private/us-east-1:bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb/bar.txt

それぞれ、publicと同様、自分のフォルダ以下でなければ書き込みはできませんでした。

まとめ

同じような記述とコマンドが続き、認識が大変だったかもしれません。しかし、以上のような認証認可の仕組みが揃っていますので、モバイルから利用するファイルストレージとして、S3が非常に実用的であることが分かると思います。

脚注

  1. 重すぎる集計処理等。
  2. とは言え、ユーザの認証認可等も担う大事なサーバですが。。。
  3. Facebookのトークンの取得を省略できますので。