Amplifyを利用したマルチテナント対応を考えてみる

# はじめに
[Amplifyを利用して簡単にWebアプリを作ってみる](https://qiita.com/Yoshihiro-Kawamura/items/b8b2667ad84d22b777fe) で作成したアプリに対して、よりサービス利用を意識した拡張を加えていこうと思います。
今回はマルチテナント対応の一環として、自身が所属するテナントのデータのみ操作できるように認可制御をかけていきます。

本対応を行うことで、以下が実現できます。
1. AWS Cognitoを用いた認証およびテナントIDの取得
2. AWS AppSyncとVTLを用いたテナント毎のデータ操作を可能にする認可制御

# アーキテクチャ
- Frontend: Next.js v12
- 認証: AWS Cognito
- バックエンド: AppSync & DynamoDB
![amplify.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/681334/999e16c9-bbec-0b91-8b1b-237bb6141cc0.jpeg)

# 前提条件
[Amplifyを利用して簡単にWebアプリを作ってみる](https://qiita.com/Yoshihiro-Kawamura/items/b8b2667ad84d22b777fe) の手順に沿って、アプリを作成済みであること。

# データ所有者のみデータの参照と変更を許可する場合
1. [Amplifyのドキュメント](https://docs.amplify.aws/cli-legacy/graphql-transformer/auth/#owner-authorization) に記載されているように、authディレクティブをTodoスキーマに対して追加する。
    schema.graphqlを以下コードで上書きしてください。
    ``` amplify/backend/api/nextamplified/schema.graphql
    type Todo
      @model
      @auth(rules: [{ allow: owner, provider: userPools }]) {
      id: ID! @primaryKey
      description: String!
    }
    ```
2. amplify pushを実行して変更を反映する。
    ```
    amplify push
    ```

## 動作確認
1. Next.jsのアプリを起動して、動作を確認する。
    ```
    npm run dev
    ```
2. ブラウザで http://localhost:3000/ にアクセスする。
3. Create Accountを選択 -> ユーザ名、パスワード、メールアドレスを入力して異なるアカウントを2つ作成する。
4. 一つ目のユーザでサインインする。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/681334/eff70cbd-51ac-ce58-1207-1a6d1ddfe4df.png)

5. Todo内容を入力後、「Todo追加」ボタンを押して、Todoリストに登録されたことを確認する。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/681334/f2808ac7-2a3c-29b7-517e-704b3f58c580.png)

6. サインアウトボタンをクリックして、サインアウトしてから先ほど作成した二つ目のユーザでサインインする。
7. Todoリストに一つ目のユーザで登録したTodoが表示されていないことが確認できる。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/681334/2fa13b86-cafb-8a42-a5a5-73cc15b9aad0.png)

それぞれのユーザが登録したデータが所有者のみ参照できることが確認できました。AWS Amplifyはこれをどのように実現しているのでしょうか？
実は、先ほどのauthディレクティブに `owner` を指定したことで、DynamoDBのテーブル上にownerというフィールドが自動で登録されるようになり、AppSyncがownerの値を元にフィルタしたレコードのみをレスポンスとして返すように処理が変更されるようになっています。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/681334/0c8a9501-1917-0fad-d093-d66c4200c739.png)

実際のユースケースではユーザに付与した権限であったり、所属しているグループなどによって認可制御を行うケースもあると思います。
今回の記事では、特定のテナントが登録したデータを異なるテナントからは参照・更新できないように発展させてみようと思います。

# 所属するテナントのデータのみ参照・変更を許可する場合

## Cognitoのユーザに対する設定追加
1. AWS管理コンソールから、Amazon Cognito -> サインアップエクスペリエンスのカスタム属性を追加をクリックする。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/681334/3a40963f-869b-1f0d-88cd-c359a57f0f4d.png)

2. 名前に `tenantId` を入力して、変更を保存をクリックする。
    これでユーザーに持たせるカスタム属性として、テナントIDが追加できるようになりました。続けてそれぞれのユーザーに対してカスタム属性を追加していきます。
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/681334/4e3afef8-ad3e-6d55-2044-eb582483dac8.png)

3. Amazon Cognito -> ユーザー からユーザーを選択して、ユーザー属性の編集ボタンをクリックする。
4. オプションの属性として、 `custom:tenantId`を追加して、`tenant1`を入力して変更を保存をクリックする。
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/681334/651ab77c-f80d-4f51-0fbd-2780a451c8d6.png)
5. 同様にもう一つのユーザーにも`custom:tenantId`を追加して、`tenant2`を入力して変更を保存をクリックする。
    (※2つのユーザーがtenantIdに異なる値を持っていることを確認する)
    ここまででCognitoに対する設定は完了です。今回は手動でカスタム属性を登録しましたが、実際にはユーザーを作成する際の処理の一部でtenantIdを登録する形がよいでしょう。

## スキーマの更新
1. schema.graphqlの中身を以下で上書きする
    新しいフィールドとしてtenantIdを追加しています。
    ``` amplify/backend/api/nextamplified/schema.graphql
    type Todo
      @model
      @auth(rules: [{ allow: private, provider: userPools }]) {
      id: ID! @primaryKey
      description: String!
      tenantId: String!
    }
    ```

# VTLを用いたマルチテナントでの認可制御
残念ながら2023/07/20時点では、先ほど紹介したauthディレクティブを用いてCognitoのカスタム属性の値を元にAppSyncで認可制御を行うことはできないというのが調査した結論です。
(Cognitoグループによる認可制御はできるのですが、Cognitoグループは管理者や閲覧者などのロールを表すために利用したかったので、自身が所属するテナントを表すためにはどうしてもカスタム属性が必要だった)

そのため複雑になってしまいますが、AppSyncが提供しているVTLによる処理をオーバーラードすることでCognitoのカスタム属性を用いて、取得したtenantIdでデータをフィルタするようにしてみます。

AppSyncは[Apache Velocity](https://docs.aws.amazon.com/ja_jp/appsync/latest/devguide/resolver-mapping-template-reference-overview.html)によるテンプレートプログラミングを用いることで、GraphQLのクエリをパースしてDynamoDBへのクエリに変換し、データの参照や更新を行うバックエンドの処理を実現しています。
これらのVTLコードは、AWS Amplifyを利用することで自動生成されます。今回はそのコードを[Custom Resolver](https://docs.amplify.aws/cli-legacy/graphql-transformer/resolvers/#custom-resolvers)にオーバーライドする方法に沿って、認証後の処理を追加します。

1. 下記VTLコードを`amplify/backend/api/nextamplified/resolvers`配下に配置する。
    ``` amplify/backend/api\nextamplified/resolvers/Query.listTodos.postAuth.1.req.vtl
    #if( !$ctx.stash.get("hasAuth") )
        $util.error("hasAuth")
    #end
    
    #set($tenantId = $context.identity.claims.get("custom:tenantId"))
    
    ## tenantIdの存在チェック
    #if( $util.isNullOrEmpty($tenantId) )
        $util.error("no tenantId claim")
    #end
    
    ## フィルタにtenantIdを追加
    #if($ctx.stash.fieldName.startsWith("list"))
        #set( $filter = {"tenantId": {"eq": $tenantId}} )
        $util.qr($ctx.stash.put("authFilter", $filter))
    #end
    
    $util.toJson({})
    ```

2. amplify pushを実行してスキーマとVTLの変更を反映する。
    ```
    amplify push
    ```

## 動作確認
1. AWS管理コンソールからAppSync画面に移動して、APIの中からnextamplified-devを選択する。
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/681334/a6341fae-8bbe-4708-9383-5d681b84f2a4.png)

2. メニューからクエリを選択し、以下のクエリを入力する
    ```
    mutation MyMutation {
      tenant1_1: createTodo(input: {description: "Todo 1", tenantId: "tenant1"}) {
        id
      }
      tenant1_2: createTodo(input: {description: "Todo 2", tenantId: "tenant1"}) {
        id
      }
      tenant2_1: createTodo(input: {description: "Todo 3", tenantId: "tenant2"}) {
        id
      }
    }
    
    query MyQuery {
      listTodos {
        items {
          tenantId
          id
          description
        }
      }
    }

    ```
    ![スクリーンショット 2023-07-20 100853.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/681334/c7150780-d604-9fe6-2857-32cfae943b31.png)

3. 「ユーザープールでログイン」をクリックして、Cognitoのユーザー名とパスワードを入力してログインする。
![スクリーンショット 2023-07-20 095658.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/681334/c45eb3be-6864-a67d-1d49-48bd2de43046.png)

4. 「実行する」をクリックして、MyMutationを選択するとレコードがTODOテーブルに挿入される。
5. 「実行する」をクリックして、MyQueryを選択すると右ペインに`tenant1`のデータ2件が表示されていることが確認できる。
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/681334/a3812554-6c50-606e-f55a-9c35c867101f.png)
6. ログアウトのボタンをクリックして、もう一つの方のユーザでログインし直す。
7. 「実行する」をクリックして、MyQueryを選択すると右ペインに`tenant2`のデータ1件のみ表示されていることが確認できる。
    ![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/681334/afa111f7-745c-975f-dda8-3f5e4227a598.png)

テナントIDと合致するデータのみ参照できることが確認できました。
同様のやり方で、VTLコードを用意すればGraphQL Mutationの方もtenantIdによる認可制御をかけることができます。
本当はフロントアプリを軽く修正して動作するだろうと見込んでいたのですが、アプリからだとVTL処理でなぜか上手くtenantIdが取れず断念しました。。
(時間があったら後日確認します)

# 感想
AWS Amplifyを使って権限が与えられているデータのみ操作する方法を説明してきました。
- AWS AmplifyはAWSのインフラとアプリの両方を理解していないと実装が難しく、ローコード開発という点ではこれからの技術かなと思いました。AWSを今までよく使っている方であればAmplifyの親和性は高く、AWSのベストプラクティスに沿って自動化されているのでそれなりに安心して利用できると思いました。(Amplifyを通して、AWSの裏の構成や仕組みを理解するのは勉強になりました。)
- AWSマネージドサービスが使われているので、拡張性、耐障害性、従量課金によりコストが抑えられるといったメリットが享受できるアプリであれば導入を検討してみるのも良いかと思います。
- 最初は覚えることが多く学習コストが高いのと、AWSインフラとアプリ含めて見ていかなければいけないのでメンテナンス性は落ちるかなという印象です。。