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)で使いたい人のテンプレートです。
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}
<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の正式対応が待ち遠しいですね!