Amazon Web Services ブログ

SaaS におけるテナントリソースへのリクエストルーティングを JWT を用いて実現する

みなさんこんにちは。ソリューションアーキテクトの福本です。

本投稿のテーマは Software as a Service(SaaS)におけるルーティングです。
SaaS ではテナントごとにサーバーなどのリソースが分離されていることがあります。そのため、各テナントに属するユーザーからのリクエストを適切なリソースへとルーティングする必要があります。

具体的なルーティングの話に入る前に、SaaS のテナント分離モデルについて説明をします。SaaS では、テナントの分離モデルとしてサイロ、プール、ブリッジモデルが存在します。また、ユーザーがサブスクライブしている利用プラン (ティア) によって、リソースの分離形態が変わるような、階層ベースの分離もあります。

サイロモデルでは、ウェブサーバーやアプリサーバー、データベースサーバーなど専用のリソースが
テナントごとにそれぞれ提供されています。
この分離モデルを選択する背景や理由は様々ですが、例えば以下のような理由が挙げられます。

  • コンプライアンス要件により、リソースやデータの分離が求められる
  • 他のテナントによる影響を避ける(ノイジーネイバー
  • テナントごとのコストを正確に把握する

詳細に関しては、AWS BlackBelt SaaS アーキテクチャ 入門編~マルチテナント SaaS とは~ にて詳細を解説しておりますので、是非ご覧ください。

サイロモデルを選択した場合、テナントを識別して適切なリソースへのルーティングを行う必要があります。
その方法として、主に以下の 2 つの方法が考えられます。また、本投稿では詳細に触れませんが、https://example.com/tenant-a/xxx のような形でパスベースでルーティングを行う方法もあります。

routing_method

図 1 テナントごとにルーティングを行う方法の概念図

図 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 サービスをご利用頂いている方を想定しております。各サービスの詳細ついてはドキュメントをご覧ください。

アーキテクチャと概要の説明

architecture

図 2 . データをベースにしたルーティングのアーキテクチャ概要

図 2 には、データをベースにしたルーティングを行うためのアーキテクチャ概要を示しています。図中の矢印はテナントごとのリクエストの流れを表しています。各番号で示している6 つのステップについてまずはそれぞれのポイントを簡単に説明します。

  1. 今回は Amazon Cognito (Cognito) を ID プロバイダーとして使用し、こちらで認証を経て各テナントのコンテキスト情報としてテナント ID を含んだ JWT を取得します。
  2. 1 の手順で取得した JWT をリクエストのヘッダーに含め、Amazon API Gateway (API Gateway) に対してリクエストを送ります。
  3. API Gateway は認可に Lambda Authorizer を利用し、Lambda 関数内で JWT の検証や認可を行います。リクエスト内容が正当なものであれば、アクセスを許可するポリシーと、JWT から抽出したテナント情報を context として返します。リクエスト内容が不当な場合は、アクセスを拒否するポリシーが返り、ここでリクエストは終了となります。
  4. API Gateway では Lambda から返される context からテナント ID を取得し、ヘッダーに値をセットします。その後リクエストを後続のサービスへと流します。API Gateway は Amazon Virtual Private Cloud (VPC) 内の Network Load Balancer (NLB) と VPC リンクによりプライベートに統合されています。 VPC リンクを用いることで、プライベートサブネット内に置かれた NLB とセキュアに接続することが可能です。
  5. さらに、NLB のターゲットグループに ALB を設定しています。
  6. 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 の構文もそのまま利用可能です。
構文などの詳細について興味がある方はドキュメントブログをご確認ください。

前提条件

このセクションの手順を実行するには以下の条件が満たされていることをご確認ください。

  1. AWS SAM CLI のインストールが済んでいること (version 1.33.0以上)
  2. Docker がインストールされていること
  3. jq がインストールされていること
  4. AWS SAM によりデプロイされるリソースの各種操作権限が適切に設定されていること
  5. リソースをデプロイするための 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 を設定します。

poolName=sample-user-pool
userPool=$(aws cognito-idp create-user-pool --pool-name ${poolName} \
--schema Name=tenant_id,AttributeDataType=String,Mutable=true)

続いてユーザープールクライアントを作成します。
先の手順で作成したユーザープールの ID を取得し、userPoolId に格納します。また、clientName にユーザープールクライアントの名前を格納します。これらの情報を用いて、ユーザープールクライアントを作成して、その ID を取得して clientId に格納しておきます。

userPoolId=$(echo ${userPool} | jq -r .UserPool.Id)
clientName=sample-client
userPoolClient=$(aws cognito-idp create-user-pool-client --user-pool-id ${userPoolId} --client-name ${clientName} --explicit-auth-flows "ALLOW_ADMIN_USER_PASSWORD_AUTH" "ALLOW_REFRESH_TOKEN_AUTH")
clientId=$(echo ${userPoolClient} | jq -r .UserPoolClient.ClientId)

作成したユーザープールに各テナントのユーザーを登録します。
ユーザーの認証に用いる名前とパスワードを userNamepassword に格納します。また、ユーザープール作成時に指定したカスタム属性の tenant_id をここで指定します。

まずは tenantA に属する tenantAUser を作成します。

userName=tenantAUser
password=PasswordA1!
aws cognito-idp sign-up \
  --client-id ${clientId} \
  --username ${userName} \
  --password ${password} \
  --user-attributes Name="custom:tenant_id",Value="tenantA"

ユーザーを登録した時点ではユーザー確認が完了していないステータスになっています。
実際に、以下のコマンドでユーザーのステータスを確認してみます。

aws cognito-idp admin-get-user \
  --user-pool-id ${userPoolId} \
  --username ${userName} | jq -r .UserStatus

UNCONFIRMED がレスポンスとして返るはずです。

こちらをコマンドでステータスを確認済みに変更します。

aws cognito-idp admin-confirm-sign-up \
  --user-pool-id ${userPoolId} \
  --username ${userName}

再度コマンドにてユーザーのステータスを確認します。

aws cognito-idp admin-get-user \
  --user-pool-id ${userPoolId} \
  --username ${userName} | jq -r .UserStatus

CONFIRMED がレスポンスとして返ることが確認できれば、ユーザーの登録作業は完了です。

ユーザー登録の手順を tenantB に属する tenantBUser に対しても繰り返します。

userName=tenantBUser
password=PasswordB1!
aws cognito-idp sign-up \
  --client-id ${clientId} \
  --username ${userName} \
  --password ${password} \
  --user-attributes Name="custom:tenant_id",Value="tenantB"
  
aws cognito-idp admin-confirm-sign-up \
  --user-pool-id ${userPoolId} \
  --username ${userName}
  
aws cognito-idp admin-get-user \
  --user-pool-id ${userPoolId} \
  --username ${userName} | jq -r .UserStatus

SAM CLI でプロジェクトの初期化を行う

プロジェクトのルートとなるディレクトリで以下のコマンドを実行します。

sam init

画面に表示されるプロンプトに従ってください。この手順では、AWS Quick Start TemplatesZip パッケージタイプ、python3.9、プロジェクト名には jwt-routing 、および Hello World Example テンプレートを選択します。

出力例

-----------------------
    Generating application:
    -----------------------
    Name: jwt-routing
    Runtime: python3.9
    Architectures: x86_64
    Dependency Manager: pip
    Application Template: hello-world
    Output Directory: .
    
    Next steps can be found in the README file at ./jwt-routing/README.md

以下のコマンドで、作成したプロジェクトのディレクトリに移動します。

cd jwt-routing

SAM テンプレートと Lambda 関数のコードの準備をする

sam init により、プロジェクトのディレクトリ直下に template.yaml が生成されています。
その中身を以下の内容で置き換えます。テンプレートをコピーして貼り付けてください。

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  jwt-routing

  Sample SAM Template for jwt-routing

Globals:
  Function:
    Timeout: 10
    
Parameters:
    VpcId:
        Type: AWS::EC2::VPC::Id
    VpcCIDR:
        Type: String
    SubnetIdA:
        Type: AWS::EC2::Subnet::Id
    SubnetIdB:
        Type: AWS::EC2::Subnet::Id
    userPoolId:
        Type: String
    clientId:
        Type: String

Resources:
    LambdaSecurityGroup:
        Type: AWS::EC2::SecurityGroup
        Properties:
            VpcId: !Ref VpcId
            GroupName: !Sub ${AWS::StackName}-LambdaSecurityGroup
            GroupDescription: Security-Group for Lambda function
            SecurityGroupEgress:
              - IpProtocol: -1
                FromPort: -1
                ToPort: -1
                CidrIp: 0.0.0.0/0

   
    VPCFunctionA:
         Type: AWS::Serverless::Function
         Properties:
             CodeUri: vpcFunc
             Handler: tenant_a.lambda_handler
             Runtime: python3.9
             FunctionName : !Join ['-', [!Sub '${AWS::StackName}-VPCLambdaA', !Select [0, !Split ['-', !Select [2, !Split ['/', !Ref 'AWS::StackId' ]]]]]]
             VpcConfig:
                 SecurityGroupIds:
                     - !Ref LambdaSecurityGroup
                 SubnetIds:
                     - !Ref SubnetIdA
    
    LambdaAPermission:
        Type: AWS::Lambda::Permission
        Properties:
            FunctionName: !GetAtt VPCFunctionA.Arn
            Action: lambda:InvokeFunction
            Principal: elasticloadbalancing.amazonaws.com
        
    
    VPCFunctionB:
         Type: AWS::Serverless::Function
         Properties:
             CodeUri: vpcFunc
             Handler: tenant_b.lambda_handler
             Runtime: python3.9
             FunctionName : !Join ['-', [!Sub '${AWS::StackName}-VPCLambdaB', !Select [0, !Split ['-', !Select [2, !Split ['/', !Ref 'AWS::StackId' ]]]]]]
             VpcConfig:
                 SecurityGroupIds:
                     - !Ref LambdaSecurityGroup
                 SubnetIds:
                     - !Ref SubnetIdA
                     
    LambdaBPermission:
        Type: AWS::Lambda::Permission
        Properties:
            FunctionName: !GetAtt VPCFunctionB.Arn
            Action: lambda:InvokeFunction
            Principal: elasticloadbalancing.amazonaws.com


    AppApi:
        Type: AWS::ApiGateway::RestApi
        Properties:
            Name: apigw-rest-api-vpclink
            Description: VPC Link integraton REST API demo
            
    RootMethodGet:
        Type: AWS::ApiGateway::Method
        Properties:
            RestApiId: !Ref AppApi
            ResourceId: !GetAtt AppApi.RootResourceId
            HttpMethod: GET
            AuthorizationType: CUSTOM
            AuthorizerId: !Ref GateWayAuth
            Integration:
                Type: HTTP
                ConnectionType: VPC_LINK
                IntegrationHttpMethod: GET
                ConnectionId: !Ref VPCLinkRestNlbInternal
                Uri: !Sub
                    - http://${NlbInternalDns}
                    - NlbInternalDns: !GetAtt NetworkLoadBalancer.DNSName
                RequestParameters:
                    integration.request.header.X-tenant: 'context.authorizer.tenant_id'
                IntegrationResponses:
                    - StatusCode: 200
            MethodResponses:
                - StatusCode: 200
                  ResponseModels:
                      application/json: Empty
        
    Deployment:
        Type: AWS::ApiGateway::Deployment
        DependsOn:
        - RootMethodGet
        Properties:
            RestApiId: !Ref AppApi
            
    Stage:
        Type: AWS::ApiGateway::Stage
        Properties:
          StageName: Prod
          RestApiId: !Ref AppApi
          DeploymentId: !Ref Deployment
           
    VPCLinkRestNlbInternal:
      Type: AWS::ApiGateway::VpcLink
      Properties:
          Name: VPCLinkRestNlbInternal
          TargetArns:
              - !Ref NetworkLoadBalancer
               
    AuthFunc:
        Type: AWS::Serverless::Function
        Properties: 
            CodeUri: authFunc
            Handler: authorizer.lambda_handler
            Runtime: python3.9
            FunctionName : !Join ['-', [!Sub '${AWS::StackName}-AuthLambda', !Select [0, !Split ['-', !Select [2, !Split ['/', !Ref 'AWS::StackId' ]]]]]]
            Environment: 
                Variables:
                    USER_POOL_ID: !Ref userPoolId
                    CLIENT_ID: !Ref clientId
            
               
    GateWayAuth:
        Type: AWS::ApiGateway::Authorizer
        Properties: 
            AuthorizerUri: !Sub
                - arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${AuthLambda}/invocations
                - AuthLambda: !GetAtt [AuthFunc, Arn]
            RestApiId: !Ref AppApi
            Type: "TOKEN"
            IdentitySource: method.request.header.Authorization
            Name: !Join ['-', [!Sub '${AWS::StackName}-custom_auth', !Select [0, !Split ['-', !Select [2, !Split ['/', !Ref 'AWS::StackId' ]]]]]]

    GateWayAuthPermission:
        Type: AWS::Lambda::Permission
        Properties:
            Action: lambda:InvokeFunction
            FunctionName: !GetAtt AuthFunc.Arn
            Principal: apigateway.amazonaws.com
            SourceArn: !Sub 
                - arn:aws:execute-api:${AWS::Region}:${AWS::AccountId}:${ApiGatewayRestApi}/authorizers/${GateWayAuth}
                - ApiGatewayRestApi: !Ref AppApi
                  GateWayAuth: !Ref GateWayAuth
               
    NetworkLoadBalancer:
        Type: AWS::ElasticLoadBalancingV2::LoadBalancer
        Properties: 
            Scheme: internal
            Subnets:
                - !Ref SubnetIdA
                - !Ref SubnetIdB
            Type: network
    
    InternetNLBListener:
        Type: AWS::ElasticLoadBalancingV2::Listener
        Properties:
            DefaultActions:
                - Type: forward
                  TargetGroupArn: !Ref NetworkLoadBalancerTargetGroup
            LoadBalancerArn: !Ref NetworkLoadBalancer
            Port: 80
            Protocol: TCP
            
    NetworkLoadBalancerTargetGroup:
        DependsOn:
          - AlbListnener
        Type: AWS::ElasticLoadBalancingV2::TargetGroup
        Properties:
            VpcId: !Ref VpcId
            Port: 80
            Protocol: TCP
            TargetType: alb
            Targets:
              - Id: !Ref AppLoadBalancer
                Port: 80
                
    AlbSecurityGroup:
        Type: AWS::EC2::SecurityGroup
        Properties:
            GroupName: !Sub "${AWS::StackName}-alb-sg"
            GroupDescription: alb
            SecurityGroupIngress:
                - IpProtocol: tcp
                  FromPort: 80
                  ToPort: 80
                  CidrIp: !Ref VpcCIDR
            SecurityGroupEgress:
              - IpProtocol: -1
                FromPort: -1
                ToPort: -1
                CidrIp: 0.0.0.0/0
            VpcId: !Ref VpcId
                   
            
    AppLoadBalancer:
        Type: AWS::ElasticLoadBalancingV2::LoadBalancer
        Properties:
            Type: application
            Scheme: internal
            Subnets:
                - !Ref SubnetIdA
                - !Ref SubnetIdB
            SecurityGroups:
                - !Ref AlbSecurityGroup

    AppLoadBalancerTargetGroupA:
        Type: AWS::ElasticLoadBalancingV2::TargetGroup
        Properties:
            TargetType: lambda
            Targets:
                - Id: !GetAtt [VPCFunctionA, Arn]
    
    AppLoadBalancerTargetGroupB:
        Type: AWS::ElasticLoadBalancingV2::TargetGroup
        Properties:
            TargetType: lambda
            Targets:
                - Id: !GetAtt [VPCFunctionB, Arn]

    AlbListnener:
        Type: AWS::ElasticLoadBalancingV2::Listener
        Properties:
            DefaultActions:
                - Type: fixed-response
                  FixedResponseConfig:
                      StatusCode: 403
                      MessageBody: Not authorized Access.
                      ContentType: text/plain
            LoadBalancerArn: !Ref AppLoadBalancer
            Port: 80
            Protocol: HTTP

    LisnerRuleA:
        Type: AWS::ElasticLoadBalancingV2::ListenerRule
        Properties:
            Actions:
            - Type: forward
              TargetGroupArn: !Ref AppLoadBalancerTargetGroupA
            Conditions:
            - Field: http-header
              HttpHeaderConfig:
                  HttpHeaderName: X-tenant
                  Values:
                      - "tenantA"
            ListenerArn: !Ref AlbListnener
            Priority: 1
    
    LisnerRuleB:
        Type: AWS::ElasticLoadBalancingV2::ListenerRule
        Properties:
            Actions:
            - Type: forward
              TargetGroupArn: !Ref AppLoadBalancerTargetGroupB
            Conditions:
            - Field: http-header
              HttpHeaderConfig:
                HttpHeaderName: X-tenant
                Values:
                    - "tenantB"
            ListenerArn: !Ref AlbListnener
            Priority: 2
Outputs:
  RestAPIEndpoint:
    Value: !Sub 'https://${AppApi}.execute-api.${AWS::Region}.${AWS::URLSuffix}/${Stage}/'

続いて、Lambda Authorizer 用 (authFunc) と 各テナント用 (vpcFunc) の Lambda 関数のコードを格納するディレクトリを以下のコマンドで作成します。

mkdir {authFunc,vpcFunc}

以下のコマンドで Lambda Authorizer の Lambda 関数のコードとライブラリの設定ファイルを作成します。

touch authFunc/{authorizer.py,requirements.txt}

作成した authorizer.py に以下の内容をコピーして貼り付けます。

import os
import json
import time
import urllib.request

from jose import jwk, jwt
from jose.utils import base64url_decode

region = "ap-northeast-1"
userpool_id = os.environ['USER_POOL_ID']
app_client_id = os.environ['CLIENT_ID']
keys_url = "https://cognito-idp.{}.amazonaws.com/{}/.well-known/jwks.json".format(region, userpool_id)


with urllib.request.urlopen(keys_url) as f:
    response = f.read()
keys = json.loads(response.decode("utf-8"))["keys"]


def lambda_handler(event, context):

    token = event["authorizationToken"].split()[1]

    verified = verify_token(token)
    principalId = "user"

    # 検証が失敗した場合、リクエストを拒否するポリシーステートメントを返却
    if verified == False:
        return generatePolicy(principalId, "Deny", event["methodArn"], None)
    # 検証が成功した場合、リクエストを許可するポリシーステートメントとテナント情報をcontextオブジェクトに格納して返却
    else:
        tenant_id = verified["custom:tenant_id"]
        context = {"tenant_id": tenant_id}

        return generatePolicy(principalId, "Allow", event["methodArn"], context)


# トークン検証用の関数
def verify_token(token):
    # 検証に先立ち、ヘッダーからkidを取得
    headers = jwt.get_unverified_headers(token)
    kid = headers["kid"]
    # ダウンロードした公開鍵から一致するkidを探索
    key_index = -1
    for i in range(len(keys)):
        if kid == keys[i]["kid"]:
            key_index = i
            break
    if key_index == -1:
        print("Public key not found in jwks.json")
        return False
    # 公開鍵を設定
    public_key = jwk.construct(keys[key_index])
    # トークンからmessageとbase64でエンコードされたsignatureを取得
    message, encoded_signature = str(token).rsplit(".", 1)
    # signatureをデコード
    decoded_signature = base64url_decode(encoded_signature.encode("utf-8"))
    # signatureを検証
    if not public_key.verify(message.encode("utf8"), decoded_signature):
        print("Signature verification failed")
        return False
    print("Signature successfully verified")
    # 検証を完了したため、
    # 安全に未検証のトークンを利用可能
    claims = jwt.get_unverified_claims(token)
    # 追加でトークンの期限を検証可能
    if time.time() > claims["exp"]:
        print("Token is expired")
        return False
    # and the Audience  (use claims['client_id'] if verifying an access token)
    if claims["aud"] != app_client_id:
        print("Token was not issued for this audience")
        return False
    # これでクレームを使用可能
    print(claims)
    return claims


def generatePolicy(principalId, effect, resource, context):
    authResponse = {}

    authResponse["principalId"] = principalId

    if effect and resource:
        policyDocument = {
            "Version": "2012-10-17",
            "Statement": [
                {"Sid": "FirstStatement", "Action": "execute-api:Invoke", "Effect": effect, "Resource": resource}
            ],
        }

        authResponse["policyDocument"] = policyDocument

        if context:
            authResponse["context"] = context

        return authResponse
              

続いて、requirements.txt については以下の内容をコピーして貼り付けます。
requirements.txt はインストールするライブラリを指定するもので、今回は JWT を Python で扱うためにpython-jose を入れています。

python-jose

さらに、以下のコマンドで 各テナント用 の Lambda 関数のコードを作成します。

touch vpcFunc/{tenant_a.py,tenant_b.py}

作成した tenant_a.pytenant_b.py に以下の内容をそれぞれコピーして貼り付けます。

# テナント A

import json


def lambda_handler(event, context):

    return {
        "statusCode": 200,
        "body": json.dumps(
            {
                "message": "This is tenat A",
            }
        ),
    }
# テナント B

import json


def lambda_handler(event, context):

    return {
        "statusCode": 200,
        "body": json.dumps(
            {
                "message": "This is tenat B",
            }
        ),
    }

今回の SAM テンプレートで前提とするファイル構造 (一部省略) を以下に示しています。Lambda Authorizer 用の Lambda 関数 (authFunc/authorizer.py) 、テナントごとの Lambda 関数 (vpcFunc/tenant_a.py、tenant_b.py) が含まれることに注意してください。

jwt-routing/
├── authFunc
│   ├── authorizer.py
│   └── requirements.txt
├── init.py
├── README.md
├── samconfig.toml
├── template.yaml
└── vpcFunc
    ├── tenant_a.py
    └── tenant_b.py

SAM CLI でアプリケーションのビルドを行う

アプリケーションのビルドを行うために以下のコマンドを実行します。

sam build --use-container

--use-container オプションを利用することで、Lambda と同じような実行環境を模したコンテナ内でビルドを行うことができます。

以下のコマンドを実行して、ビルドしたアプリケーションを AWS 上にデプロイします。

sam deploy --guided --parameter-overrides userPoolId=${userPoolId} clientId=${clientId}

こちらの手順では、デプロイ時の入力項目として、スタック名は jwt-routing-app、AWS リージョンは ap-northeast-1 を入力します。また、各種リソースが作成される VPC をSAM テンプレート内で Parameters として指定しているので、あらかじめ準備済みの VpcIdSubnetIdASubnetIdB を入力します。

テンプレート内には他に userpoolIdappClientId のパラメータが指定されています。こちらは Lambda オーソライザーとして使用する Lambda 関数内で参照する値で、 テンプレート内にて Lambda 関数の環境変数として使用するように記述してあります。この 2 つのパラメータには先の手順にて AWS CLI で構築した Cognito ユーザープール関連の値を渡したいため、 --parameter-overrides のオプションで変数から値を渡しています。

パラメータ入力後は Confirm changes before deploy を y とし、それ以外の項目はデフォルトのまま進めます。

最後に、Deploy this changeset? に対して y を入力します。

これで環境の構築は終了です。

この手順で示したテンプレートはあくまでサンプルであり、実際の環境へ適用する際には各リソースの設定や権限などを必要に応じて修正してください。また、テンプレートにより作成される各 AWS リソースにはそれぞれ利用料がかかります。 不要なリソースは削除することを忘れないように注意してください。

リソースの削除は以下のコマンドで実施できます。

Cognito ユーザープールの削除

aws cognito-idp delete-user-pool --user-pool-id ${userPoolId}

SAM CLI により作成されるリソースの削除

sam delete

各ポイントの解説

Amazon Cognito ユーザープールへの各テナントの登録 (関連するステップ:1)

各テナントのユーザー情報を格納する ID プロバイダーとして Amazon Cognito を利用します。

Cognito には、ユーザープール内の認証に用いるユーザー情報を格納することができ、デフォルトの属性以外にも、任意のカスタム属性を含めることが可能です。今回はユーザー認証の結果得られる情報から、そのユーザーがどのテナントに属するかを知りたいので、テナントの ID をカスタム属性としてユーザーごとに格納します。

user_attribute

Cognito ユーザープールのユーザー属性

上の図は 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 リンクをご利用ください。詳細は後述します。

apigw_method

API Gateway の統合リクエストの設定

また、このあと説明する 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 オーソライザーの設定を確認することができます。

lambda_autholizer

API Gateway Lambda オーソライザーの設定

今回のケースでは、ユーザーからのリクエストの Authorization ヘッダーに、認証の結果得られた JWT がセットされていることを想定しています。そのため、Lambda オーソライザーの Lambda 関数内では、Authorization ヘッダーから JWT を取得、デコードし、検証を行います。

検証が成功した場合、API Gateway へのリクエストを許可する ポリシーステートメントと、テナント情報を context として返します

Lambda 関数の返り値として渡すポリシーステートメントは以下のような形式です。

{
  "principalId": "yyyyyyyy", 
  "policyDocument": {
    "Version": "2012-10-17",
    "Statement": [
      {
        "Action": "execute-api:Invoke",
        "Effect": "Allow|Deny",
        "Resource": "arn:aws:execute-api:{regionId}:{accountId}:{apiId}/{stage}/{httpVerb}/[{resource}/[{child-resources}]]"
      }
    ]
  }
}

指定された API の呼び出しを許可 / 拒否するか、対象のリソースは何か、などを記述します。
また、ルーティングに用いる情報として使用するテナント情報は以下のように context として格納し、返り値として渡します。

context = {
          "tenant_id": tenant_id
        }

tenant_id は Cognito のカスタム属性として設定したもので、JWT から取得しています。
このようにして、context を利用することで、後続のサービスに値を渡すことができます。

先ほど説明したように、API Gateway で以下のように設定することで、context から値を取得してヘッダーに設定することが可能です。

context

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 ヘッダーを使用して、ヘッダーの値に基づいたルーティングを行います。

listener_rule

ALB リスナールールの設定

上の図のように、Lambda オーソライザーの Lambda 関数から API Gateway に渡されヘッダーにセットされた値を見てルーティングを行うルールを作成します。具体的には、HTTP ヘッダー X-tenant が tenantAであれば、テナント A 用のターゲットグループに、HTTP ヘッダー X-tenant が tenantBであれば、テナント B 用のターゲットグループという形です。

テナントの数が増えるに従って、こちらのルールをテナントごとに設定する形になります。その際、ALB ごとに設定できるルール数にはクオータがありますので、注意する必要があります。

テナントごとの Lambda 関数を作成し、ターゲットグループに設定する (関連するステップ:6)

ALB によるルーティングでは、転送先としてターゲットグループを設定します。
今回は各テナントごとにリソースが分かれているため、テナントごとのターゲットグループを作成し、そこに Lambda 関数を登録します。

target_group

Lambda 関数を ALB のターゲットとして設定

ターゲットグループに登録している Lambda 関数ではテナントを表す文字列をレスポンスとして返しています。

JWT の取得からリソースへのアクセスまでを試す

ここまでで、データをベースとしたルーティングの実装について説明してきました。具体的には、JWT を利用してテナント別のルーティングを実現しています。
最後に、実際に Cognito ユーザープールへと認証を行い、得られた JWT を用いて、テナントごとにルーティングされるかを確認してみます。

まずは下記のように、AWS CLI コマンドで Cognito に対して認証を行い、その結果から IdToken を抽出します。

idtoken=$(aws cognito-idp admin-initiate-auth --user-pool-id ${userPoolId} --client-id ${clientId} --auth-flow "ADMIN_USER_PASSWORD_AUTH" --auth-parameters USERNAME=${userName},PASSWORD=${password} | jq -r '.AuthenticationResult.IdToken')

idtoken に入る値をデコードして、ペイロードを確認してみます。デコードには、jwt.io などを利用すると簡単に確認できて便利です。取り出した値は以下のようになります。

{
  "sub": "1234xxxx-1234-5678-1xxx-xxxxxxxxxx12",
  "email_verified": true,
  "iss": "https://cognito-idp.ap-northeast-1.amazonaws.com/ap-northeast-1_xxxxxxxxx",
  "cognito:username": "tenantAUser",
  "custom:tenant_id": "tenantA",
  "origin_jti": "3fcb9414-1234-5678-xxxx-a09b32b14fdd",
  "aud": "xxxxxxxamnfdra6nj123456789",
  "event_id": "057a5361-3850-4654-a55c-37a8eb47ca3b",
  "token_use": "id",
  "auth_time": 1654236536,
  "exp": 1654240136,
  "iat": 1654236536,
  "jti": "xxxxxxxx-1234-40b1-5678-xxxx123797ac"
}

Cognito で設定したカスタム属性も無事トークンに含まれていることがわかります。
続いて、こちらを利用して API Gateway のエンドポイントに対してリクエストを送ってみます。

SAM テンプレートでは Output セクションにて、API Gateway のエンドポイントを出力するように記述しました。

Output に出力されたエンドポイントをコマンドで取得し、変数にセットします。
※ デプロイが完了していることを確認してください。

endpoint=$(aws cloudformation describe-stacks --stack-name jwt-routing-app  | jq -r '.Stacks[].Outputs[].OutputValue')

curl を利用して下記のコマンドを実行します。

curl "${endpoint}" -H 'Content-Type:application)/json;charset=utf-8' -H 'Authorization: Bearer '${idtoken}''

以下のレスポンスが得られます。

{"message": "This is tenannt B"}

テナント 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 です。バイクで銭湯に行き、サウナに入ることにはまってます。