見出し画像

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

KanbanGantt(仮)はマルチテナント型のサービスです。本サービスにおけるテナントをプロジェクトと定義しています。
ユーザーはいくつでもプロジェクトを作ることができます。プロジェクトの中にはそれぞれのタスクなどの情報があります。
この様なマルチテナント型のサービスではリソースへのアクセス制限が重要になってきます。
例えば、以下の様なプロジェクトとユーザーがいたとします。

  • プロジェクトA

    • ユーザー1

    • ユーザー2

  • プロジェクトB

    • ユーザー3

この時、プロジェクトAのタスクはプロジェクトAに所属していないユーザー3からアクセスできてはいけません。こうなってしまうと立派な情報漏洩でセキュリティインシデントです。
もしそんなことが起きたらと想像するだけで胃が痛くなりそうですが、昨今のモダンなフレームワークだったり、RDBMSを使用している一般的なwebサービスであればそこまで実装が難しい話ではありません。

ところが、AWS Amplifyを使用した場合は話が違ってきます。Amplifyではマルチテナント型のアクセス制御をフレームワークレベルでは提供していないため、開発者自らがデータ構造からバリデーションから何から何まで、どうAmplifyの機能を使って実現するのかを設計し、実装しなければいけません。

幸い、先駆者達が幾つかの方法を考案しているため、全くのゼロから考える必要はありませんでしたが、サービスの特性に合わせて幾つかの実現方法があるため、それらを比較検証し自分の開発するサービスに適した実装方法を吟味する必要がありました。
それらの詳細方法は既にネット上にある記事に委ねるとして、今回はKanbanGantt(仮)で採用しているマルチテナント型のアクセス制御の実装方法について紹介します。
(興味がある方は、「Amplify multi-tenant」などで検索していくと、いくつか記事が見つかると思います。)

モデル定義

Projectモデル、Userモデル、Taskモデルを定義します。それぞれの定義詳細は以下の通り。

type Project @model @auth(rules: [{allow: private}]) {
  id: ID!
  name: String!
  users: [User] @manyToMany(relationName: "ProjectUser")
  owner: String
}

type User @model @auth(rules: [{allow: private, operations: [read, update, delete]}, {allow: private, provider: iam}]) {
  id: ID!
  name: String! 
  email: AWSEmail!
  eula: Boolean!
  tasks: [Task] @manyToMany(relationName: "TaskUser")
  projects: [Project] @manyToMany(relationName: "ProjectUser")
  owner: String @auth(rules: [{allow: private, operations: [read]}, {allow: private, provider: iam}])
}


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"])
}

ProjectにUserを所属させるため、@manyToManyでProjectとUserは多対多の関係になっています。
また、TaskとUserも多対多です。(Task.assigneesとUser.tasks)
そして重要なのはTaskにはprojectIdを持たせます。
このモデル定義をビルドすると以下の中間モデルが生成されます。

type ProjectUser @aws_iam @aws_cognito_user_pools {
  id: ID!
  projectId: ID!
  userId: ID!
  project: Project!
  user: User!
  createdAt: AWSDateTime!
  updatedAt: AWSDateTime!
}

type TaskUser @aws_iam @aws_cognito_user_pools {
  id: ID!
  userId: ID!
  taskId: ID!
  user: User!
  task: Task!
  createdAt: AWSDateTime!
  updatedAt: AWSDateTime!
}

以上でモデル定義は完了です。

アクセス制御の実装

今回はCognito認証を使用しているため、モデルへアクセスする際のGraphQLリクエストにはユーザーIDが入ってきます。
要するに、そのユーザーIDが、アクセス対象のモデルのあるプロジェクトに所属しているかどうかをチェックすれば良いわけです。
PythonやNode.js等を使用したサーバーサイド処理であれば何のことはなさそうな処理に聞こえますが、ここで立ちはだかるのがリゾルバーという仕組み、そしてVTL…。

リゾルバーとは

実装方法の説明の前に、リゾルバーについての知識が必要です。
リゾルバーの詳細な説明は公式ドキュメントにお任せしますが、簡単に言うと、GraphQLのリクエストの際に呼ばれる、認証チェックやモデルのCRUD処理などのサーバーサイド処理が書かれたものです。

その様に言ってしまうと、一般的なプログラミング言語によるサーバーサイド処理と同じ様に聞こえますが、仕組み上次の様な特徴があります。

  1. 1リゾルバーはリクエストテンプレートと、レスポンステンプレートの2枚一組。

  2. VTLという言語を使う。

  3. 1リゾルバーでアクセスできるリソースは1つだけ。つまり、1リゾルバーでアクセスできるDynamoDBのテーブルは1つだけ。ただし、複数のリゾルバーを連結して使用できる。(パイプラインリゾルバーと呼ばれる。)

  4. 共通処理を関数化したて再利用するなどはできない。

リゾルバー

実装にあたって、かなり開発者泣かせな特徴です。慣れない内はDynamoDBからデータを取ってくるだけで一苦労なので、バリデーションのためにいくつものテーブルにアクセスしなければいけない時など頭がパンクしそうです。ファイル数もどんどん増えてきます。

ちなみに、VTLに関してはJavaScriptに置き換わる流れです。VTLのリファレンスページにも廃止予定の案内が表示されています。
サンプルもリリースされていますが、何となくまだまだ開発途中で、直ぐに使い方が変わりそうな匂いがしているので様子をみてKanbanGantt(仮)ではしばらく頑張ってVTLを書こうと思います。

リゾルバー開発時の工夫

さてさて、こんな大変なリゾルバーという仕組みを使って、モデルのアクセス制御やバリデーションなどを実装していかなければいけないのですが、少しでも楽にするために以下のような工夫をしています。

  1. できるだけ自動生成を活用し、OverrideではなくExtendする。

  2. 共通化できる処理は1ファイルにまとめてシンボリックリンクを張る。

1. できるだけ自動生成を活用し、OverrideではなくExtendする

Amplifyはモデル定義からリゾルバーを自動生成しますが、それらのリゾルバーはOverrideまたはExtendすることができます。

resolversディレクトリ以下に同名ファイルを置けばOverrideされますが、自動生成されたリゾルバーは結構複雑な処理がかかれていたりするので、不用意に触らないことにしました。

その代わり、resolversディレクトリ以下に決められた命名規則に従ってファイルを作成することでリゾルバーを拡張することができるため、必要な処理をその機能で挟み込んでいきます。

まだ実践途中なので詰め切れていませんが、現状のスロットと役割は以下のようにしています。

  • preAuth: 認証処理に必要なデータを取得する。

  • auth: 実際の認証処理(アクセス制御処理)。

  • preDataLoad/preUpdate: 値のバリデーションやデフォルト値の代入など。(認証処理以外)

2. 共通化できる処理は1ファイルにまとめてシンボリックリンクを張る

resolversディレクトリ以下に命名規則に従ったファイルを置きますが、シンボリックリンクでもOKでした。
なので、例えば色んなモデルのpreAuthスロットでProjectUserテーブルのデータを取得する処理が必要ですが、その処理をscan_project_user.req.vtlなどという名前で作成しておき、Query.listTasks.preAuth.1.req.vtlなどという名前でシンボリックリンクを張ります。
こうすることで、同じファイルをいくつも作成する必要がなく、変更があった場合も1ファイルの修正で済みます。

実装方法

ようやく具体的な実装方法の説明です。
上記の知識を使って、Taskモデルへのアクセス制御を実装すると以下のようになります。

resolversディレクトリ以下にscan_project_user.req.vtlを作成。内容は以下の通り。

#set( $userId = $util.defaultIfNull($ctx.identity.claims.get("sub"), null) )

#set( $expressionNames = {} )
$util.qr($expressionNames.put("#userId", "userId"))

#set( $expressionValues = {} )
$util.qr($expressionValues.put(":userId", $util.parseJson($util.dynamodb.toDynamoDBJson($userId))))

#set( $filter = {
  "expression": "#userId = :userId",
  "expressionNames": $expressionNames,
  "expressionValues": $expressionValues
} )

#set( $ListRequest = {
  "version": "2018-05-29",
  "operation": "Scan",
  "filter": {}
})
#set( $ListRequest.filter = $filter)
$util.toJson($ListRequest)

scan_project_user.req.vtlのレスポンスとなるテンプレートscan_project_user.res.vtlを以下の内容で作成。リクエストテンプレートの結果をコンテキストに保存しています。

## [Start] ResponseTemplate. **
#if( $ctx.error )
  $util.error($ctx.error.message, $ctx.error.type)
#else
  $util.qr($ctx.stash.put("projectUsers", $ctx.result.items))
#end

$util.toJson({})

resolversディレクトリ以下にcheck_auth_for_query.req.vtlというファイルを作成。内容は以下の通り。

#if( $ctx.error )
  $util.error($ctx.error.message, $ctx.error.type)
#else

  #set($projectIds = [])
  #foreach($item in $ctx.stash.projectUsers)
    $util.qr($projectIds.add($item.projectId))
  #end

  #set($authFilter = [])
    
  #if(!$projectIds.isEmpty())
    $util.qr($authFilter.add({"projectId": { "in": $projectIds }}))
  #else
    ## 何もヒットしないようにする
    $util.qr($authFilter.add({"projectId": { "eq": "0" }}))
  #end

  $util.qr($ctx.stash.put("authFilter", {"or": $authFilter}))
  $util.toJson({})

#end

scan_project_user.res.vtlで保存したprojectUsersを読みだして使います。

authFilterという変数が何なのかわかりにくいですが、実際にTaskテーブルからデータを取得するリゾルバーでフィルタ条件に入っていきます。
$ctx.result.itemsにはリクエストテンプレートで実行したScan ProjectUserテーブルの結果が入りますので、そのデータからprojectIdの配列を作成し、authFilterにin条件で追加しています。

この辺りは自動生成したリゾルバーを良く読む必要があります。VTLの学習にあたって、一番勉強になるのは自動生成されたリゾルバーを読むことだと思います。

次に、
scan_project_user.req.vtl -> Query.listTasks.preAuth.1.req.vtl
scan_project_user.res.vtl -> Query.listTasks.preAuth.1.res.vtl
check_auth_for_query.vtl -> Query.listTasks.auth.2.req.vtl
とシンボリックリンクを張ります。
これでlistTasksが実行された時に上記で追加したリゾルバーが実行されるようになります。
Query.listTasks.auth.2.res.vtlは不要です。デプロイ時は何もしないVTLがデプロイされます。

最後に忘れてはいけないのが、Extendしたリゾルバーではデータソースの指定がされていないということです。
開発当初、VTL内で「"operation": "Scan",」などは書いているけど、対象のテーブルはどこに書いているんだろう? と悩みました…。
Extendしたリゾルバーにデータソースを指定するには、override.tsに処理を書く必要があります。override.tsはビルド時に呼ばれます。

export function override(resources: AmplifyApiGraphQlResourceStackTemplate, amplifyProjectInfo: AmplifyProjectInfo) {
    resources.models["Task"].appsyncFunctions["QuerylistTasksauth1FunctionQuerylistTasksauth1Function.AppSyncFunction"].dataSourceName = "ProjectUserTable";

    ...

また、注意点として、appsyncFunctionsのキー名ですが、同じ内容のリゾルバーだと複数のモデルで共有されるようです。詳しい挙動は謎ですが、たとえば、同じ内容のリクエストテンプレート、Query.listTasks.preAuth.1.req.vtlとQuery.listSomeModels.preAuth.1.req.vtlがあったとき、Query.listTasks.preAuth.1.req.vtlだけがデプロイされ、listSomeModelsではQuery.listSomeModels.preAuth.1.req.vtlの代わりに、Query.listTasks.preAuth.1.req.vtlが使われたりします。なので、appsyncFunctions[QuerylistSomeModelsauth1FunctionQuerylistSomeModelsauth1Function.AppSyncFunction"].dataSourceNameに、"ProjectUserTable"を代入しようとするとキーが無いためエラーになったりします。
appsyncFunctionsの値をconsole.log()でデバッグしながら調整します。

これでamplify pushするとリゾルバーがデプロイされます。
デプロイされたリゾルバーはAWSコンソールのAWS AppSync > {API_NAME} > Functionsから確認できます。

デプロイされたリゾルバー

上記の実装で、ProjectAに所属しているUser1がlistTasksを実行すると、ProjectAのタスクのみが取得できるようになります。
まだまだ開発途中であり、抜け穴などがないか確認中ではありますが、大枠の実装としては今回の方法をさらに進めて、他のバリデーションなども実装していく予定です。

以上、AWS Amplifyでマルチテナント型のアクセス制御を実装する方法についてでした。
今回はかなり駆け足の説明でしたので、Amplifyに興味のある方で、よくわからなかった部分や、もっと詳しく聞きたい部分などありましたら是非気軽にコメントください。
それではまた次回。

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