一時クレデンシャルとSigV4署名付きリクエストを用いて、関数URLを有効化したLambda関数をセキュアに実行する方法、および必要なリソースをIaCで構築する手法

おいおいタイトル長すぎだろ……常識的に考えて。

今回はだいぶ実用性重視の作例を紹介しようと思って、言いたいことを全部タイトルに盛り込もうとしたらちょっと長くなっちゃった。

AI にタイトルつけてもらえばよかったのに。

それな。

前略(ナイスな導入)

だいぶ前の話ですが、AWS Lambda 単体で Web API が作れてしまう関数URLという機能が登場しました。

この機能を有効化すると、下図に示すように HTTP(S) リクエストを契機に直接 Lambda 関数を実行できるようになります(API Gateway が不要になります)。

リクエストが API Gateway を通らないため、「レスポンスタイムが29秒を超えてもタイムアウトにならない」という大きなメリットを享受できます。

AI による推論のように、時間のかかる処理を API 化したい場合には有効と言えるでしょう。

一方で、関数URL を有効化した Lambda 関数は、API Gateway のような高度なオーソライザを具備しているわけではありません。 せいぜい、IAMロールによる認可を設定できるくらいです。

そのため、「API を保護する手段が少ない」という切実なデメリットとも向き合う必要があります。

どういうこと?

まぁ要するに、「ログイン中のユーザだけがアクセスできるようにしたい」みたいな要件を素直に満たせないんだよ。

なるほどそれは不便だね。

ただ、このあと示す手法を使えば、この課題をテクニカルに解決できるのだ。

今回作るモノ

まず結論から言うと、下図のように「❶ 一時権限を払い出すAPI」と「❷ 関数URLを有効化したLambda」の2階建てを作ります。

そして、❷の Lambda 関数は、❶の API で払い出した一時権限がない限りアクセスできないよう、IAM ロールによる認可制にします。

❶ は、前段に API Gateway があるため、オーソライザや API キーなどを用いて API を保護できます。

この方法を使えば、29秒を超える API をセキュアに呼び出す仕組みを実現できるでしょう。


理屈は解ったけど、作るべきリソースと依存関係がややこしいね。

そうだね。

手動でポチポチ作ると、どこかで手順を間違えたときに悲惨なことになるので、IaC を使って構築することにしよう。

AWS SAMだね。

うん。

これでタイトルの伏線は全回収だ。

はじめの一歩

まずは最も簡単な、「関数 URL を有効化した Lambda」を AWS SAM で作ります。 下図の赤枠相当のリソースになります。

template.yaml

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  Sample SAM Template

Globals:
  Function:
    Timeout: 3

Resources:
  HelloWorldFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: hello_world/
      Handler: app.lambda_handler
      Runtime: python3.12
      Architectures:
        - x86_64
      FunctionUrlConfig: # ★追加 ── 関数URLを有効化
        AuthType: NONE   # ★追加 ── 認証なし

Outputs:
  HelloWorldFunctionUrl:
    Value: !GetAtt HelloWorldFunctionUrl.FunctionUrl # ★追加 ── Lambdaの関数URL

次に、Lambda 関数本体のコードを示します。

こちらは、単に 200 応答と Hello World メッセージを返すだけのごく簡単な関数です。

AWS SAM CLI で生成される一番簡単な Lambda と同じものですので、特に説明は不要でしょう。

hello_world/app.py

import json

def lambda_handler(event, context):
    return {
        "statusCode": 200,
        "body": json.dumps({
            "message": "hello world",
        }),
    }
CORS 対応

続いて、今作った Lambda を、CORS 対応させます。 template.yaml を以下のように修正しましょう(app.py の修正は不要です)。

template.yaml(第2版)

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  Sample SAM Template

Globals:
  Function:
    Timeout: 3

Resources:
  HelloWorldFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: hello_world/
      Handler: app.lambda_handler
      Runtime: python3.12
      Architectures:
        - x86_64
      FunctionUrlConfig:
        AuthType: NONE
        Cors:                     # ★追加 ── CORS対応
          AllowOrigins:           # ★追加 ──    〃
            - "*"                 # ★追加 ──    〃
          AllowCredentials: true  # ★追加 ──    〃
          AllowMethods:           # ★追加 ──    〃
            - "*"                 # ★追加 ──    〃
          AllowHeaders:           # ★追加 ──    〃
            - "*"                 # ★追加 ──    〃

Outputs:
  HelloWorldFunctionUrl:
    Value: !GetAtt HelloWorldFunctionUrl.FunctionUrl

これで、オリジンの異なるクライアントから API 呼び出しができるようになりました。

Lambdaの関数URLにIAMロール認可を設定する

このままでは、関数URLを知ってさえいれば、世界中の誰でもその Lambda を呼び出せてしまいます。

そのため、まずは実行権限を持たないクライアントから Lambda が呼び出せないよう、IAM ロール認可を設定しましょう。

また、Lambda の呼び出しに必要となる IAMロールも併せて作成します。 このロールは、どこかの誰かに対して恒久的に権限を与える使い方ではなく、一時的に権限を貸し出す使い方(AssumeRole)をします。

ただし、誰でも AssumeRole ができてしまうと、(結局、誰もがアクセス権を得ることになってしまい)事実上、認証の意味が無くなってしまいますので、権限の貸し出し先を絞るために信頼関係ポリシーを設定します。

下図の赤枠部に相当する箇所となります。

template.yaml(第3版)

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  Sample SAM Template

Globals:
  Function:
    Timeout: 3

Resources:
  HelloWorldFunction: # ─────────────────────────────────┐
    Type: AWS::Serverless::Function                  #   │
    Properties:                                      #   │
      CodeUri: hello_world/                          #   │
      Handler: app.lambda_handler                    #   │
      Runtime: python3.12                            #   │
      Architectures:                                 #   │
        - x86_64                                     #   │
      FunctionUrlConfig:                             #   │
        AuthType: AWS_IAM  # ★修正 ── IAM認可を有効化       │
        Cors:                                        #   │
          AllowOrigins:                              #   │
            - "*"                                    #   │
          AllowCredentials: true                     #   │
          AllowMethods:                              #   │
            - "*"                                    #   │
          AllowHeaders:                              #   │
            - "*"                                    #   │
#                                                        │
# ★ 追加ここから ★                                         │
#                                                        │
# ① Lambdaを関数URLで実行するIAMポリシー                    │
  LambdaFunctionInvokePolicy:                        #   │
    Type: AWS::IAM::Policy                           #   │
    Properties:                                      #   │
      PolicyName: hello-world-invoke-policy          #   │
      PolicyDocument:                                #   │
        Version: '2012-10-17'                        #   │
        Statement:                                   #   │
          - Effect: Allow                            #   │
            Action: lambda:InvokeFunctionUrl         #   │
            Resource: !GetAtt HelloWorldFunction.Arn # ←─┘
      Roles:
        - !Ref LambdaFunctionInvokeRole # ─┐
#                                          │
# ② 上記①のポリシーをアタッチしたIAMロール     │
#    信頼関係ポリシーを設定しているため、        |
#    一時的にこのロールを引き渡すことができる     │
  LambdaFunctionInvokeRole: # ←────────────┘
    Type: AWS::IAM::Role
    Properties:
      AssumeRolePolicyDocument:
        Version: '2012-10-17'
        Statement:
          - Effect: Allow
            Principal:
              AWS: !Sub "arn:aws:iam::${AWS::AccountId}:root"
            Action: sts:AssumeRole
#
# ★ 追加ここまで ★
#

Outputs:
  HelloWorldFunctionUrl:
    Value: !GetAtt HelloWorldFunctionUrl.FunctionUrl

一時権限を払い出す Lambda 関数を追加する

いよいよバックエンドの実装も大詰めです。 最後に、一時権限を払い出す Lambda を作っていきましょう。

まずは template.yaml に関数の定義と実行契機を記述していきます。 また、CORS 対応を行うための記述も必要となります。

template.yaml(第4版)

AWSTemplateFormatVersion: '2010-09-09'
Transform: AWS::Serverless-2016-10-31
Description: >
  Sample SAM Template

Globals:
  Function:
    Timeout: 3

  Api:                          # ★追加 ── API GatewayのCORS設定
    Cors:                       # ★追加 ──   〃
      AllowOrigin: "'*'"        # ★追加 ──   〃
      AllowCredentials: "true"  # ★追加 ──   〃
      AllowMethods: "'POST'"    # ★追加 ──   〃
      AllowHeaders: "'*'"       # ★追加 ──   〃

Resources:
  HelloWorldFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: hello_world/
      Handler: app.lambda_handler
      Runtime: python3.12
      Architectures:
        - x86_64
      FunctionUrlConfig:
        AuthType: AWS_IAM
        Cors:
          AllowOrigins: 
            - "*"
          AllowCredentials: true
          AllowMethods: 
            - "*"
          AllowHeaders: 
            - "*"

#
# ★ 追加ここから ★
#
  TemporaryPermissionGrantFunction:
    Type: AWS::Serverless::Function
    Properties:
      CodeUri: temporary_permission_grant/
      Handler: app.lambda_handler
      Runtime: python3.12
      Architectures:
        - x86_64
      Policies:
        - Statement:
          - Effect: Allow
            Action:
              - sts:AssumeRole
            Resource: !GetAtt LambdaFunctionInvokeRole.Arn # ←───┐
      Environment:                                         #     │
        Variables:                                         #     │
          REGION: !Ref AWS::Region                         #     │
          FUNC_URL: !GetAtt HelloWorldFunctionUrl.FunctionUrl # ←──┐
          ROLE_ARN: !GetAtt LambdaFunctionInvokeRole.Arn   # ←──┐│ │
      Events:                                              #    ││ │
        ApiGateway:                                        #    ││ │
          Type: Api                                        #    ││ │
          Properties:                                      #    ││ │
            Path: /grant                                   #    ││ │
            Method: post                                   #    ││ │
#                                                               ││ │
# ★ 追加ここまで ★                                                ││ │  
#                                                               ││ │
  LambdaFunctionInvokePolicy:                             #     ││ │
    Type: AWS::IAM::Policy                                #     ││ │
    Properties:                                           #     ││ │
      PolicyName: hello-world-invoke-policy               #     ││ │
      PolicyDocument:                                     #     ││ │
        Version: '2012-10-17'                             #     ││ │
        Statement:                                        #     ││ │
          - Effect: Allow                                 #     ││ │
            Action: lambda:InvokeFunctionUrl              #     ││ │
            Resource: !GetAtt HelloWorldFunction.Arn      #     ││ │
      Roles:                                              #     ││ │
        - !Ref LambdaFunctionInvokeRole                   #     ││ │
#                                                               ││ │
  LambdaFunctionInvokeRole: # ──────────────────────────────────┘┘ │
    Type: AWS::IAM::Role                                  #        │
    Properties:                                           #        │
      AssumeRolePolicyDocument:                           #        │
        Version: '2012-10-17'                             #        │
        Statement:                                        #        │
          - Effect: Allow                                 #        │
            Principal:                                    #        │
              AWS: !Sub "arn:aws:iam::${AWS::AccountId}:root" #    │
            Action: sts:AssumeRole                        #        │
#                                                                  │
Outputs:                                                  #        │
  HelloWorldFunctionUrl:                                  #        │
    Value: !GetAtt HelloWorldFunctionUrl.FunctionUrl # ────────────┘
  
  GrantFunctionApiUrl:
    Value: !Sub "https://${ServerlessRestApi}.execute-api.${AWS::Region}.amazonaws.com/Prod/grant/"

続いて、関数本体の実装です。

関数 URL から Lambda を実行する権限を持った IAM ロールを AssumeRole して、クレデンシャルと呼ばれる情報を呼び出し元に返却します。

ついでに、次に呼ぶべき Lambda 関数の URL 情報と、後述する SigV4 の計算に利用するリージョンも併せて呼び出し元に返すことにします。

temporary_permission_grant/app.py

import json
import boto3
import os

def lambda_handler(event, context):
    sts_client = boto3.client('sts')
    
    # 環境変数からIAMロールのArn、関数URL、リージョンを取得
    role_arn = os.environ['ROLE_ARN']
    func_url = os.environ['FUNC_URL']
    region = os.environ['REGION']

    # HelloWorldFunctionを実行するための一時的な権限を取得
    assumed_role = sts_client.assume_role(
        RoleArn=role_arn,
        RoleSessionName='InvokeHelloWorldFunction'
    )
    credentials = assumed_role['Credentials']

    return {
        'statusCode': 200,
        'headers': {
          "Access-Control-Allow-Origin": "*",
          "Access-Control-Allow-Credentials": "true",
          "Access-Control-Allow-Methods": "POST",
          "Access-Control-Allow-Headers": "*",
        },
        'body': json.dumps({
            'FuncUrl': func_url,
            'Region': region,
            'AccessKeyId': credentials['AccessKeyId'],
            'SecretAccessKey': credentials['SecretAccessKey'],
            'SessionToken': credentials['SessionToken'],
            'Expiration': credentials['Expiration'].isoformat()
        })
    }

以上でバックエンドの実装は完了です。 お疲れ様でした。

クラウアントサイドの実装

最後の仕上げに、API を呼び出す側(クライアントサイド)を実装していきます。

初めに、一時権限を払い出す API にリクエストを送り、Lambda を実行するための一時権限を取得します。

続いて、この取得した情報を用いて SigV4と呼ばれる電子署名を作成し、Lambda 関数を呼び出します。

リクエストを2回行うことになるためラウンドトリップは増えますが、セキュアな形で API アクセスができます。

SigV4 によって、「権限を持った人からのリクエストであること」「リクエストが改竄されていないこと」が保証されるようになるからです。

ただし、SigV4 の計算は非常に繁雑ですので、AWS-SDK などのライブラリを利用することが AWS 公式によって強く推奨されています。

以下のサンプルは、ボタンを押すと「一時権限の払い出し → それを用いて Lambda 関数 URL にリクエストを送り、レスポンスの内容をアラートに表示する」という一連の動きを確認できる HTML です。

コード中の変数 apiUrl には、sam deployした際に表示される API Gateway のエンドポイント URL を指定しましょう。

index.html

<!DOCTYPE html>
<html>

<head>
  <title>Function URL Invoker</title>
  <script src="https://cdn.jsdelivr.net/npm/aws-sdk@latest/dist/aws-sdk.min.js"></script>
</head>

<body>
  
  <button id="button">button</button>

  <script>
    document.getElementById('button').addEventListener('click', async() => {

      // 一時権限払い出しAPIのエンドポイントURL
      const apiUrl = 'https://██████████.execute-api.ap-northeast-1.amazonaws.com/Prod/grant/';

      // AWSから一時的な権限を取得する
      const getCredentials = async() => {
        const response = await fetch(apiUrl, { method: 'POST' });
        return response.json(); // JSON形式のレスポンスを返す
      }

      // Lambda Function URLを呼び出す
      const invokeFunctionUrl = async(credentials) => {

        // AWSクレデンシャルとリージョンの設定
        const AWS = window.AWS;
        AWS.config.credentials = {
          accessKeyId: credentials.AccessKeyId,
          secretAccessKey: credentials.SecretAccessKey,
          sessionToken: credentials.SessionToken,
        };
        AWS.config.region = credentials.Region;

        // リクエストの設定
        const endpoint = new AWS.Endpoint(credentials.FuncUrl);
        const request = new AWS.HttpRequest(endpoint, AWS.config.region);
        request.method = 'POST';
        request.headers['Host'] = endpoint.host;
        request.headers['Content-Type'] = 'application/json';

        // 送信するデータ
        request.body = JSON.stringify({ /* 必要に応じて Body を設定してください */ });

        // リクエストにSigV4署名を付与
        const signer = new AWS.Signers.V4(request, 'lambda');
        signer.addAuthorization(AWS.config.credentials, AWS.util.date.getDate());

        // Lambda Function URLへリクエストを送信
        const response = await fetch(endpoint.href, {
          method: request.method,
          headers: request.headers,
          body: request.body
        });

        return response.json(); // JSON形式のレスポンスを返す
      }

      // 主な処理フロー
      try {
        const credentials = await getCredentials();
        console.log('取得したクレデンシャル:', credentials);

        const result = await invokeFunctionUrl(credentials);
        console.log('Function URLのレスポンス:', result);

        // アラートに表示
        window.alert(JSON.stringify(result));
      } catch (error) {
        console.error('エラー:', error);
      }
    });
  </script>
</body>

</html>

まとめ

前回、Lambda と Bedrock を組み合わせる際、どうしても API Gateway のサービスクォータ(29秒制限)がネックになることがあります。

今回ご紹介した手法は、「時間のかかる処理は、関数URLを有効化した Lambda」「その Lambda を実行するための一時権限を払い出すAPI」を巧妙に組み合わせた例となります。

最もベーシックなサンプルですので、必要に応じてカスタマイズして活用いただければ幸いです。

Copyright (c) 2012 @tercel_s, @iTercel, @pi_cro_s.