見出し画像

AWS Amplifyでマルチテナント型サービスを作る その2

前回はAWS Amplifyでマルチテナント型サービスのためのアクセス制御を同実装するかという話を書きました。前回はモデルを取得する(Query)ケースについて説明しましたが、今回はさらに実装を進めて、モデルの作成、更新、削除(Mutation)の場合について書こうと思います。

Amplify CLIによる自動生成の話なので、Amplify CLIのバージョンによっては挙動が変わる可能性があります。よってAmplify CLIのバージョンを書いておきます。
Amplify CLI version 12.7.1

モデルのMutationの場合もQueryのときと同じくProjectUserテーブルにプロジェクトとユーザーの紐づけがあるかどうかを確認すればよいのですが、以下のモデル定義だと、Mutationクエリの入力には主キーであるidしか生成されません。(projectIdは必須定義のためCreateTaskInputには必須で入ってきます。)

type Task @model @auth(rules: [{allow: private}]) {
  id: ID!
  projectId: String!
  project: Project @hasOne
  name: String!
  startDate: String
  endDate: String
  manDays: Float
  description: String
  category: Category @hasOne
  priority: Priority! @hasOne
  status: Status! @hasOne
  order: Float!
  creator: User! @hasOne
  assignees: [User] @manyToMany(relationName: "TaskUser")
  attachmentFiles: [AttachmentFile] @hasMany(indexName: "byTask", fields: ["id"])
}

上記のモデルから生成されるupdateTaskInputとdeleteTaskInput。projectIdが必須で含まれていません。(projectId自体は必須定義なのでcreateTaskInputでは必須になっています。)

export type UpdateTaskInput = {
  id: string,
  projectId?: string | null,
  name?: string | null,
  startDate?: string | null,
  endDate?: string | null,
  manDays?: number | null,
  description?: string | null,
  order?: number | null,
  taskProjectId?: string | null,
  taskCategoryId?: string | null,
  taskPriorityId?: string | null,
  taskStatusId?: string | null,
  taskCreatorId?: string | null,
};

export type DeleteTaskInput = {
  id: string,
};

この時生成されるリゾルバーの読み解いてみます。
リゾルバーを全てここに記載すると長くなってしまうので簡単に概要を書くにとどめます。例えばupdateTaskのパイプラインリゾルバーは以下の様になりました。

  1. Mutation.updateTask.init.1.req.vtl

    1. 更新日にデフォルト値を入れています。

  2. Mutation.updateTask.auth.1.req.vtl

    1. 引数のidをキーにしてGetItemしています。

  3. Mutation.updateTask.auth.1.res.vtl

    1. メインの認証処理です。reqで取得したTaskの結果確認などしています。

  4. Mutation.updateTask.postAuth.1.req.vtl

    1. auth.1.resの結果を再度確認しています。(あまり意味はないかも。)

  5. Mutation.updateTask.req|res.vtl

    1. UpdateItemしています。

さて、上記のフローのどこかにProjectUserの確認を挟みたいのですが、以下の問題があります。

  1. リクエスト引数にTask.idは必ず含まれますが、Task.projectIdは必須ではないため含まれない場合があります。

  2. Mutation.updateTask.auth.1.res.vtlでGetItemしていますが、結果をstashしていないため、auth.2リゾルバーを作ってもせっかくGetItemで取得したTask情報からprojectIdを得ることができません。OverrideしてGetItemの結果をstashすれば良いですが自動生成テンプレートはできれば触りたくないのとファイル数が増えていくので、Overrideはしたくありません。

シンプルにprojectIdがリクエストに必ず含まれるようにすれば良さそうです。
生成されるMutationクエリーを書き換えてもよさそうですが、できるだけ自動生成に任せたいです。
そこでモデルの主キーにソートキーを指定する方法=複合主キーにする方法を採ってみます。
ソートキーを指定すると、生成されるMutationクエリーを書き換えるよりもスマートに実装できます。以下の2点がメリットです。

  1. 生成されるMutationクエリーにprojectIdが強制される。

  2. DynamoDBのテーブルがidとprojectIdで複合主キーになるため、update/deleteの場合、生成されるauth.1リゾルバーで行うGetItem時にidとprojectIdを使うようになる。これで対象のTask.idが対象のProject.idに所属しているかどうかのチェックもできる。

では、idに@primaryKey(sortKeyFields:["projectId"])としてソートキーにprojectIdを設定します。

type Task @model @auth(rules: [{allow: private}]) {
  id: ID! @primaryKey(sortKeyFields:["projectId"])
  projectId: String!
  project: Project @hasOne
  ...略
}

するとUpdateTaskInputにprojectIdが強制されました。DeleteTaskInputも同様です。

export type UpdateTaskInput = {  
  id: string,
  projectId: string,
  name?: string | null,
  startDate?: string | null,
  endDate?: string | null,
  manDays?: number | null,
  description?: string | null,
  order?: number | null,
  taskProjectId?: string | null,
  taskCategoryId?: string | null,
  taskPriorityId?: string | null,
  taskStatusId?: string | null,
  taskCreatorId?: string | null,
};

export type DeleteTaskInput = {
  id: string,
  projectId: string,
};

これで、Mutationリゾルバーの$ctx.args.inputにはprojectIdが必ず含まれることになるため、Queryのときと同じくpreAuthでProjectUserをScanし、auth.2でチェックができます。

ただし、sortKeyFieldsを設定すると、preAuht.1スロットにMutation.updateTask.preAuth.1.req.vtlというリゾルバーが生成されます。sortKeyFieldsが指定されるとDynamoDBのテーブルにもSortKeyが設定されます。SortKeyはMutation.updateTask.auth.1.req.vtlで行われるGetItem時に指定しなければいけないため、preAuth.1ではその処理を行っています。
なので、自動生成されたpreAuth.1を上書きしない様にProjectUserのScanはpreAuth.2スロットで行います。

注意点としては、Taskモデルの主キーにソートキーを作ると、TaskTableは作り直しになります。その点に関してはこの方法のデメリットと言えるかもしれません。自分の場合はそのままamplify pushしてもエラーがでTaskTableの更新ができなかったので、一度Taskモデルの定義を消してamplify pushをし、TaskTableが消えた後、再度Taskモデルの定義を戻してamplify pushをすることで無事にデプロイできました。
既にTaskTableにデータがある場合は、データも消えてしまうので、データをエクスポートしてバックアップを取っておきます。S3へのエクスポート/インポートは既存テーブルへのインポートができなかったので、CSVでエクスポートし、インポートは専用のプログラムを作るなどして対応が必要そうです。

今回はマルチテナント型のアクセス制御にソートキーを活用する方法についてでした。ソートキーをつけることでリレーションモデルへの影響などもあるのですがその辺りの話はまた別の機会に。
10月中にα版を公開したかったですが、モデルのアクセス制御を色々検証していたら間に合わなくなってしまいました…。地道に実装を進めようと思います。

それではまた次の記事で。

もしこの記事があなたのお役に立てたなら幸いです。 よろしければサポートをお願いします。今後の制作資金にさせていただきます!