Amazon Web Services ブログ
SaaS におけるテナントリソースへのリクエストルーティングを JWT を用いて実現する
みなさんこんにちは。ソリューションアーキテクトの福本です。
本投稿のテーマは Software as a Service(SaaS)におけるルーティングです。
SaaS ではテナントごとにサーバーなどのリソースが分離されていることがあります。そのため、各テナントに属するユーザーからのリクエストを適切なリソースへとルーティングする必要があります。
具体的なルーティングの話に入る前に、SaaS のテナント分離モデルについて説明をします。SaaS では、テナントの分離モデルとしてサイロ、プール、ブリッジモデルが存在します。また、ユーザーがサブスクライブしている利用プラン (ティア) によって、リソースの分離形態が変わるような、階層ベースの分離もあります。
サイロモデルでは、ウェブサーバーやアプリサーバー、データベースサーバーなど専用のリソースが
テナントごとにそれぞれ提供されています。
この分離モデルを選択する背景や理由は様々ですが、例えば以下のような理由が挙げられます。
- コンプライアンス要件により、リソースやデータの分離が求められる
- 他のテナントによる影響を避ける(ノイジーネイバー)
- テナントごとのコストを正確に把握する
詳細に関しては、AWS BlackBelt SaaS アーキテクチャ 入門編~マルチテナント SaaS とは~ にて詳細を解説しておりますので、是非ご覧ください。
サイロモデルを選択した場合、テナントを識別して適切なリソースへのルーティングを行う必要があります。
その方法として、主に以下の 2 つの方法が考えられます。また、本投稿では詳細に触れませんが、https://example.com/tenant-a/xxx
のような形でパスベースでルーティングを行う方法もあります。
図 1 にはテナントごとにルーティングを行う方法を2つ示しています。
1 つはドメインをベースにルーティングを行う方法で、各テナントはテナントごとに設定されたサブドメインを指定してサービスにアクセスします。例えばテナント A とテナント B がいる場合、テナント A は tenant-a.example.com に、テナント B は tenant-b.example.com にアクセスすることで、それぞれのリソースへとルーティングされます。
テナントごとにサブドメインを用意し管理する手間を減らしたいなど、ドメインを利用した方法を選択したくないケースもあり得ます。そのような場合には 2 つ目の方法が使えます。
2 つ目はデータをベースにルーティングを行う方法で、認証処理の結果得られた JSON Web Token (JWT) を利用します。JWT にはテナントを識別するためのテナント id などのコンテキスト情報をカスタム属性として入れておき、そちらをルーティングを行うための情報として使います。
この方法では、同一のドメインでサービスを提供できることによるメリットが複数存在します。例えば以下のようなものです。
- テナントごとにサブドメインを払い出し、管理する必要がない
- テナント側からアクセスする先のドメインを変えずに、それぞれのリソースにアクセスできる
- 将来的にプールに移行する際も、すでにドメインは統一されているため、テナントに意識させずに裏側の仕組みだけ変更できる
ルーティングを行う 1 つ目の方法に関しては、例えば AWS が提供するドメインネームシステムである Amazon Route 53 のレコードと ロードバランシングのサービスである Elastic Load Balancing (ELB) の一タイプである Application Load Balancer (ALB) のホストベースのルーティングルールにより容易に実現が可能です。
一方で、2つ目の方法に関しては別途ルーティングの処理を実装する必要があります。
そこで、今回は AWS 上で2つ目の方法を実現する方法について重要な点を解説していきます。
本投稿では各種設定を事細かに説明することはしません。そのため、想定の読者は普段から以下の AWS サービスをご利用頂いている方を想定しております。各サービスの詳細ついてはドキュメントをご覧ください。
アーキテクチャと概要の説明
図 2 には、データをベースにしたルーティングを行うためのアーキテクチャ概要を示しています。図中の矢印はテナントごとのリクエストの流れを表しています。各番号で示している6 つのステップについてまずはそれぞれのポイントを簡単に説明します。
- 今回は Amazon Cognito (Cognito) を ID プロバイダーとして使用し、こちらで認証を経て各テナントのコンテキスト情報としてテナント ID を含んだ JWT を取得します。
- 1 の手順で取得した JWT をリクエストのヘッダーに含め、Amazon API Gateway (API Gateway) に対してリクエストを送ります。
- API Gateway は認可に Lambda Authorizer を利用し、Lambda 関数内で JWT の検証や認可を行います。リクエスト内容が正当なものであれば、アクセスを許可するポリシーと、JWT から抽出したテナント情報を context として返します。リクエスト内容が不当な場合は、アクセスを拒否するポリシーが返り、ここでリクエストは終了となります。
- API Gateway では Lambda から返される context からテナント ID を取得し、ヘッダーに値をセットします。その後リクエストを後続のサービスへと流します。API Gateway は Amazon Virtual Private Cloud (VPC) 内の Network Load Balancer (NLB) と VPC リンクによりプライベートに統合されています。 VPC リンクを用いることで、プライベートサブネット内に置かれた NLB とセキュアに接続することが可能です。
- さらに、NLB のターゲットグループに ALB を設定しています。
- 4 でヘッダーにセットされたテナント情報をもとに、ALB のリスナールールに基づいて AWS Lambda にリクエストをそれぞれルーティングし、テナントのリソースである Lambda 関数を起動します。
今回は例として Lambda 関数を各テナントのリソースに使用していますが、Amazon Elastic Compute Cloud (EC2) など他のサービスを使うことも可能です。
これらのステップについて重要なポイントをさらに説明していきます。
本投稿で解説するアーキテクチャをセットアップする (オプション)
図 2 に示したアーキテクチャを自動でセットアップするための AWS Serverless Application Model (AWS SAM) テンプレートを用意しました。AWS SAM は YAML 形式の構文で記述できる Infrastructure as Code のフレームワークです。SAM は AWS CloudFormation の拡張構文で、CloudFormation の構文もそのまま利用可能です。
構文などの詳細について興味がある方はドキュメントやブログをご確認ください。
前提条件
このセクションの手順を実行するには以下の条件が満たされていることをご確認ください。
- AWS SAM CLI のインストールが済んでいること (version 1.33.0以上)
- Docker がインストールされていること
- jq がインストールされていること
- AWS SAM によりデプロイされるリソースの各種操作権限が適切に設定されていること
- リソースをデプロイするための Amazon Virtual Private Cloud (Amazon VPC) が 1 つと、その中のプライベートサブネットが 2 つあること
1 と 2 の条件を満たすためには、クラウドベースの統合開発環境 (IDE) である AWS Cloud9 を利用することもできます。Cloud9 にはすでに AWS SAM CLI や Docker などがインストール済みのため、環境構築の手順を簡略化することが可能です。
手順
こちらの手順を実行すると、各 AWS リソースの利用料金がかかる点にご注意ください。
以降の手順では AWS の東京リージョン (ap-northeast-1) を想定しています。
Amazon Cognito でユーザープールを作成し、サンプルユーザを登録する
AWS CLI を利用して、ユーザープールを作成し、その中にテナント A、B 用ユーザをセットアップします。
(こちらの手順で作成する Cognito 関連のリソースは SAM テンプレートの記述に含まれておりません。)
まずは以下のコマンドにて、poolName
にユーザープールの名前を格納し、その名前を用いて cognito-idp create-user-pool
コマンドでユーザープールを作成します。ユーザープールにはユーザーがどのテナントに属するかの情報を格納するために、カスタム属性として tenant_id
を設定します。
続いてユーザープールクライアントを作成します。
先の手順で作成したユーザープールの ID を取得し、userPoolId
に格納します。また、clientName
にユーザープールクライアントの名前を格納します。これらの情報を用いて、ユーザープールクライアントを作成して、その ID を取得して clientId
に格納しておきます。
作成したユーザープールに各テナントのユーザーを登録します。
ユーザーの認証に用いる名前とパスワードを userName
と password
に格納します。また、ユーザープール作成時に指定したカスタム属性の tenant_id
をここで指定します。
まずは tenantA
に属する tenantAUser
を作成します。
ユーザーを登録した時点ではユーザー確認が完了していないステータスになっています。
実際に、以下のコマンドでユーザーのステータスを確認してみます。
UNCONFIRMED
がレスポンスとして返るはずです。
こちらをコマンドでステータスを確認済みに変更します。
再度コマンドにてユーザーのステータスを確認します。
CONFIRMED
がレスポンスとして返ることが確認できれば、ユーザーの登録作業は完了です。
ユーザー登録の手順を tenantB
に属する tenantBUser
に対しても繰り返します。
SAM CLI でプロジェクトの初期化を行う
プロジェクトのルートとなるディレクトリで以下のコマンドを実行します。
画面に表示されるプロンプトに従ってください。この手順では、AWS Quick Start Templates
、Zip
パッケージタイプ、python3.9
、プロジェクト名には jwt-routing
、および Hello World Example
テンプレートを選択します。
出力例
以下のコマンドで、作成したプロジェクトのディレクトリに移動します。
SAM テンプレートと Lambda 関数のコードの準備をする
sam init
により、プロジェクトのディレクトリ直下に template.yaml
が生成されています。
その中身を以下の内容で置き換えます。テンプレートをコピーして貼り付けてください。
続いて、Lambda Authorizer 用 (authFunc) と 各テナント用 (vpcFunc) の Lambda 関数のコードを格納するディレクトリを以下のコマンドで作成します。
以下のコマンドで Lambda Authorizer の Lambda 関数のコードとライブラリの設定ファイルを作成します。
作成した authorizer.py
に以下の内容をコピーして貼り付けます。
続いて、requirements.txt
については以下の内容をコピーして貼り付けます。
requirements.txt
はインストールするライブラリを指定するもので、今回は JWT を Python で扱うためにpython-jose
を入れています。
さらに、以下のコマンドで 各テナント用 の Lambda 関数のコードを作成します。
作成した tenant_a.py
と tenant_b.py
に以下の内容をそれぞれコピーして貼り付けます。
今回の SAM テンプレートで前提とするファイル構造 (一部省略) を以下に示しています。Lambda Authorizer 用の Lambda 関数 (authFunc/authorizer.py) 、テナントごとの Lambda 関数 (vpcFunc/tenant_a.py、tenant_b.py) が含まれることに注意してください。
SAM CLI でアプリケーションのビルドを行う
アプリケーションのビルドを行うために以下のコマンドを実行します。
--use-container
オプションを利用することで、Lambda と同じような実行環境を模したコンテナ内でビルドを行うことができます。
以下のコマンドを実行して、ビルドしたアプリケーションを AWS 上にデプロイします。
こちらの手順では、デプロイ時の入力項目として、スタック名は jwt-routing-app
、AWS リージョンは ap-northeast-1
を入力します。また、各種リソースが作成される VPC をSAM テンプレート内で Parameters
として指定しているので、あらかじめ準備済みの VpcId
、SubnetIdA
、SubnetIdB
を入力します。
テンプレート内には他に userpoolId
と appClientId
のパラメータが指定されています。こちらは Lambda オーソライザーとして使用する Lambda 関数内で参照する値で、 テンプレート内にて Lambda 関数の環境変数として使用するように記述してあります。この 2 つのパラメータには先の手順にて AWS CLI で構築した Cognito ユーザープール関連の値を渡したいため、 --parameter-overrides
のオプションで変数から値を渡しています。
パラメータ入力後は Confirm changes before deploy を y
とし、それ以外の項目はデフォルトのまま進めます。
最後に、Deploy this changeset? に対して y
を入力します。
これで環境の構築は終了です。
この手順で示したテンプレートはあくまでサンプルであり、実際の環境へ適用する際には各リソースの設定や権限などを必要に応じて修正してください。また、テンプレートにより作成される各 AWS リソースにはそれぞれ利用料がかかります。 不要なリソースは削除することを忘れないように注意してください。
リソースの削除は以下のコマンドで実施できます。
Cognito ユーザープールの削除
SAM CLI により作成されるリソースの削除
各ポイントの解説
Amazon Cognito ユーザープールへの各テナントの登録 (関連するステップ:1)
各テナントのユーザー情報を格納する ID プロバイダーとして Amazon Cognito を利用します。
Cognito には、ユーザープール内の認証に用いるユーザー情報を格納することができ、デフォルトの属性以外にも、任意のカスタム属性を含めることが可能です。今回はユーザー認証の結果得られる情報から、そのユーザーがどのテナントに属するかを知りたいので、テナントの ID をカスタム属性としてユーザーごとに格納します。
上の図は Cognito ユーザープールに登録した、tenantAUser
のユーザー属性を示しています。このユーザーはテナント A に属するため、カスタム属性の tenant_id
に tenantA が登録されています。このユーザープールには同様に、テナント B に属するユーザーも登録してあります。
Amazon API Gateway の設定 (関連するステップ:2)
API Gateway は現在 HTTP API と REST API が提供されています。本投稿では REST API を前提としています。REST API はかねてから提供されているもので、HTTP API に比べてより多くの機能を備えています。2つの API の違いなどに関しては、「HTTP API または REST API を選択する」をご覧ください。
上に示したアーキテクチャ図のように、今回は API Gateway はプライベートサブネット内に置かれた NLB にリクエストを送ります。そのため、統合タイプは VPC リンクを選択します。
※ REST API の API Gateway では、API Gateway へのリクエストを 直接 ALB に流すことも可能ですが、その場合は ALB がパブリックサブネットに存在する必要があります。API Gateway 以降のリソースを閉じられたネットワーク環境に置きたい場合は、今回の構成のように NLB を用意し、VPC リンクをご利用ください。詳細は後述します。
また、このあと説明する API Gateway Lambda オーソライザーによって、リクエストのペイロードから取得されるテナント情報を HTTP ヘッダーに挿入するために、API Gateway の統合リクエストにて、X-tenant
というヘッダーを設定し、そのマッピング元を context.authorizer.tenant_id
としています。
API Gateway Lambda オーソライザーによる認可 (関連するステップ:3, 4)
Lambda オーソライザーを用いることで、認可のロジックを柔軟に実装することが可能です。このロジックで許可された場合のみ、API へのリクエストは成功し、それ以外は拒否され失敗します。
※ 今回は JWT からテナントの情報を抜き出して検証をし、後続のサービスで使うために値をヘッダーに設定する処理を実装しています。もしこのような認可ロジックなどを必要とせず、 Cognito での認証されたユーザーからのリクエストだけを許可したいケースでは、Cognito をオーソライザーとして使用してよりシンプルに API Gateway へのリクエスト制御を実現できます。
API Gateway の画面にて、Lambda オーソライザーの設定を確認することができます。
今回のケースでは、ユーザーからのリクエストの Authorization
ヘッダーに、認証の結果得られた JWT がセットされていることを想定しています。そのため、Lambda オーソライザーの Lambda 関数内では、Authorization
ヘッダーから JWT を取得、デコードし、検証を行います。
検証が成功した場合、API Gateway へのリクエストを許可する ポリシーステートメントと、テナント情報を context として返します。
Lambda 関数の返り値として渡すポリシーステートメントは以下のような形式です。
指定された API の呼び出しを許可 / 拒否するか、対象のリソースは何か、などを記述します。
また、ルーティングに用いる情報として使用するテナント情報は以下のように context として格納し、返り値として渡します。
tenant_id
は Cognito のカスタム属性として設定したもので、JWT から取得しています。
このようにして、context を利用することで、後続のサービスに値を渡すことができます。
先ほど説明したように、API Gateway で以下のように設定することで、context から値を取得してヘッダーに設定することが可能です。
NLB のターゲットとして ALB を設定する (関連するステップ:5)
今回の構成では、VPC リンクを用いることで、API Gateway とプライベートサブネットにある NLB を接続しています。実際のルーティングを担うのは後続の ALB ですが、API Gateway 以降のリソースをプライベートな環境に置くために NLB と VPC リンクを利用しています。
※ NLB はターゲットグループとして ALB を指定することができます。ALB が自動的にスケールアップするのに伴い、ロードバランサーの IP アドレスは動的に変わりますが、NLBのターゲットとしてALBを直接登録することにより、ALBのIPアドレスの変更を管理する必要がなくなります。詳しくはブログの Network Load BalancerのターゲットグループにApplication Load Balancerを設定するをご覧ください。
ALB のリスナールールでヘッダーの基づくルーティングを行う (関連するステップ:6)
先の手順にて、各テナントの id がヘッダーに挿入されました。ALB のリスナールールの条件のタイプで各リクエストの HTTP ヘッダーを使用して、ヘッダーの値に基づいたルーティングを行います。
上の図のように、Lambda オーソライザーの Lambda 関数から API Gateway に渡されヘッダーにセットされた値を見てルーティングを行うルールを作成します。具体的には、HTTP ヘッダー X-tenant が tenantAであれば、テナント A 用のターゲットグループに、HTTP ヘッダー X-tenant が tenantBであれば、テナント B 用のターゲットグループという形です。
テナントの数が増えるに従って、こちらのルールをテナントごとに設定する形になります。その際、ALB ごとに設定できるルール数にはクオータがありますので、注意する必要があります。
テナントごとの Lambda 関数を作成し、ターゲットグループに設定する (関連するステップ:6)
ALB によるルーティングでは、転送先としてターゲットグループを設定します。
今回は各テナントごとにリソースが分かれているため、テナントごとのターゲットグループを作成し、そこに Lambda 関数を登録します。
ターゲットグループに登録している Lambda 関数ではテナントを表す文字列をレスポンスとして返しています。
JWT の取得からリソースへのアクセスまでを試す
ここまでで、データをベースとしたルーティングの実装について説明してきました。具体的には、JWT を利用してテナント別のルーティングを実現しています。
最後に、実際に Cognito ユーザープールへと認証を行い、得られた JWT を用いて、テナントごとにルーティングされるかを確認してみます。
まずは下記のように、AWS CLI コマンドで Cognito に対して認証を行い、その結果から IdToken を抽出します。
idtoken
に入る値をデコードして、ペイロードを確認してみます。デコードには、jwt.io などを利用すると簡単に確認できて便利です。取り出した値は以下のようになります。
Cognito で設定したカスタム属性も無事トークンに含まれていることがわかります。
続いて、こちらを利用して API Gateway のエンドポイントに対してリクエストを送ってみます。
SAM テンプレートでは Output セクションにて、API Gateway のエンドポイントを出力するように記述しました。
Output に出力されたエンドポイントをコマンドで取得し、変数にセットします。
※ デプロイが完了していることを確認してください。
curl を利用して下記のコマンドを実行します。
以下のレスポンスが得られます。
テナント B 用のリソースにルーティングされたことがわかります。
※ idtoken
取得時の変数には tenantBUser
の情報が格納されているため、テナント B 用のリソースにルーティングされています。同様の手順を認証部分からテナント A に対して実施すると、テナント A 用のリソースにルーティングされます。
まとめ
本投稿では JWT を用いたコンテキストベースのルーティングを実現する方法について説明しました。
ユーザーの Id 管理と認証には Amazon Cognito ユーザープールを利用し、また、テナント情報を格納するために Cognito のカスタム属性を利用しました。認可においては、Amazon API Gateway と Lambda を利用することで、JWT の検証やテナント情報の抽出と、ヘッダーへの挿入を実現しています。
VPC リンクを利用した API Gateway と NLB の統合により、リソースをプライベートサブネット内に配置でき、よりセキュアな構成となっています。
また、ALB の HTTP ヘッダーによるルーティングにより、テナントごとのリソースへのルーティングを行っています。
投稿の最後では、実際にユーザーの認証を行い得られた JWT を API へのリクエストに含めることで、テナントごとのルーティングが行えることも確認しました。
今回ご説明したデータをベースとしたルーティング方法では、テナントから見えるドメインは 1 つです。仮にテナントごとにサブドメインを割り振るような場合、新規テナントのオンボーディング時には新規テナント用のサブドメインの用意や、各システムへの反映作業なども必要です。そのような煩雑さがなくなり、運用がスケールする点はデータをベースとしたルーティング方法のメリットです。
また、Lambda オーソライザー を利用することによる、より柔軟な認可ロジックを実装できる点もメリットです。
Lambda 関数からは Amazon DynamoDB などのデータベースにアクセスすることも可能なので、テナント ID に紐づく情報をデータベースから取得し、アクセス許可を判断するような認可ロジックも組めます。また、レスポンスとして返すポリシーの記述によって、特定の IP アドレスのみが API にアクセスできるような実装も行えます。
冒頭で説明したドメインをベースとしたルーティングの方法か、今回ご説明したデータをベースとした方法はどちらが良いというものではありません。サブドメインを利用したルーティングは実装が簡単ではあるものの、テナントごとのドメイン管理が負担になったり、柔軟な認可ロジックを組むことは難しいです。一方で、今回ご紹介したデータドリブンな方法では、ドメインを統一することも可能で、JWT に含まれる情報をもとにした認可を行うこともできます。ドメインをベースとした方法ではテナントごとに増加する要素がサブドメインであったのに対して、データをベースとした方法では ALB のルール数が増えていくことになるため、その点を認識しておくことも重要です。
メリットやデメリットを把握した上で、皆さんの要件に応じて、合う方法をご選択いただければと思います。
ドメインをベースとしたルーティングではなく、JWT などを利用してデータをベースとしてルーティングを行いたいといった際には、本投稿が皆さんのお役に立つことを願っております。
著者
ソリューションアーキテクト 福本 健亮
普段はISV / SaaS 領域のお客様の技術的な支援をしております。
好きな AWS サービスは AWS Lambda です。バイクで銭湯に行き、サウナに入ることにはまってます。