[Amplify] AppSyncの複数認証モードを利用して非同期Mutationを行う

AppSyncの複数認証モードを利用して、Cognito認証とIAM認証を組み合わせたAPIを構築していきたいと思います。

# やりたいこと

目標とするアーキテクチャは以下のようなイメージです

![AppSyncMultiAuth.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/204890/a3fd8fce-d503-f266-ea1b-d51a1abf953d.png)

1. ユーザはS3に静的ホスティングされたNode.jsアプリケーションにCognitoでログインする
2. ログインしたCognitoユーザ認証を用いてAppSyncのProcessedデータモデルをsubscribeする
3. ユーザはクライアントからRawデータをmutateする
4. Rawデータを格納したDynamoDB Streamを受け取ったLambdaが起動する
5. Lambdaは非同期で何かしら重たい解析処理を行う
6. 処理結果をLambdaが **IAM認証で** AppSyncのProcessedデータモデルにmutateする
7. AppSyncからpublishされたデータをクライアントアプリケーションがリアルタイムに受け取る

## ポイント

問題となるのは6番目の処理です。
LambdaがDynamoDBに直接ItemをPutしてもAppSyncはイベントを検出することが出来ません。
subscribeしているユーザがmutateイベントを受け取るためには、LambdaはAppSyncに対してmutateする必要があります。
ですが、AppSyncの認証モードにCognitoを使用すると、Lambdaがmutateする際に認証をどうクリアするかが課題になります。
認証方式をAPIキー認証にしてクライアントアプリケーションとLambdaでキーを共有することも1つの解決策ですが、AppSyncでは現在複数の認証モードに対応しているため、デフォルトの認証にはCognitoを用いて追加の認証にIAM認証を組み込むことにします。

# 複数認証モードの対応

バックグラウンド用FunctionにIAM認証を利用するために必要な作業は以下になります。
Cognito認証を用いたNode.jsアプリケーション(Amplify Hosting)とAppSync(Amplify API)までは構築済みの想定です。

1. Amplify APIの構成定義に追加の認証モードを設定する
2. AppSyncのスキーマ定義に認証の設定を追加する
3. Lambda関数をIAM認証を使うように構成する

順に追っていきます。

## Amplify APIの構成定義に追加の認証モードを設定する

APIの設定を更新するには `amplify api update` コマンドを実行すると対話的に設定が始まります。
最初の質問はGraphQLのまま進み、次の質問で `Update auth settings` を選択します。

```
$amplify api update
? Please select from one of the below mentioned services: GraphQL
? Select from the options below Update auth settings
```

デフォルトの認証モードはCognitoを選択します。

```
? Choose the default authorization type for the API
  API key
> Amazon Cognito User Pool
  IAM
  OpenID Connect                        
```

追加の認証モードを設定するか聞かれるのでyを入力し、IAMを追加します。

```
? Configure additional auth types? Yes
? Choose the additional authorization types you want to configure for the API
 ( ) API key
>(*) IAM
 ( ) OpenID Connect 
```

amplifyの構成ファイル `amplify/backend/backend-config.json` のapiのセクションが以下のようになっていれば設定完了です。

```amplify/backend/backend-config.json
{
    "api": {
        "myapiname": {
            "service": "AppSync",
            "providerPlugin": "awscloudformation",
            "output": {
                "authConfig": {
                    "additionalAuthenticationProviders": [
                        {
                            "authenticationType": "AWS_IAM"
                        }
                    ],
                    "defaultAuthentication": {
                        "authenticationType": "AMAZON_COGNITO_USER_POOLS",
                        "userPoolConfig": {
                            "userPoolId": "myuserpoolid"
                        }
                    }
                }
            }
        }
    },
```

デフォルトの認証がCognito、追加の認証がAWS_IAMになっていることが分かります。
ここで `amplify push`を行うことでAppSyncに設定が反映されます。

## AppSyncのスキーマ定義に認証の設定を追加する

`amplify/backend/api/myapiname/schema.graphql`にあるスキーマ定義に認証の設定を追加します。

ここでは、Rawデータを格納するモデルとProcessedデータを格納するモデルの2つを定義します。
要件は以下の通りです。

- RawデータはCognitoユーザがmutateできる
- ProcessedデータはCognitoユーザがsubscribeできる
- ProcessedデータはIAMユーザがmutateできる

schema.graphqlには`@auth`ディレクティブを用いて認証を定義できますが、これは複数並べることが可能です。

```amplify/backend/api/myapiname/schema.graphql
type RawData @model @auth(rules: [
  {allow: owner, ownerField: "user"}
]) {
  id: ID!
  user: String!
  data: String!
}

type ProcessedData @model @auth(rules: [
  {allow: owner, ownerField: "user"},
  {allow: private, provider: iam, operations: [create]}
]) {
  id: ID!
  user: String!
  result: String!
}
```

`{allow: owner, ownerField: "user"}`がデフォルトの認証になります。
この設定で、デフォルト認証であるCognitoユーザのusernameが各モデルのuserフィールドの値と一致しているItemに対してのみCRUDが出来るようになります。

` {allow: private, provider: iam, operations: [create]}`が追加のIAM認証になります。
この設定で、IAM認証でAPIへのアクセスが許可されたオペレータがmutateすることが出来るようになります。
`operations`はcreate/update/delete/readの中から必要な権限をリストで指定することが出来ます。
ちなみに、今回の要件的にはCognitoユーザはProcessedDataに対してはread権限のみでよさそうな感じもしましたが、OnCreateイベントをsubscribeするにはcreateの権限が必要、という関係性があるようで、CRUD権限をすべて許可しました。


スキーマの編集が完了したら`amplify push`を実行します。
途中のオプションでGraphQLのコードを生成するか聞かれますのでYesとすることで編集したスキーマに対応したコードが生成されます。

## Lambda関数をIAM認証を使うように構成する

### Functionの準備

`amplify add function`を実行してLambda Functionを追加します。

適切なfunction名(今回はmyfunction)を入力し、テンプレートでLambda triggerを選択します。
今回はNodeJSのfunctionを作成します。


```
$amplify add function
Using service: Lambda, provided by: awscloudformation
? Provide a friendly name for your resource to be used as a label for this category in the project: myfunction
? Provide the AWS Lambda function name: myfunction
? Choose the function runtime that you want to use: NodeJS
```

Lambda triggerとしてDynamoDB Streamを選択すると先に選択したモデルと関連付けたtriggerを持つLambdaテンプレートを生成できます。

```
? Choose the function template that you want to use:
  CRUD function for DynamoDB (Integration with API Gateway)
  Hello World
> Lambda trigger
  Serverless ExpressJS function (Integration with API Gateway)
```

```
? What event source do you want to associate with Lambda trigger? (Use arrow keys)
> Amazon DynamoDB Stream
  Amazon Kinesis Stream                                                            
```

イベントのソースを聞かれるので、モデルと関連付けることを選択します

```
? Choose a DynamoDB event source option (Use arrow keys)
> Use API category graphql @model backed DynamoDB table(s) in the current Amplify project
  Use storage category DynamoDB table configured in the current Amplify project
  Provide the ARN of DynamoDB stream directly
```

スキーマ定義済みモデルの中から、eventをトリガーするモデルを選択します。
今回の例ではRawDataです。

```
? Choose the graphql @model(s)
>(*) RawData
 ( ) ProcessedData        
```

Lambdaから他のリソースにアクセスするか聞かれるので、Yesとしてapiを選択します。

```
? Do you want to access other resources created in this project from your Lambda function? Yes
? Select the category
>(*) api
 ( ) hosting
 ( ) auth
 ( ) function
 ( ) storage
```

必要な権限について聞かれるので、今回はcreateを選択します。

```
Api category has a resource called myapiname
? Select the operations you want to permit for myapiname
>(*) create
 ( ) read
 ( ) update
 ( ) delete
```

スケジュールイベントは今回不要です。

```
? Do you want to invoke this function on a recurring schedule? (y/N) N
? Do you want to edit the local lambda function now? (y/N) N
```

これでLambdaFunctionのテンプレートが生成されます。
生成されるリソースの詳細は `amplify/backend/function/myfunction/myfunction-cloudformation-template.json`に書かれています。
このファイルを見ると、LambdaFunction本体の他、

- APIエンドポイント等の情報が環境変数として渡されている
- RawDataモデルに対応するDynamoDBのイベントに対応するEventSourceMapping
- DynamoDB Streamを受け取るPolicy
- AppSyncにcreateを行うPolicy
- それらのポリシーがアタッチされたLambdaRole

などが一式用意されていることが分かります。
ここでの対話的な設定で設定しなかった場合でもこのtemplate.jsonを直接書き換えることでも構成は可能です。

ここで`amplify push`を実行するとテンプレートソースのままのLambdaFunctionがデプロイされます。

### ソースコードの実装

ここまででRawDataにユーザがmutateされたことをトリガーに起動するLambdaが作成出来ました。
書き込むべきAppSyncへの権限も付与され、エンドポイント情報も環境変数で渡されています。

最後に実際にIAMロールでmutateを行うNode.jsコードを書いていきます。

#### 必要なnpmモジュール

今回ここでは aws-amplify モジュールは使いません。
Amplify.APIサブモジュールにうまくIAMロール情報を認証情報として渡せなかったためです。誰かご存じの方いたら教えて下さい。

代わりに、aws-appsync モジュールをインストールし、AppSync SDKを利用してクエリを送信します。
また、IAM認証情報をAppSyncに渡すためにaws-sdkも必要です。

```
$npm install aws-sdk aws-appsync graphql graphql-tag isomorphic-fetch --save
```

`graphql-tag`はGraphQLコマンドを生成するために使用していますが、依存する`graphql`モジュールがそのままではインストールされず依存エラーになったため明示的にインストールしています。
`isomorphic-fetch`はAppSyncClientを初期化する際に必要になるようです。

#### 実装

まずは認証情報を渡すなどのクライアントの初期化を行います。

```amplify/backend/function/myfunction/src/index.js
require('isomorphic-fetch');
const AWS = require('aws-sdk');
const gql = require('graphql-tag');
const AWSAppSyncClient = require('aws-appsync').default;
const { AUTH_TYPE } = require('aws-appsync');

const appSyncUrl = process.env.API_MYAPI_GRAPHQLAPIENDPOINTOUTPUT;
const region = process.env.REGION;

const client = new AWSAppSyncClient({
    url: appSyncUrl,
    region: region,
    auth: {
      type: AUTH_TYPE.AWS_IAM,
      credentials: ()=> AWS.config.credentials,
    },
    disableOffline: true,
});
```

credentialsで `AWS.config.credentials`を渡すことで、現在実行しているLambdaRoleの認証情報を渡すことが出来ます。
また、Lambda Functionで動かす関係上、`disableOffline: true`を設定してオフライン機能を無効化しておく必要もあります。

次にクエリを構築します。

```amplify/backend/function/myfunction/src/index.js
const query = gql(`mutation CreateProcessedData(
  $input: CreateProcessedDataInput!
  $condition: ModelProcessedDataConditionInput
) {
  createProcessedData(input: $input, condition: $condition) {
    id
    user
    result
  }
}`);
```

クエリの元になるクラスは今回はテキストのままコピーしました。
スキーマから生成されたソースは、普通にAmplifyプロジェクトを構成していると`src/graphql/mutation.js`あたりにあると思いますので、`amplify/backend/function/myfunction/src/`以下にコピーしてFunction内に取り込んだ上でソース内でrequireしても良いと思います。
モデルを修正した時に生成されるソースといい感じに同期が取れると嬉しいのですが。

最後に、mutateを実行します。

```amplify/backend/function/myfunction/src/index.js
exports.handler = async (event) => {
    for (const i in event.Records) {
        let record = event.Records[i];
        if(record.eventName == 'INSERT'){
            let rawData = record.dynamodb.NewImage;
            let processResult = heavy_process(rawData.data.S) // 何かしら重い処理
            let processedDataInput = { user: rawData.user.S, result: processResult };

            try {
                let response = await client.mutate({
                    mutation: query,
                    variables: {
                        input: processedDataInput
                    }
                });
                console.dir(response);
            } catch (err) {
                console.error(JSON.stringify(err));
            }
        }
    }
    return {
        statusCode: 200,
        body: JSON.stringify('Success'),
    };
};
```

エラー処理やら周辺の処理はサンプルのため適当です。
Rawデータのuser列の値をProcessedデータにもコピーしてmutateすることで、そのuser名でsubscribeしているユーザに対してイベントを配信することが出来ます。

これで、非同期化されたLambdaで生成したデータをCognitoユーザに配信することができました。

複数の認証モードを活用することである程度複雑なシステムの構築も出来そうなので活用の幅は広がりそうです。
ですがまだまだハマりどころが多くて思い通り動かすまでがなかなか大変ですね。


# 参考

- [Amplify API document](https://aws-amplify.github.io/docs/js/api#iam-auth)
- [LambdaからAppSyncをIAM認証で使う](https://qiita.com/nakayama_cw/items/df0a1952c7fb2d9b80f8)
- [AWS Amplify の AppSync API に IAM 認証を追加する](https://qiita.com/shinnoki/items/8a976cb2e7958cef2e9a)
- [AWS Amplify フレームワークの使い方Part9〜Function編〜](https://qiita.com/too/items/d2732b3ec625011f4567)
- [[AWS AppSync] LambdaからGraphQLのクエリを送ってみる](https://dev.classmethod.jp/articles/aws-appsync-lambda-mutation/)