Amplify Gen2 のStorageを試してみた

2024.04.25

NTT東日本の中村です。

昨年発表されたAmplify Gen2(プレビュー)ですが、日々機能が少しづつ追加されており、完成度が高まってきました。 今回もNextJSのWebアプリケーションを使って、Amplify Storageの調査を行いました。

Amplify Gen2のStorageの概要

ファイルのアップロード・ダウンロードが行えるようになる機能です。Gen1から存在している機能を引き継いでいます。

既存のAmplifyでは、Storageカテゴリはファイルのストレージ(S3でファイルのアップロード・ダウンロード)、データのストレージ(DynamoDB)の2つの概念があり、CLIで「amplify add storage」を実行すると、選択が必要です。

Amplify Gen2では、執筆時点ではファイルのストレージのみ対応しています。

Gen2に変わったことで、Storageのファイルストレージ機能がどのように変化したか調査してみました。 以降は、現行のAmplify V6を主な比較対象としています。

ファイルストレージの概要

Amplify Storageのファイルストレージは、基本的な機能に変化はありませんが、考え方が大きく変わりました。

変わらない所・変わった所

基本的な機能の部分に変化はありません。

  • 使用すると、S3が追加される
  • Amplify Authログインでの認証、ゲストユーザ認証、 Cognito Groups認証でのアクセス制御ができる
  • Lambda Triggerを追加できる

アクセスのルールについては、public、private、protectedの概念をやめ、パスベースのきめ細やかなルールを設定できます。 根底のS3は同じですが、アクセスの仕方が大きく変わっています。

既存のAmplifyは、3つの認証のカテゴリに対して、read, write, deleteのアクションが可能かどうかを設定します。

  • private:認証済みユーザ
  • guest:認証無しユーザ
  • groups:ユーザーグループ

ファイルストレージ側は

  • public:ゲストユーザを含めたユーザーがアクセス可能
  • protected:全てのユーザが読み取り可能で、作成ユーザのみ編集できる
  • private: 自分だけが読み取り・編集できる

の、3つのアクセスのルールを設定します。このルールを元に、オブジェクトの起点のフォルダが決まります。 この認証カテゴリとアクセスのルールを別々の所(CLIとAPI)で設定するのが若干複雑だと感じていました。

 

一方のGen2ですが、自由度が増してスッキリした印象です。

  • デフォルトの3つのアクセスのルールが無くなり、オブジェクトのパスに対して、「どの認証カテゴリに、どのアクションを適用するか」を自分で設定するようになりました。
  • 認証のカテゴリが少し表現が変わりました(Amplify Dataでの認可戦略の考え方とほぼ同様です)
    • authenticated:認証済みユーザ
    • guest:認証無しユーザ
    • group:ユーザーグループ
    • (owner):オブジェクトの所有者
    • (custom):defineFunctionで定義したロジックに従って許可・不許可を判断する

これらがresource.tsの中だけで全て設定できるのが、解りやすくて良い所だと思います。

例えば、「foo配下は、認証済みユーザのみreadできる」という場合、下記のように書くことができます。

 export const storage = defineStorage({
   name: 'myProjectFiles',
   access: (allow) => ({
     'foo/*': [allow.authenticated.to(['read'])] 
   })
 });

 

変更点の中で、気になった2点をピックアップしました。

ownerアクセス

所有者ベースのストレージは、従来のprivateに近いものと考えると、「自分だけが'read, write, deleteできる」と考えられ、構築の際にはオブジェクトパスにentity id(user_identity_id)を含める必要があります。

allow.entity()を使用し、所有者の認証カテゴリに対し、アクションを設定します。

 export const storage = defineStorage({
   name: 'myProjectFiles',
   access: (allow) => ({
     'foo/{entity_id}/*': [
       allow.entity('identity').to(['read', 'write', 'delete'])
     ]
   })
 });

ルールを緩和し、「自分のみ編集可能、他の人は見るだけならOK」(従来のProtected)としたい場合は、許可する権限を追加します。

この例では、認証済みユーザ、認証なしユーザにreadを付与しています。

  export const storage = defineStorage({
    name: 'myProjectFiles',
    access: (allow) => ({
      'foo/{entity_id}/*': [
        allow.entity('identity').to(['read', 'write', 'delete'])
        allow.guest.to(['read']),
        allow.authenticated.to(['read'])        
      ]
    })
  });

オブジェクトのパス階層のアクセス

パスの階層レベルごとにアクセス制御を行うことができます。

 export const storage = defineStorage({
   name: 'myProjectFiles',
   access: (allow) => ({
     'foo/*': [allow.authenticated.to(['read', 'write', 'delete'])],
     'foo/bar/*': [allow.guest.to(['read'])],
     'foo/baz/*': [allow.authenticated.to(['read'])],
     'other/*': [
       allow.guest.to(['read']),
       allow.authenticated.to(['read', 'write'])
     ]
   })
 });
  • ネストしてアクセス制御できるのは1階層まで
  • サブパスを定義した場合、サブパスのアクセス制御だけが有効になり、親のアクセス制御は継承されない

だけ、気にしておけば良さそうです。

ちなみに既存のAmplifyも、格納先のパスのカスタマイズは可能でしたが、IAMポリシーの追加が必要でした。Gen2では、IAMの設定も自動で行います(後ほど説明します)

Gen1のアクセスパターンのテンプレート

既存のAmplifyと同様のルール(public、protected、private)で使いたい人のテンプレートです。

https://docs.amplify.aws/gen2/build-a-backend/storage/#configuring-amplify-gen-1-equivalent-access-patterns

 export const storage = defineStorage({
   name: 'myProjectFiles',
   access: (allow) => ({
     'public/*': [
       allow.guest.to(['read'])
       allow.authenticated.to(['read', 'write', 'delete']),
     ],
     'protected/{entity_id}/*': [
       allow.authenticated.to(['read']),
       allow.entity('identity').to(['read', 'write', 'delete'])
     ],
     'private/{entity_id}/*': [allow.entity('identity').to(['read', 'write', 'delete'])]
   })
 });

やってみた

アクセスのルールの定義と、実際にアップロード・ダウンロードを行い、アクセスが可能か確認します。

バックエンドの構築

アクセスのルールを定義してみました。

  • /admin直下は、adminグループのみread, write, delete
  • /content/{{entity_id}}所有者ベースのストレージを用意し、所有者とadminグループのみread, write, delete。ゲストユーザ及び認証済ユーザはread。
 import { defineStorage } from "@aws-amplify/backend";
 
 export const storage = defineStorage({
   name: "myProjectFiles",
   access: (allow) => ({
     "admin/*": [allow.group("admin").to(["read", "write", "delete"])],
     "content/{entity_id}/*": [
       allow.entity("identity").to(["read", "write", "delete"]),
       allow.group("admin").to(["read", "write", "delete"]),
       allow.guest.to(["read"]),
       allow.authenticated.to(["read"]),
     ],
   }),
 });

ユーザーグループは、auth/resource.tsで定義します。 以前cfnでグループを定義したのですが、defineAuthで定義しないと、グループに紐づくRoleが生成されず、storageとの連携時にcdkの更新に失敗してしまいます。

 import { defineAuth } from "@aws-amplify/backend";
 
 /**
  * Define and configure your auth resource
  * @see https://docs.amplify.aws/gen2/build-a-backend/auth
  */
 export const auth = defineAuth({
   loginWith: {
     email: {
       verificationEmailSubject: "Welcome! Verify your email!",
     },
   },
   groups: ["worker", "admin"],
 });

パスベースでアクセス制御を行った結果、生成されるIAMポリシーをチェックします。 Cognitoベースの認証を用いているので、authStackで生成されるそれぞれのIAMポリシーに、アクセス制御設定が追加されています。

  • authenticatedUserRole(認証済)
  • unauthenticatedUserRole(ゲスト)
  • workerGroupRole(userGroup"worker")
  • adminGroupRole(userGroup"admin")

抜粋して、adminGroupRoleとauthenticatedUserRoleを見てみます。

それぞれのIAMポリシーの中で、GetObject、ListBucket、PutObject、DeleteObjectとアクション事にStatementが分かれており、パスベースのルールがアクションベースに落とし込まれていることが分かりますね。

adminGroupRole

 {
    "Version": "2012-10-17",
    "Statement": [
        {
            "Action": "s3:GetObject",
            "Resource": [
                "arn:aws:s3:::amplify-nextamplifygen2-n-hogehoge-vjbpkjzb6b9r/admin/*",
                "arn:aws:s3:::amplify-nextamplifygen2-n-hogehoge-vjbpkjzb6b9r/content/*/*"
            ],
            "Effect": "Allow"
        },
        {
            "Condition": {
                "StringLike": {
                    "s3:prefix": [
                        "admin/*",
                        "admin/",
                        "content/*/*",
                        "content/*/"
                    ]
                }
            },
            "Action": "s3:ListBucket",
            "Resource": "arn:aws:s3:::amplify-nextamplifygen2-n-hogehoge-vjbpkjzb6b9r",
            "Effect": "Allow"
        },
        {
            "Action": "s3:PutObject",
            "Resource": [
                "arn:aws:s3:::amplify-nextamplifygen2-n-hogehoge-vjbpkjzb6b9r/admin/*",
                "arn:aws:s3:::amplify-nextamplifygen2-n-hogehoge-vjbpkjzb6b9r/content/*/*"
            ],
            "Effect": "Allow"
        },
        {
            "Action": "s3:DeleteObject",
            "Resource": [
                "arn:aws:s3:::amplify-nextamplifygen2-n-hogehoge-vjbpkjzb6b9r/admin/*",
                "arn:aws:s3:::amplify-nextamplifygen2-n-hogehoge-vjbpkjzb6b9r/content/*/*"
            ],
            "Effect": "Allow"
        }
    ]
 }

authenticatedUserRole

{
     "Version": "2012-10-17",
     "Statement": [
         {
             "Action": "s3:GetObject",
             "Resource": [
                 "arn:aws:s3:::amplify-nextamplifygen2-n-hogehoge-vjbpkjzb6b9r/content/${cognito-identity.amazonaws.com:sub}/*",
                 "arn:aws:s3:::amplify-nextamplifygen2-n-hogehoge-vjbpkjzb6b9r/content/*/*"
             ],
             "Effect": "Allow"
         },
         {
             "Condition": {
                 "StringLike": {
                     "s3:prefix": [
                         "content/${cognito-identity.amazonaws.com:sub}/*",
                         "content/${cognito-identity.amazonaws.com:sub}/",
                         "content/*/*",
                         "content/*/"
                     ]
                 }
             },
             "Action": "s3:ListBucket",
             "Resource": "arn:aws:s3:::amplify-nextamplifygen2-n-hogehoge-vjbpkjzb6b9r",
             "Effect": "Allow"
         },
         {
             "Action": "s3:PutObject",
             "Resource": "arn:aws:s3:::amplify-nextamplifygen2-n-hogehoge-vjbpkjzb6b9r/content/${cognito-identity.amazonaws.com:sub}/*",
             "Effect": "Allow"
         },
         {
             "Action": "s3:DeleteObject",
             "Resource": "arn:aws:s3:::amplify-nextamplifygen2-n-hogehoge-vjbpkjzb6b9r/content/${cognito-identity.amazonaws.com:sub}/*",
             "Effect": "Allow"
         }
     ]
 }

フロントエンドの構築

Gen2のアップロード・ダウンロードAPIというものが存在しないため、V6のDocmentを参考にします。

V6のDocmentを参考にします。

また、Amplify-UIのConnected Componentsの挙動もチェックしてみました。

注意点

V6のAPI、Amplify-UIのConnected Componentsいずれも、accessLevelというオプションを定義する必要があります。 このオプションにはStorageAccessLevelという、V6以前のルールに則った型が適用されます。

export type StorageAccessLevel = 'guest' | 'protected' | 'private';

この値を元に、S3へファイルを保存する時の起点となるフォルダ名が決定します。

  • guest→public
  • protected→protected/{entity_id}/*
  • private→private/{entity_id}/*

といった具合です。

しかし、Gen2では起点のフォルダ名に、protectedやprivate以外も使いたい訳ですね。 その場合、accessLevelに任意の値を指定し、prefixResolverで任意の値に対応するパスを返すようにすると、任意のパスにアップロードを行えるようになります。

今回は、admin、contentの2つのアクセスレベルを設定しました。 なお、現時点ではStorageAccessLevelにInvalidな文字列を与えることになるので、TypeScriptの型エラーが発生します。留意が必要です。

 Amplify.configure(amplifyconfig, {
    Storage: {
      S3: {
        prefixResolver: async ({ accessLevel, targetIdentityId }) => {
          // @ts-ignore
          if (accessLevel === "admin") {
            return "admin/";
          } else if (accessLevel === "content") {
            return `content/${targetIdentityId}/`;
          } else {
            return `content/${targetIdentityId}/`;
          }
        },
      },
    },
  });

以上を考慮して、コンポーネントを作成しています。

 "use client";
 import { useEffect, useState } from "react";
 import { Amplify } from "aws-amplify";
 import type { StorageAccessLevel } from "@aws-amplify/core";
 import { getCurrentUser } from "aws-amplify/auth";
 import { list, downloadData } from "aws-amplify/storage";
 import { StorageImage,StorageManager } from "@aws-amplify/ui-react-storage";
 
 import "@aws-amplify/ui-react/styles.css";
 import amplifyconfig from "../amplifyconfiguration.json";
 
 const ACCESS_LEVEL: StorageAccessLevel = "admin" as StorageAccessLevel;
 
 Amplify.configure(amplifyconfig, {
   Storage: {
     S3: {
       prefixResolver: async ({ accessLevel, targetIdentityId }) => {
         // @ts-ignore
         if (accessLevel === "admin") {
           return "admin/";
         } else if (accessLevel === "content") {
           return `content/${targetIdentityId}/`;
         } else {
           return `content/${targetIdentityId}/`;
         }
       },
     },
   },
 });
 
 const download = async (key: string) => {
   const task = downloadData({
     key,
     options: {
       accessLevel: ACCESS_LEVEL,
     },
   });
   const { body } = await task.result;
   const blob = new Blob([await body.blob()]);
   const link = document.createElement("a");
   link.download = key;
   link.href = URL.createObjectURL(blob);
   link.click();
   URL.revokeObjectURL(link.href);
 };
 
 export function App() {
   const [user, setUser] = useState("");
   const [fileList, setFileList] = useState<string[]>([]);
 
   useEffect(() => {
     (async () =>
       setUser((await getCurrentUser()).signInDetails?.loginId ?? ""))();
   }, []);
 
   useEffect(() => {
     (async () => {
       const response = await list({
         options: {
           accessLevel: ACCESS_LEVEL,
         },
       });
       setFileList(response.items.map((item) => item.key));
     })();
   }, []);
 
   return (
     <div style={{ padding: "2rem" }}>
       <span>{user}</span>
       <h2>accessLevel:{ACCESS_LEVEL}(custom)</h2>
       <div style={{ margin: "0 2rem" }}>
         <StorageManager
           acceptedFileTypes={["image/*"]}
           accessLevel={ACCESS_LEVEL}
           maxFileCount={1}
         />
       </div>
 
       <h2>{ACCESS_LEVEL} file list</h2>
       <ul style={{ margin: "0 2rem" }}>
         {fileList.map((key, _i) => (
           <li key={_i}>
             {key}&nbsp;
             <button onClick={() => download(key)}>download</button>
             <StorageImage
               alt={key}
               imgKey={key}
               accessLevel={ACCESS_LEVEL}
             />
           </li>
         ))}
       </ul>
     </div>
   );
 }
 
 export default App;

では、挙動を確認してみます。

まず、CognitoのGroupがadminであるユーザでログインし、画像をアップロードしました。

adminのaccessLevelを設定しているので、ファイルはS3のaccess/直下に保存されます。 adminGroupにはRead/Writeの権限があるので、アップロードと、リスト取得、表示、ダウンロードを行うことができます。

S3上で、admin直下にファイルが保存されていることを確認できます。

次に、user01でログインを行います。 user01はグループに所属していないので、認証カテゴリとしてはauthenticatedに相当します。

この権限でaccessLevel:adminでアクセスしますが、adminのアクセスルールでは、authenticatedは許可されていません。

そのため、adminパスへのファイルの取得や、表示に失敗していることが分かります。

このように、ユーザの認証カテゴリによりアクセスルールが正しく機能し、表示を制御できることが分かりました。

まとめ

Amplify Gen2のStorage機能はパスベースの細かな設定が直感的に行えるようになり、データをセキュアに保つ上で使い勝手が良くなりました。APIやAmplify UIの正式対応が待ち遠しいですね!