API Gateway の Lambda オーソライザーキャッシュを使う時に問題となる実装を検証してみた

2023.04.27

いわさです。

API Gateway では Lambda オーソライザーでポリシーを生成することで、対象のリクエストがどの API リソースに対してアクセスを許可するか、あるいは拒否するかを動的にコントロールすることが出来ます。

一方で、Lambda オーソライザーはリクエストが発生する度に認可のためだけに Lambda が必ず実行されることになります。
これは、リクエスト数が多いサービスなどでは、動的生成によるレイテンシー増加や Lambda の実行回数が増えることによる利用料金増加が気になるところです。

有効な対策として Lambda オーソライザーにはキャッシュ機能があります。
こちらを有効化することで認証トークンをキャッシュキーにポリシードキュメントをキャッシュすることが出来るので、高速にポリシーを取得しつつキャッシュが有効な間は Lambda の冗長な実行が不要になります。

Lambda オーソライザーのキャッシュ検証については以前次の記事で紹介させて頂いたことがあります。

この記事をひさしぶりに見ていたのですが、Lambda オーソライザーで実行する関数を次のように実装していました。

import json

def lambda_handler(event, context):
    print(json.dumps(event))
    return {
        'principalId': event["authorizationToken"],
        'policyDocument': {
            'Version': '2012-10-17',
            'Statement': [{
                'Action': 'execute-api:Invoke',
                'Effect': 'Allow',
                'Resource': event['methodArn']
            }]
        }
    }

たまにサンプルコードなどで上記のようにリクエストされたメソッド・パスに対して許可するか拒否するかを判定しているコードを見ることがあります。
これはリクエストごとに都度判定するような場合であれば問題なく動作すると思いますが、キャッシュを有効化した場合だと意図せぬ挙動をしてしまうのでは?と気になりました。

結論としては、期待しない認可動作となる場合があります。
本日はこちらの検証結果を紹介したいと思います。

Lambda オーソライザーに使ったコードのベース

Python 3.8 になりますが、Lambda オーソライザー用のブループリントが用意されています。
今回はこちらのブループリントをベースに検証を行いました。

改めて思うと、ブループリントではリクエストパターンによる動的ポリシーの切り替えは最低限な実装になっていました。
よく出来ていますね。

まずは普通にキャッシュなしでオーソライザーを使ってみる

抜粋になりますが、次のようなポリシー生成コードを用意してみました。
ハイライト部分でどのリソースへのアクセスを許可するか設定していまして、ここでは hoge へのアクセスは許可し、fuga へのアクセスは拒否するようにしてみました。

lambda_function.py

import re

def lambda_handler(event, context):
    print("Client token: " + event['authorizationToken'])
    print("Method ARN: " + event['methodArn'])

    principalId = 'user|a1b2c3d4'

    tmp = event['methodArn'].split(':')
    apiGatewayArnTmp = tmp[5].split('/')
    awsAccountId = tmp[4]

    policy = AuthPolicy(principalId, awsAccountId)
    policy.restApiId = apiGatewayArnTmp[0]
    policy.region = tmp[3]
    policy.stage = apiGatewayArnTmp[1]
    policy.allowMethod(HttpVerb.GET, '/hoge/*')
    policy.denyMethod(HttpVerb.GET, '/fuga/*')

    authResponse = policy.build()
    return authResponse

上記関数をデプロイ後に API Gateway コンソールから Lambda オーソライザーを作成します。
まずはキャッシュを無効化した状態にします。

Lambda オーソライザーのコンソールでどういったポリシーが生成されるのかテストすることが出来るのでこの時点で一度確認してみましょう。

次のようなポリシーが生成されていました。
期待どおりと考えて良いでしょう。

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Action": "execute-api:Invoke",
      "Effect": "Allow",
      "Resource": [
        "arn:aws:execute-api:ap-northeast-1:123456789012:whtqmzxmr9/ESTestInvoke-stage/GET/hoge/*"
      ]
    },
    {
      "Action": "execute-api:Invoke",
      "Effect": "Deny",
      "Resource": [
        "arn:aws:execute-api:ap-northeast-1:123456789012:whtqmzxmr9/ESTestInvoke-stage/GET/fuga/*"
      ]
    }
  ]
}

続いて API Gateway (REST) で、hoge と fuga のリソースとメソッドを作成し、メソッドリクエストの認可設定で作成したオーソライザーを選択します。

では、cURL でリクエストを送信してみます。

# hoge 1 回目
% curl -H "Authorization: aaaaa" https://whtqmzxmr9.execute-api.ap-northeast-1.amazonaws.com/piyo/hoge/
hogeresult

# fuga 1 回目
% curl -H "Authorization: aaaaa" https://whtqmzxmr9.execute-api.ap-northeast-1.amazonaws.com/piyo/fuga/
{"Message":"User is not authorized to access this resource with an explicit deny"}

# hoge 2 回目
% curl -H "Authorization: aaaaa" https://whtqmzxmr9.execute-api.ap-northeast-1.amazonaws.com/piyo/hoge/
hogeresult

# fuga 2 回目
% curl -H "Authorization: aaaaa" https://whtqmzxmr9.execute-api.ap-northeast-1.amazonaws.com/piyo/fuga/
{"Message":"User is not authorized to access this resource with an explicit deny"}

良いですね。
hoge にはアクセスが出来ますが、fuga はアクセス拒否されましたね。

オーソライザーキャッシュを使ってみる

続いてキャッシュを有効化した場合の挙動を確認してみます。

今回は検証のためデフォルトの 300 秒で良いです。最大で 3600 秒まで設定することが出来ます。

デフォルトの [TTL] 値は 300 秒です。最大値は 3600 秒で、この制限値を増やすことはできません。

キャッシュが使われているのかどうかわかるようにしたいので、Lambda オーソライザーでタイムスタンプをコンテキストとして出力し、レスポンスボディで出力してみます。

lambda_function.py

import re
import time

def lambda_handler(event, context):
    print("Client token: " + event['authorizationToken'])
    print("Method ARN: " + event['methodArn'])

    principalId = 'user|a1b2c3d4'

    tmp = event['methodArn'].split(':')
    apiGatewayArnTmp = tmp[5].split('/')
    awsAccountId = tmp[4]

    policy = AuthPolicy(principalId, awsAccountId)
    policy.restApiId = apiGatewayArnTmp[0]
    policy.region = tmp[3]
    policy.stage = apiGatewayArnTmp[1]
    # policy.denyAllMethods()
    policy.allowMethod(HttpVerb.GET, '/hoge/*')
    policy.allowMethod(HttpVerb.GET, '/fuga/*')

    authResponse = policy.build()
    context = {
        'time': time.time()
    }
    authResponse['context'] = context
    
    return authResponse

このコードで生成されるポリシーは次のような形となります。
hoge も fuga も許可する形ですが、オーソライザーが設定するコンテキスト値としてタイムスタンプを設定しています。
オーソライザーキャッシュが使われる(= Lambda が実行されない)場合はタイムスタンプが変化しないだろうということを期待しています。

{
    "principalId": "user|a1b2c3d4",
    "policyDocument": {
        "Version": "2012-10-17",
        "Statement": [
            {
                "Action": "execute-api:Invoke",
                "Effect": "Allow",
                "Resource": [
                    "arn:aws:execute-api:ap-northeast-1:123456789012:whtqmzxmr9/ESTestInvoke-stage/GET/hoge/*",
                    "arn:aws:execute-api:ap-northeast-1:123456789012:whtqmzxmr9/ESTestInvoke-stage/GET/fuga/*"
                ]
            }
        ]
    },
    "context": {
        "time": 1682459476.7381952
    }
}

また、オーソライザーで生成されたコンテキストをレスポンスに設定することでキャッシュが使われてるかを確認したいと思います。
以下のようにマッピングテンプレートで context 変数を使用するだけです。

まずはキャッシュなしの場合です。
期待どおり毎回タイムスタンプが変わっていることが確認出来ます。

% curl -H "Authorization: aaaaa" https://whtqmzxmr9.execute-api.ap-northeast-1.amazonaws.com/piyo/hoge/
hogeresult: 1682459560.5272636
% curl -H "Authorization: aaaaa" https://whtqmzxmr9.execute-api.ap-northeast-1.amazonaws.com/piyo/hoge/
hogeresult: 1682459563.1927652
% curl -H "Authorization: aaaaa" https://whtqmzxmr9.execute-api.ap-northeast-1.amazonaws.com/piyo/fuga/
fugaresult: 1682459566.5653803
% curl -H "Authorization: aaaaa" https://whtqmzxmr9.execute-api.ap-northeast-1.amazonaws.com/piyo/fuga/
fugaresult: 1682459568.8503056

続いてキャッシュありの場合です。
同じタイムスタンプが使われていることがわかるでしょうか。
キャッシュキーは Authorization ヘッダーのみなので、リソースパスが変わったとしても同じキャッシュが使用されていることも確認出来ました。

% curl -H "Authorization: aaaaa" https://whtqmzxmr9.execute-api.ap-northeast-1.amazonaws.com/piyo/hoge/
hogeresult: 1682459630.2804842
% curl -H "Authorization: aaaaa" https://whtqmzxmr9.execute-api.ap-northeast-1.amazonaws.com/piyo/hoge/
hogeresult: 1682459630.2804842
% curl -H "Authorization: aaaaa" https://whtqmzxmr9.execute-api.ap-northeast-1.amazonaws.com/piyo/fuga/
fugaresult: 1682459630.2804842
% curl -H "Authorization: aaaaa" https://whtqmzxmr9.execute-api.ap-northeast-1.amazonaws.com/piyo/fuga/
fugaresult: 1682459630.2804842

キャッシュされるとまずそうなパターンを試してみる

リソースパスに関わらずトークンをキーにキャッシュが使用されることが確認出来ました。
そうなると、次のようにリクエストごとにオーソライザーでリクエストされたパスが許可されているかどうかを判断する場合は都合が悪くなります。

lambda_function.py

import re
import time

def lambda_handler(event, context):
    print("Client token: " + event['authorizationToken'])
    print("Method ARN: " + event['methodArn'])

    principalId = 'user|a1b2c3d4'

    tmp = event['methodArn'].split(':')
    apiGatewayArnTmp = tmp[5].split('/')
    awsAccountId = tmp[4]

    policy = AuthPolicy(principalId, awsAccountId)
    policy.restApiId = apiGatewayArnTmp[0]
    policy.region = tmp[3]
    policy.stage = apiGatewayArnTmp[1]
    
    if apiGatewayArnTmp[3] == 'hoge':
        policy.allowAllMethods()
    else:
        policy.denyAllMethods()

    authResponse = policy.build()
    context = {
        'time': time.time()
    }
    authResponse['context'] = context
    
    return authResponse

上記の実装の場合でも、オーソライザーキャッシュが無効化されている場合は動作します。
hoge が許可され、それ以外は拒否されていますね。

% curl -H "Authorization: aaaaa" https://whtqmzxmr9.execute-api.ap-northeast-1.amazonaws.com/piyo/hoge/
hogeresult: 1682460272.4720817

% curl -H "Authorization: aaaaa" https://whtqmzxmr9.execute-api.ap-northeast-1.amazonaws.com/piyo/fuga/
{"Message":"User is not authorized to access this resource with an explicit deny"}

% curl -H "Authorization: aaaaa" https://whtqmzxmr9.execute-api.ap-northeast-1.amazonaws.com/piyo/hoge/
hogeresult: 1682460280.113771

% curl -H "Authorization: aaaaa" https://whtqmzxmr9.execute-api.ap-northeast-1.amazonaws.com/piyo/fuga/
{"Message":"User is not authorized to access this resource with an explicit deny"}

ただし、キャッシュありの場合だと、最初にキャッシュされたポリシーが使い回されるので、hoge に最初にアクセスするとそれ以外でのリソースでも許可されたポリシーが使用されてしまいます。

# 先に許可リソースでキャッシュされた場合
% curl -H "Authorization: aaaaa" https://whtqmzxmr9.execute-api.ap-northeast-1.amazonaws.com/piyo/hoge/
hogeresult: 1682460368.7841194

% curl -H "Authorization: aaaaa" https://whtqmzxmr9.execute-api.ap-northeast-1.amazonaws.com/piyo/fuga/
fugaresult: 1682460368.7841194

# 先に拒否リソースでキャッシュされた場合
% curl -H "Authorization: bbbbb" https://whtqmzxmr9.execute-api.ap-northeast-1.amazonaws.com/piyo/fuga/
{"Message":"User is not authorized to access this resource with an explicit deny"}

% curl -H "Authorization: bbbbb" https://whtqmzxmr9.execute-api.ap-northeast-1.amazonaws.com/piyo/hoge/
{"Message":"User is not authorized to access this resource with an explicit deny"}

さいごに

本日は API Gateway の Lambda オーソライザーはキャッシュを考慮した作りにすべきか検証してみました。

結論としてはキャッシュの使用も視野に入れるのであればオーソライザーのトークンソースにのみ依存したポリシー生成とするべきだということがわかりました。
呼び出しパスに対して必要な最低限のポリシーのみの場合だと、キャッシュされた際に期待しない動作となってしまいます。
パフォーマンス改善などのために後からキャッシュだけ有効化することもあるので、トークンソースにのみ依存したポリシーを生成するように意識しておくと良さそうです。