NRIネットコム社員が様々な視点で、日々の気づきやナレッジを発信するメディアです

注目のタグ

    AWS CDKで別リージョンに基本認証用Lambda@Edgeを作成するスタックをデプロイしてAmazon CloudFrontに設定する

    小西秀和です。
    前回の記事、「AWS CDKで別リージョンにレプリケーション用S3バケットを作成するスタックをデプロイしてAmazon CloudFrontオリジンフェイルオーバーを設定する」では次の記事で紹介したリージョン間でパラメータを送受信する方法を使ってACM証明書をCloudFrontに設定する方法を紹介しました。

    今回はその記事の続編として、前回記事で作成したAWS CDKカスタムリソースを使用して、別リージョンに基本認証用Lambda@Edgeを作成し、Amazon CloudFrontに設定する方法を紹介します。
    パラメータをクロスリージョンで扱うAWS CDKカスタムリソースについては元記事を参照してください。

    ※本記事および当執筆者のその他の記事で掲載されているソースコードは自主研究活動の一貫として作成したものであり、動作を保証するものではありません。使用する場合は自己責任でお願い致します。また、予告なく修正することもありますのでご了承ください。

    本記事で試す内容の概要図

    今回は次の内容をAWS Cloud Development Kit(AWS CDK)とAWS CDKカスタムリソースを中心に試しています。

    • us-east-1リージョンで基本認証用Lambda@Edgeを作成するスタックを実行し、Lambda@EdgeのARNをap-northeast-1リージョンのAWS Systems Manager(SSM)パラメータストアに保存する
    • ap-northeast-1リージョンでAmazon CloudFront+Amazon S3のホスティングを構成するスタックを実行し、SSMパラメータストアからLambda@EdgeのARNを取得して、Amazon CloudFrontに設定する

    クロスリージョンパラメータによる別リージョンLambda@Edgeの関連付けの例
    クロスリージョンパラメータによる別リージョンLambda@Edgeの関連付けの例

    実はAWS CDKにはcloudfront.experimental.EdgeFunctionというクラスを使用すれば、us-east-1以外のスタックからでもus-east-1にLambda@Edgeを作成してCloudFrontに関連付けることが可能です。
    ただ、今回はLambda@Edgeを作成する機能を別スタックにしてCloudFrontへの適用有無を選択できるように、またリージョン間パラメータ送受信を試すために従来のやり方と同じようにLambda@Edge作成スタックをus-east-1で作成するようにしています。
    その他、従来のようにus-east-1でLambda@Edgeを作成した方が適しているケースはLambda@Edgeに関連する他のリソースも同時にus-east-1リージョンへ作成するような場合が考えられます。

    AWS CDKプロジェクトの階層構造とファイル概要

    AWS CDKのプロジェクトの構造は次のようになります。
    主に実装した部分にはコメントを追記しています。

    [ho2k_com@ho2k-com s3cf-edge]# tree
    ~不要部分は省略しています~
    ├── README.md
    ├── app.py # 各スタックのリージョンや依存関係などを指定する
    ├── cdk.json # Contextに静的パラメータを定義する
    ├── cdk.out
    ├── lambda
    │   └── index.py # Lambda@EdgeとしてデプロイするLambda関数
    ├── requirements.txt
    ├── s3cf_edge
    │   ├── __init__.py
    │   ├── lambda_edge_stack.py # us-east-1リージョンでLambda@Edgeを作成するスタック
    │   ├── s3cloudfront_stack.py # ap-northeast-1リージョンでCloudFront+S3ホスティングを構成するスタック
    │   └── x_region_param.py # クロスリージョンのパラーメータ受け渡しをするConstruct継承クラス
    ├── setup.py
    └── source.bat
    

    各スタック共通設定

    各スタックのリージョンや依存関係などを指定するapp.pyは次のようになります。
    add_dependencyでACM証明書発行スタックを実行してから、CloudFront+S3ホスティングのスタックを実行する依存関係を指定しています。

    ■app.py

    #!/usr/bin/env python3
    import os
    from aws_cdk import core as cdk
    from aws_cdk import core
    
    from s3cf_edge.lambda_edge_stack import LambdaEdgeStack
    from s3cf_edge.s3cloudfront_stack import S3CloudfrontStack
    
    app = core.App()
    lambda_edge_stk = LambdaEdgeStack(app, "CdkS3CfEdgeLambdaEdgeStack", env=core.Environment(region='us-east-1'))
    s3cloudfront_stk = S3CloudfrontStack(app, "CdkS3CfEdgeS3CloudfrontStack", env=core.Environment(region='ap-northeast-1'))
    
    s3cloudfront_stk.add_dependency(lambda_edge_stk)
    
    app.synth()
    

    cdk.jsonではContextに次の静的パラメータを追記してスタック等で呼び出して使用します。

    "PREFIX": "<リージョン間で受け渡すパラメータ名の先頭にプロジェクト固有のプレフィックスを付けるために使用>", 
    "ACTIVATE_BASIC_AUTH": <初回デプロイ以降に基本認証用Lambda@Edgeを有効化するフラグ。true:有効化、false:無効化>, 
    "BASIC_AUTH_FUNC_NAME": "<基本認証用Lambda@Edgeの関数名>", 
    "BASIC_AUTH_ID": "<基本認証ID>", 
    "BASIC_AUTH_PW": "<基本認証パスワード>"

    ■cdk.json

    {
      "app": "python3 app.py",
      "context": {
        "@aws-cdk/aws-apigateway:usagePlanKeyOrderInsensitiveId": true,
        "@aws-cdk/core:enableStackNameDuplicates": "true",
        "aws-cdk:enableDiffNoFail": "true",
        "@aws-cdk/core:stackRelativeExports": "true",
        "@aws-cdk/aws-ecr-assets:dockerIgnoreSupport": true,
        "@aws-cdk/aws-secretsmanager:parseOwnedSecretName": true,
        "@aws-cdk/aws-kms:defaultKeyPolicies": true,
        "@aws-cdk/aws-s3:grantWriteWithoutAcl": true,
        "@aws-cdk/aws-ecs-patterns:removeDefaultDesiredCount": true,
        "@aws-cdk/aws-rds:lowercaseDbIdentifier": true,
        "@aws-cdk/aws-efs:defaultEncryptionAtRest": true,
        "@aws-cdk/aws-lambda:recognizeVersionProps": true,
        "@aws-cdk/aws-cloudfront:defaultSecurityPolicyTLSv1.2_2021": true, 
          
        "PREFIX": "s3cf_edge_", 
        "ACTIVATE_BASIC_AUTH": false, 
        "BASIC_AUTH_FUNC_NAME": "CdkS3CfEdgeLambdaEdgeOfBasicAuth", 
        "BASIC_AUTH_ID": "Iam", 
        "BASIC_AUTH_PW": "Nobody"
      }
    }
    
    

    基本認証用Lambda@Edgeを作成するスタック

    us-east-1リージョンに基本認証用Lambda@Edgeを作成するスタックの内容です。
    AWS CDKにはcloudfront.experimental.EdgeFunctionというus-east-1以外のスタックでもus-east-1にLambda@Edgeを作成してCloudFrontに関連付けれるクラスが存在します。
    ただ、今回はリージョン間パラメータ送受信を試すために従来のやり方と同様に別スタックとして機能を独立させてus-east-1リージョンにLambda@Edge用のLambda関数を作成しています。
    今回、実装しているLambda@Edgeでは基本認証用のID・パスワードを格納するAWS Secrets Managerシークレットの名称とLambda@EdgeのIAMロールのIAMポリシーにCloudFrontディストリビューションIDを使用して一意に識別しています。
    そのため、CloudFrontが作成されていない初回実行時はContextのACTIVATE_BASIC_AUTHの値をfalseにして、このスタックの処理をスキップするようにしています。
    2回目以降の更新実行時にACTIVATE_BASIC_AUTHの値をtrueにすることで、CloudFrontディストリビューションIDを使用してシークレットおよびIAMロールを作成し、Lambda@EdgeのARNをap-northeast-1リージョンのSSMパラメータとして保存します。
    処理のポイントとなる箇所についてコメントを追記しておきます。

    ■lambda_edge_stack.py

    from aws_cdk import core as cdk
    from x_region_param import XRegionParam
    from aws_cdk.aws_cloudfront import experimental
    from aws_cdk import (
        core,
        aws_iam as iam,
        aws_lambda as lmd
    )
    
    class LambdaEdgeStack(core.Stack):
    
        def __init__(self, scope: core.Construct, id: str, **kwargs) -> None:
            super().__init__(scope, id, **kwargs)
            stack = core.Stack.of(self);
            #スタックの環境設定からAWSアカウントIDを取得
            ACCOUNT_ID = stack.account
            #cdk.jsonのContextから静的パラメータを取得
            PREFIX = self.node.try_get_context("PREFIX")
            ACTIVATE_BASIC_AUTH = self.node.try_get_context("ACTIVATE_BASIC_AUTH")
            BASIC_AUTH_FUNC_NAME = self.node.try_get_context("BASIC_AUTH_FUNC_NAME")
            BASIC_AUTH_ID = self.node.try_get_context("BASIC_AUTH_ID")
            BASIC_AUTH_PW = self.node.try_get_context("BASIC_AUTH_PW")
    
            #ACTIVATE_BASIC_AUTHがtrueだとLambda@Edge作成処理を実行、falseだと処理をスキップ
            if ACTIVATE_BASIC_AUTH:
                #リージョン間値送受信クラスでus-east-1リージョンにあるSSMパラメータ(CloudFront Distribution ID)を取得する
                resut_get_param = XRegionParam(self, 'GetCloudFrontDistId', 
                    region='us-east-1', 
                    service='SSM', 
                    action='GET', 
                    key=PREFIX + 'cloud-front-dist-id', 
                    val='', 
                    description=''
                )
                cloud_front_dist_id = resut_get_param.get_result()['result']
                
                #CloudFront Distribution IDと基本認証IDでAWS Secrets Managerシークレット名を作成する
                asm_secret_prefix = "CloudFrontBasicAuth/" + cloud_front_dist_id + "/"
                asm_secret_name = asm_secret_prefix + BASIC_AUTH_ID
                
                #experimental.EdgeFunctionを使用するとus-east-1以外のスタックでもus-east-1にLambda@Edgeを作成できるが、
                #別スタックとして機能を独立させるため、リージョン間パラメータ送受信でLambda@Edge用のLambda関数を作成する。
                #Lambda@Edgeはメモリが最大128MB、タイムアウトが最大5秒までとなっている
                edge_func = experimental.EdgeFunction(self, 'BasicAuthLambdaEdgeFunction',
                    runtime=lmd.Runtime.PYTHON_3_8,
                    memory_size=128,
                    timeout=core.Duration.seconds(5),
                    code=lmd.Code.asset('lambda'),
                    function_name=BASIC_AUTH_FUNC_NAME,
                    handler='index.lambda_handler'
                )
    
                #Lambda@EdgeのIAMロールにIAMポリシーを追加していく
                edge_func.add_to_role_policy(iam.PolicyStatement(
                    effect=iam.Effect.ALLOW,
                    actions=['iam:CreateServiceLinkedRole'], 
                    resources=['*']
                ))
                edge_func.add_to_role_policy(iam.PolicyStatement(
                    effect=iam.Effect.ALLOW,
                    actions=['lambda:GetFunction', 'lambda:EnableReplication'], 
                    resources=['arn:aws:lambda:us-east-1:' + ACCOUNT_ID + ':function:' + BASIC_AUTH_FUNC_NAME]
                ))
                edge_func.add_to_role_policy(iam.PolicyStatement(
                    effect=iam.Effect.ALLOW,
                    actions=['cloudfront:UpdateDistribution'], 
                    resources=['arn:aws:cloudfront::' + ACCOUNT_ID + ':distribution/' + cloud_front_dist_id]
                ))
                edge_func.add_to_role_policy(iam.PolicyStatement(
                    effect=iam.Effect.ALLOW,
                    actions=['secretsmanager:GetSecretValue'], 
                    resources=['arn:aws:secretsmanager:us-east-1:' + ACCOUNT_ID + ':secret:' + asm_secret_prefix + '*']
                ))
                
                #基本認証用の認証情報をAWS Secrets Managerシークレットに保存する
                resut_put_secret = XRegionParam(self, 'CloudFrontBasicAuthSecret', 
                    region='us-east-1', 
                    service='ASM', 
                    action='PUT', 
                    key=asm_secret_name, 
                    val='{"Password":"' + BASIC_AUTH_PW + '"}', 
                    description='ASM Secret for Lambda@Edge of Basic Auth'
                )
    
                #リージョン間値送受信クラスでap-northeast-1リージョンにSSMパラメータ(Lambda@EdgeのARN)を保存する
                resut_put_param = XRegionParam(self, 'LambdaEdgeArnParameter', 
                    region='ap-northeast-1', 
                    service='SSM', 
                    action='PUT', 
                    key=PREFIX + 'lambda-edge-arn', 
                    val=edge_func.edge_arn, 
                    description=''
                )
                
    

    Lambda@Edgeに使用するLambda関数の内容

    前述のlambda_edge_stack.py内でcode=lmd.Code.asset('lambda')として記載しているAWS CDKプロジェクト内にある「lambda」ディレクトリ配下のファイルが次のindex.pyになります。
    処理のポイントとなる箇所についてコメントを追記しておきます。

    ※コードの内容はAWS CloudFromationで同様の構成を構築している次の記事で紹介したものと同じです。

    ■index.py

    # 基本認証を実施するLambda@Edgeの関数
    import json
    import boto3
    import base64
    # ID、パスワードを保管し、認証に使用するAWS Secrets Managerを扱うクライアントを作成
    asm_client = boto3.client('secretsmanager', region_name='us-east-1')
    # エラーの場合のレスポンスを定義
    err_response = {
        'status': '401',
        'statusDescription': 'Unauthorized',
        'body': 'Authentication Failed.',
        'headers': {
            'www-authenticate': [
                {
                  'key': 'WWW-Authenticate',
                  'value': 'Basic realm="Basic Authentication"'
                }
            ]
        }
    }
    
    def lambda_handler(event, context):
        try:
            print('event:')
            print(event)
            # イベントからCloudFrontのリクエストを取得
            request = event['Records'][0]['cf']['request']
            # イベントからCloudFrontのDistribution IDを取得
            cf_dist_id = event['Records'][0]['cf']['config']['distributionId']
            # リクエストからヘッダーを取得
            headers = request['headers']
            if (headers.get('authorization') != None):
                # ヘッダーにauthorizationがあれば認証を試行する
                # headers['authorization'][0]['value']の中身は「Basic <base64でエンコードされた[ID:Password]の文字列>」
                # authorizationの内容を分解してID、パスワードを取り出す
                target_credentials_str = headers['authorization'][0]['value'].split(
                    " ")
                target_credentials = base64.b64decode(
                    target_credentials_str[1]).decode().split(":")
                target_id = target_credentials[0]
                target_pw = target_credentials[1]
    
                # 取得したCloudFrontのDistribution IDおよび基本認証で入力されたIDでシークレットを取得する
                response = asm_client.get_secret_value(
                    SecretId='CloudFrontBasicAuth/' +
                    cf_dist_id + '/' + str(target_id)
                )
                # シークレットが取得でき、格納されている文字列が基本認証で入力されたパスワードと一致すれば認証成功としてリクエストを返却する。
                # それ以外の場合はエラーレスポンスを返す。
                if (response.get('SecretString') != None):
                    secret_string = json.loads(response['SecretString'])
                    if (secret_string.get('Password') == target_pw):
                        return request
                    else:
                        return err_response
                else:
                    return err_response
    
            else:
                return err_response
    
        except Exception as e:
            print("Exception:")
            print(e)
    
            return err_response
    

    Amazon CloudFront+Amazon S3を作成し、基本認証用Lambda@Edgeを設定するスタック

    ap-northeast-1にCloudFront+S3ホスティングを構成するスタックの内容です。
    前述のように今回、実装しているLambda@Edgeでは基本認証用のID・パスワードを格納するAWS Secrets Managerシークレットの名称とLambda@EdgeのIAMロールのIAMポリシーにCloudFrontディストリビューションIDを使用して一意に識別しています。
    そのため、CloudFrontが作成されていないとLambda@EdgeやSecrets Managerシークレットも作成できないため、初回実行時はContextのACTIVATE_BASIC_AUTHの値をfalseにして、このスタックの処理をスキップするようにしています。
    2回目以降の更新実行時にACTIVATE_BASIC_AUTHの値をtrueにすることで、CloudFrontディストリビューションIDを使用してLambda@EdgeとSecrets Managerシークレットが作成され、Lambda@EdgeのARNをap-northeast-1リージョンのSSMパラメータから取得してCloudFrontに設定します。
    その他、処理のポイントとなる箇所についてコメントを追記しておきます。

    ■s3cloudfront_stack.py

    from aws_cdk import core as cdk
    import time
    from x_region_param import XRegionParam
    from aws_cdk import (
        core,
        aws_s3 as s3,
        aws_iam as iam, 
        aws_cloudfront as cf,
        aws_lambda as lmd
    )
     
    class S3CloudfrontStack(core.Stack):
        
        def __init__(self, scope: core.Construct, id: str, **kwargs) -> None:
            super().__init__(scope, id, **kwargs)
            #cdk.jsonのContextから静的パラメータを取得
            PREFIX = self.node.try_get_context("PREFIX")
            HOSTED_ZONE_ID = self.node.try_get_context("HOSTED_ZONE_ID")
            ZONE_NAME = self.node.try_get_context("ZONE_NAME")
            DOMAIN_NAME = self.node.try_get_context("DOMAIN_NAME")
            RECORD_NAME = self.node.try_get_context("RECORD_NAME")
            ACTIVATE_BASIC_AUTH = self.node.try_get_context("ACTIVATE_BASIC_AUTH")
            
            #ホスティング用S3バケットを作成する
            bucket = s3.Bucket(self, 'BucketPrimary',
                access_control=s3.BucketAccessControl.PUBLIC_READ,
                website_index_document='index.html'
            )
            bucket.grant_public_access()
            
            #CloudFrontにS3バケットを設定するためのOAIを作成
            oai = cf.OriginAccessIdentity(self, 'CfOai');
    
            #S3へのアクセス権限をOAIに限定するようにS3バケットポリシーを設定
            bucket_policy = iam.PolicyStatement(
                effect=iam.Effect.ALLOW,
                actions=['s3:GetObject'],
                principals=[
                    iam.CanonicalUserPrincipal(
                        oai.cloud_front_origin_access_identity_s3_canonical_user_id
                    )
                ],
                resources=[bucket.bucket_arn + '/*']
            )        
            bucket.add_to_resource_policy(bucket_policy);
    
            #ACTIVATE_BASIC_AUTHがtrueだとLambda@Edgeを設定したBehaviorを作成
            #ACTIVATE_BASIC_AUTHがfalseだとデフォルトのBehaviorを作成
            if ACTIVATE_BASIC_AUTH:
                #リージョン間値送受信クラスでap-northeast-1リージョンにあるSSMパラメータ(Lambda@EdgeのARN)を取得する
                resut_get_param = XRegionParam(self, 'GetLambdaEdgeArn', 
                    region='ap-northeast-1', 
                    service='SSM', 
                    action='GET', 
                    key= PREFIX + 'lambda-edge-arn', 
                    val='', 
                    description=''
                )
                lmd_ver_arn = resut_get_param.get_result()['result']
                
                #Lambda@EdgeのARNでCloudFrontのビューワーリクエストに対してlambda@EdgeをBehaviorに設定する
                cf_behavior = cf.Behavior(
                    is_default_behavior=True,
                    lambda_function_associations=[
                    cf.LambdaFunctionAssociation(
                            event_type=cf.LambdaEdgeEventType.VIEWER_REQUEST,
                            lambda_function=lmd.Version.from_version_arn(self,"BasicAuthLambdaEdgeVersion4AmazonCloudFront",
                                version_arn=lmd_ver_arn
                            ),
                            include_body=True
                        )
                    ]
                )
            else:
                #デフォルトのBehaviorを作成
                cf_behavior = cf.Behavior(is_default_behavior=True)
    
            #Lambda@Edgeの有効化の有無を反映したBehaviorを含めてCloudFrontを作成する
            distribution = cf.CloudFrontWebDistribution(self, 'CloudFrontWebDistribution',
                origin_configs=[
                    cf.SourceConfiguration(
                        behaviors=[cf_behavior],
                        s3_origin_source=cf.S3OriginConfig(
                            s3_bucket_source=bucket,
                            origin_access_identity=oai
                        )
                    )
                ]
            )
    
            #リージョン間値送受信クラスでus-east-1リージョンにSSMパラメータ(CloudFront Distribution ID)を保存する
            resut_put_param = XRegionParam(self, 'CloudFrontDistIdParameter', 
                region='us-east-1', 
                service='SSM', 
                action='PUT', 
                key=PREFIX + 'cloud-front-dist-id', 
                val=distribution.distribution_id, 
                description=''
            )
            
    

    AWS CDKカスタムリソースを使用するクラス

    次の記事で紹介したAWS CDKカスタムリソースでクロスリージョンのパラメータ受け渡しをするConstructを継承したクラス(x_region_param.py)を各スタックと同じ階層に配置して使用しています。

    ■x_region_param.py

    実行手順

    今回のAWS CDKプロジェクトで基本認証用Lambda@Edgeを有効化する設定は2段階で実行することを前提にしています。
    まず、初回実行でS3+CloudFrontのウェブホスティング環境を作成し、2回目実行でCloudFrontディストリビューションIDを使用してLambda@EdgeとSecrets Managerシークレットを作成してCloudFrontに関連付けます。

    • 【初回実行】cdk.jsonでcontextを「"ACTIVATE_BASIC_AUTH": false」の状態で実行する。
      この段階ではS3+CloudFrontのウェブホスティング環境が構築されます。
    • 【2回目実行】初回実行後、cdk.jsonでcontextを「"ACTIVATE_BASIC_AUTH": true」の状態で実行する。
      この段階で基本認証用Lambda@Edgeが有効になります。


    参考:
    What is the AWS CDK? - AWS Cloud Development Kit (AWS CDK) v1
    What is the AWS CDK? - AWS Cloud Development Kit (AWS CDK) v2
    Tech Blog with related articles referenced

    まとめ

    今回は「AWS CDKで別リージョンにスタックをデプロイしてパラメータをリージョン間で受け渡す方法 -AWS CDKカスタムリソースの使い方」の記事で説明したAWS CDKカスタムリソースでリージョン間のパラメータ受け渡しをするConstruct継承クラスを使用して、別リージョンに基本認証用Lambda@Edgeを作成し、Amazon CloudFrontに設定する例を紹介しました。
    次回はリージョン間値送受信をするConstruct継承クラスを使用した記事のまとめとして、us-east-1リージョンにACMのSSL証明書、レプリケーション用S3バケットへのオリジンフェイルオーバー設定、基本認証用Lambda@Edgeを一括でAmazon CloudFrontに設定する例を紹介しようと考えています。

    Written by Hidekazu Konishi
    Hidekazu Konishi (小西秀和), a Japan AWS Top Engineer and a Japan All AWS Certifications Engineer

    執筆者小西秀和

    Japan AWS Top Engineer / Japan All AWS Certifications Engineer(AWS認定全冠)として、知識と実践的な経験を活かし、AWSの活用に取り組んでいます。
    Personal Tech Blog | Web Tools Collection | 
    小西 秀和 - Amazon著者ページ | [B! Bookmark] |