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

注目のタグ

    Amazon S3とAmazon CloudFrontによる静的ウェブサイトにSSL/TLS証明書(AWS Certificate Manager)・基本認証(Lambda@Edge)・IP制限(AWS WAF)をクロスリージョンで追加するAWS CloudFormationテンプレートとAWS Lambdaカスタムリソース

    小西秀和です。
    この記事は過去に投稿した次の記事の続編で、SSL/TLS証明書(AWS Certificate Manager)、基本認証(Lambda@Edge)に加えてIP制限(AWS WAF)を追加したパターンでAmazon S3とAmazon CloudFrontによる静的ウェブサイトホスティングをAWS CloudFormationテンプレートとAWS Lambdaカスタムリソースを使用して構成するものです。

    今回は更にOrigin Access Identity(OAI)をOrigin Access Control (OAC)に変更し、キャッシュポリシー(CachePolicy)、オリジンリクエストポリシー(OriginRequestPolicy)、レスポンスヘッダーポリシー(ResponseHeaderPolicy)をマネージドポリシーから選択式で設定できるようにしています。
    ここではAWSマネジメントコンソールでの操作とAWS WAFを柔軟に設定することを重視し、AWS AmplifyやAWS CDKはあえて使用していません。
    AWSマネジメントコンソールから特定のリージョンにAmazon AWS CloudFormationをデプロイし、us-east-1で作成が必要なAWSリソースをAWS Lambdaカスタムリソースを使用してクロスリージョンで作成します。

    AWS Amplify、AWS CDKを使用した静的ウェブサイトホスティングについては以下の記事に基本的な内容を書いていますので、そちらをご参考に御覧ください。
    * AWSの静的ウェブサイトホスティングで入門するAWS Amplify(Console、CLI) - 概要編
    * AWSの静的ウェブサイトホスティングで入門するAWS Amplify(Console、CLI) - 構築編(Amplify Console)
    * AWSの静的ウェブサイトホスティングで入門するAWS Amplify(Console、CLI) - 構築編(Amplify CLI)
    * AWS Amplify CLIとAWS CloudFormationでAmplify Console Hostingと同じ機能の再現を試みる - AWS CloudFormationによるAWS Amplify CLIの拡張
    * AWS CDKで別リージョンにSSL証明書・基本認証・レプリケーション用S3バケットを作成するスタックをデプロイしてAmazon CloudFrontオリジンフェイルオーバーを設定する

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

    今回の記事の内容は次のような構成になっています。

    本記事で試す構成の概要図

    今回は次の手順で概要図の構成をAWS CloudFormationとカスタムリソースを使用して作成することを試しています。
    初回作成時と2回目更新時で処理を分けている理由はAmazon CloudFrontのDistribution IDがAmazon CloudFront作成後にしか取得できず、Distribution IDが必要なAWSリソースを作成した後に再び関連付けのためにAmazon CloudFrontを更新する必要があるからです。

    【初回作成時】

    • 呼出元AWS CloudFormationスタックをパラメータにAmazon CloudFrontのDistribution IDを入力せずに作成する
    • 呼出元AWS CloudFormationスタックはデプロイしたリージョンでOriginAccessControl(OAC)、Amazon S3バケット、Amazon CloudFrontディストリビューションを作成する
    • 呼出元AWS CloudFormationスタックはカスタムリソースを使用して次の内容をus-east-1へデプロイし、作成した結果を呼出元AWS CloudFormationスタックに返却する
      • AWS Certificate Manager(ACM)証明書
    • 呼出元AWS CloudFormationスタックは呼出元リージョンのリソースを作成し、カスタムリソースからの返却値を使用してus-east-1リージョンの「AWS Certificate Manager(ACM)証明書」をAmazon CloudFrontに関連付ける

    【2回目更新時】

    • 呼出元AWS CloudFormationスタックをパラメータにAmazon CloudFrontのDistribution IDを入力して更新する
    • 呼出元AWS CloudFormationスタックは次の内容をus-east-1にデプロイするカスタムリソースを呼び出し、作成した結果を呼出元AWS CloudFormationスタックに返却する
      • IP制限をするAWS WAF Web ACL
      • 基本認証をするAWS Lambda@Edgeとそのバージョン
    • 呼出元AWS CloudFormationスタックは呼出元リージョンのリソースを更新してOriginAccessControl(OAC)を関連付け、カスタムリソースからの返却値を使用してus-east-1リージョンの「基本認証をするAWS Lambda@Edgeとそのバージョン」、「IP制限をするAWS WAF Web ACL」をAmazon CloudFrontに関連付ける

    AWS LambdaカスタムリソースによるSSL証明書・基本認証・IP制限のスタックデプロイと関連付けの例
    AWS LambdaカスタムリソースによるSSL証明書・基本認証・IP制限のスタックデプロイと関連付けの例

    テンプレートの名称設定

    AWS CloudFormationテンプレートに定義するAWSリソースは一意に必要なものを識別して連携できれば各ファイルの名称は任意ですが、説明のために次のように各ファイルの名称を設定します。

    • 任意のリージョンへAmazon S3とAmazon CloudFrontによる静的ウェブサイトを作成し、カスタムリソースを使用してSSL/TLS証明書(AWS Certificate Manager)・基本認証(Lambda@Edge)・IP制限(AWS WAF)をus-east-1リージョンにデプロイして関連付けるAWS CloudFormationテンプレート(呼び出し元)
      WebHostCFnS3CloudFrontWithAcmLambdaEdgeWaf.yml
    • 呼び出し元テンプレートから呼び出され、指定したAmazon S3にあるAWS CloudFormationテンプレートを使用してリソースをus-east-1リージョンへデプロイするカスタムリソースを作成するAWS CloudFormationテンプレート(カスタムリソース作成用)
      WebHostCFnCustomResourceToDeployAWS CloudFormationStack.yml
    • カスタムリソースからデプロイし、us-east-1リージョンへSSL/TLS証明書用AWS Certificate Managerを作成するAWS CloudFormationテンプレート(SSL/TLS証明書用AWS Certificate Manager)
      WebHostWebHostCFnACMCertificate.yml
    • カスタムリソースからデプロイし、us-east-1リージョンへ基本認証用Lambda@Edgeを作成するAWS CloudFormationテンプレート(基本認証用Lambda@Edge)
      WebHostWebHostCFnBasicAuthLambdaEdge.yml
    • カスタムリソースからデプロイし、us-east-1リージョンへIP制限用AWS WAFを作成するAWS CloudFormationテンプレート(IP制限用AWS WAF)
      WebHostCFnWAFWebACL.yml

    各ファイルやパラメータの例

    各ファイルやパラメータの例を記載していきます。
    特に入力パラメータに関しては入力形式を知っていただくための例なので、使用する場合は各パラメータを要件に合わせて設定する必要があります。

    AWS CloudFormationテンプレート(カスタムリソース作成用)

    参考記事:AWS LambdaカスタムリソースでAWS CloudFormationスタックを別リージョンにデプロイする

    テンプレート本体

    ファイル名:WebHostCFnCustomResourceToDeployCloudformationStack.yml

    AWSTemplateFormatVersion: '2010-09-09'
    Description: 'CFn Template for a stack that creates AWS Lambda Custom Resource which deploys AWS CloudFormation stack.'
    Resources:
      LambdaCustomResourceToDeployCFnStack:
        Type: AWS::Lambda::Function
        DependsOn: 
          - LambdaCustomResourceToDeployCFnStackRole
        Properties:
          FunctionName: LambdaCustomResourceToDeployCFnStack
          Description : 'AWS Lambda Custom Resource which deploys AWS CloudFormation stack.'
          Runtime: python3.9
          MemorySize: 10240
          Timeout: 900
          Role: !GetAtt LambdaCustomResourceToDeployCFnStackRole.Arn
          Handler: index.lambda_handler
          Code:
            ZipFile: |
              # ## このカスタムリソースの特徴
              # * カスタムリソース仕様のレスポンスを返すメソッドを自前で作成した
              #   CloudFormationスタックから呼び出す場合はcfnresponse.sendが使用できますが、Lambda単体での実行やテストには使用できないためcfn_response_sendという自前のメソッドを作成しました。
              #   参考:https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/cfn-lambda-function-code-cfnresponsemodule.html
              # * デプロイするテンプレートをS3またはハードコーディングから選択できるようにした
              #   S3からテンプレートを取得する方法では様々なテンプレートが実行できます。一方で特定のテンプレート実行に特化させたい場合はハードコーディングを選択することで他のテンプレートを実行させないようにできます。
              # * カスタムリソースからデプロイするCloudFormationテンプレートの引数の指定を簡素化した
              #   デプロイするCloudformationテンプレートの引数として使用しないキーを指定して、それ以外を渡すことで引数の受け渡しを汎用的にしました。
              # * 要求された処理と異なる状況の場合でもリソースが作成される方針に寄せた
              #   要求されたCreate、Update処理をそのままCloudFormationのデプロイに使用するのではなく、Update処理でスタックが存在しない場合にはCreate処理に切り替えるなど、なるべくリソースが作成される方針に寄せました。
              # * 処理のスキップを選択できるようにした
              #   呼出元で依存関係を維持しながらCreate処理では使用せず、Update処理から使用するといったことができるようにスキップ処理が選択できるようにしました。
    
              import urllib3
              import json
              import boto3
              import botocore
              import datetime
              # import cfnresponse
    
              SUCCESS = 'SUCCESS'
              FAILED = 'FAILED'
    
              http = urllib3.PoolManager()
    
              # 呼出元のCloudformationスタックから受けた引数のうち、デプロイするCloudformationスタックの引数として使用しないキー
              config = {
                  "ExcludeKeys": ["ServiceToken", "StackName", "CfnTplS3Bucket", "CfnTplS3Key", "IsSkip"]
              }
    
              # デプロイするCloudformationテンプレートを直接コードに記述したい場合はここに記述
              fixed_cfn_template = '''
              '''
    
              # us-east-1リージョンにCloudformationスタックをデプロイするクライアント
              cfn_client = boto3.client('cloudformation', region_name='us-east-1')
              # Cloudformationテンプレートを取得するS3リソース
              s3_resource = boto3.resource('s3')
    
              # カスタムリソースの仕様でCloudFormationにレスポンスを返す独自メソッド
              # CloudFormationスタックから呼び出す場合はcfnresponse.sendが使用できるが、Lambda単体での実行には使用できないため独自メソッドを使用する
              def cfn_response_send(event, context, response_status, response_data, physical_resource_id=None, no_echo=False, reason=None):
                  response_url = event['ResponseURL']
                  print(response_url)
    
                  response_body = {
                      'Status': response_status,
                      'Reason': reason or 'See the details in CloudWatch Log Stream: {}'.format(context.log_stream_name),
                      'PhysicalResourceId': physical_resource_id or context.log_stream_name,
                      'StackId': event['StackId'],
                      'RequestId': event['RequestId'],
                      'LogicalResourceId': event['LogicalResourceId'],
                      'NoEcho': no_echo,
                      'Data': response_data
                  }
    
                  json_response_body = json.dumps(response_body)
    
                  print('Response body:')
                  print(json_response_body)
    
                  headers = {
                      'content-type': '',
                      'content-length': str(len(json_response_body))
                  }
    
                  try:
                      response = http.request('PUT', response_url, headers=headers, body=json_response_body)
                      print('Status code:', response.status)
                  except Exception as e:
                      print('cfn_response_send(..) failed executing http.request(..):', e)
    
              def lambda_handler(event, context):
                  response_data = {}
    
                  try:
                      print(('event:' + json.dumps(event, indent=2)))
    
                      resource_properties = event['ResourceProperties']
                      is_skip = resource_properties.get('IsSkip')
    
                      # IsSkipにtrueが入ってきた場合は何も処理せず空のレスポンスを返す
                      # 呼出元でカスタムリソース処理をスキップする場合に使用する
                      if is_skip is not None and is_skip.strip().lower() == 'true':
                          print('Skip All Process.')
                          cfn_response_send(event, context, SUCCESS, response_data)
                          # cfnresponse.send(event, context, cfnresponse.SUCCESS, response_data)
                          return
    
                      stack_name = resource_properties.get('StackName')
    
                      exclude_keys = config['ExcludeKeys']
    
                      cfn_params = []
                      for key, val in resource_properties.items():
                          if key not in exclude_keys:
                              cfn_params.append({
                                  'ParameterKey': key,
                                  'ParameterValue': val
                              })
    
                      print('----------[Start]cfn_params:----------')
                      print(cfn_params)
                      print('----------[End]cfn_params:------------')
    
                      cfn_template = fixed_cfn_template
                      try:
                          # fixed_cfn_template変数に直接テンプレートを記述していない場合は指定されたS3バケットのオブジェクトからテンプレートを取得する
                          if cfn_template.strip() == '':
                              cfn_tpl_s3_bucket = resource_properties['CfnTplS3Bucket']
                              cfn_tpl_s3_key = resource_properties['CfnTplS3Key']
                              bucket = s3_resource.Bucket(cfn_tpl_s3_bucket)
                              obj = bucket.Object(cfn_tpl_s3_key).get()
                              cfn_template = obj['Body'].read().decode('utf-8')
                              print('----------[Start]cfn_template:----------')
                              print(cfn_template)
                              print('----------[End]cfn_template:------------')
                      except Exception as e:
                          print('lambda_handler(..) failed executing read s3 obj(..):', e)
                          raise
    
                      if event['RequestType'] == 'Delete':
                          try:
                              print('Delete Stacks')
                              # Delete処理の場合は単純に削除する
                              print('Run cfn_client.delete_stack.')
                              response = cfn_client.delete_stack(
                                  StackName=stack_name
                              )
    
                              print('cfn_client.delete_stack response:')
                              print(response)
    
                              # スタックのDelete処理が完了するまで待つ
                              waiter = cfn_client.get_waiter('stack_delete_complete')
                              waiter.wait(StackName=stack_name)
                          except (botocore.exceptions.ValidationError, botocore.exceptions.ClientError) as err:
                              err_msg = str(err)
                              print('err_msg:')
                              print(err_msg)
                              #相互に関連付けしたリソース同士で削除エラーのループが発生するため、例外は上げない。
    
                      if event['RequestType'] == 'Create' or event['RequestType'] == 'Update':
                          # Create処理とUpdate処理の実行方針
                          # Create処理要求かつスタックが存在しない ⇒ Create処理
                          # Create処理要求かつスタックが存在する ⇒ 何もしない
                          # Update処理要求かつスタックが存在しない ⇒ Create処理
                          # Update処理要求かつスタックが存在するかつ変更点が存在する ⇒ Update処理
                          # Update処理要求かつスタックが存在するかつ変更点が存在しない ⇒ 何もしない
    
                          # 同名のスタックが存在するかを示すフラグ
                          exist_stack = False
    
                          try:
                              print('Run cfn_client.describe_stacks.')
                              existing_stacks = cfn_client.describe_stacks(StackName=stack_name)
                              print('Existing Stacks:')
                              print(existing_stacks)
    
                              # スタックが存在し、describe_stacksが正常に処理されればexist_stackはTrue
                              exist_stack = True
                          except (botocore.exceptions.ValidationError, botocore.exceptions.ClientError) as err:
                              err_msg = str(err)
                              print('err_msg:')
                              print(err_msg)
                              # スタックが存在せず、describe_stacksがエラーになればexist_stackはFalse
                              if 'DescribeStack' in err_msg and 'does not exist' in err_msg:
                                  print(('describe_stacks error: ' + err_msg))
                                  exist_stack = False
                              else:
                                  raise
    
                          # Create処理が必要かを示すフラグ
                          need_create = True
    
                          if event['RequestType'] == 'Update':
                              print('Update Stacks')
                              if exist_stack == False:
                                  # Update処理かつスタックが存在しない場合はCreate処理でスタックを作成する
                                  need_create = True
                              else:
                                  # Create処理が必要かどうかを示すフラグ
                                  need_create = False
                                  # すでにスタック名に一致するスタックが存在すればUpdateで処理する
                                  try:
                                      print('Run cfn_client.update_stack.')
                                      response = cfn_client.update_stack(
                                          StackName=stack_name,
                                          TemplateBody=cfn_template,
                                          Parameters=cfn_params,
                                          Capabilities=[
                                              'CAPABILITY_NAMED_IAM'
                                          ]
                                      )
    
                                      print('cfn_client.update_stack response:')
                                      print(response)
    
                                      # スタックのUpdate処理が完了するまで待つ
                                      waiter = cfn_client.get_waiter('stack_update_complete')
                                      waiter.wait(StackName=stack_name)
                                  except (botocore.exceptions.ValidationError, botocore.exceptions.ClientError) as err:
                                      err_msg = str(err)
                                      print('err_msg:')
                                      print(err_msg)
                                      # Update処理を要求されたが、変更点がない場合は何もしない
                                      if 'UpdateStack' in err_msg and 'No updates are to be performed' in err_msg:
                                          print(('update_stacks error: ' + err_msg))
                                      else:
                                          raise
    
                          if event['RequestType'] == 'Create' or need_create == True:
                              print('Create Stacks')
                              # Create処理要求かつスタックが存在しない場合、またはUpdate処理要求かつスタックが存在しない場合にスタックをCreate処理する
                              if exist_stack == False:
                                  print('Run cfn_client.create_stack.')
                                  response = cfn_client.create_stack(
                                      StackName=stack_name,
                                      TemplateBody=cfn_template,
                                      Parameters=cfn_params,
                                      Capabilities=[
                                          'CAPABILITY_NAMED_IAM'
                                      ]
                                  )
    
                                  print('cfn_client.create_stack response:')
                                  print(response)
    
                                  # スタックのCreate処理が完了するまで待つ
                                  waiter = cfn_client.get_waiter('stack_create_complete')
                                  waiter.wait(StackName=stack_name)
    
                          # CreateまたはUpdateされたスタックを取得する
                          stacks = cfn_client.describe_stacks(StackName=stack_name)
    
                          # Outputsの内容を取得し、返却値として整形する
                          outputs = stacks['Stacks'][0]['Outputs']
                          print(('Outputs:' + json.dumps(outputs, indent=2)))
    
                          for output in outputs:
                              response_data[output['OutputKey']] = output['OutputValue']
                          print(('Outputs:' + json.dumps(response_data, indent=2)))
                      # CloudFormationカスタムリソース仕様で呼出元のスタックにレスポンスを返す
                      cfn_response_send(event, context, SUCCESS, response_data)
                      # cfnresponse.send(event, context, cfnresponse.SUCCESS, response_data)
    
                      return
                  except Exception as e:
                      print('Exception:')
                      print(e)
                      # CloudFormationカスタムリソース仕様で呼出元のスタックにレスポンスを返す
                      cfn_response_send(event, context, FAILED, response_data)
                      # cfnresponse.send(event, context, cfnresponse.FAILED, response_data)
    
                      return
    
      LambdaCustomResourceToDeployCFnStackRole:
        Type: AWS::IAM::Role
        Properties:
          RoleName: IAMRole-LambdaCustomResourceToDeployCFnStack
          Path: /
          AssumeRolePolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Principal:
                  Service:
                    - edgelambda.amazonaws.com
                    - lambda.amazonaws.com
                Action:
                  - sts:AssumeRole
          Policies:
          - PolicyName: IAMPolicy-LambdaCustomResourceToDeployCFnStack
            PolicyDocument:
              Version: '2012-10-17'
              Statement:
                - Effect: Allow
                  Action:
                    - cloudformation:CancelUpdateStack
                    - cloudformation:ContinueUpdateRollback
                    - cloudformation:Describe*
                    - cloudformation:Get*
                    - cloudformation:List*
                    - cloudformation:CreateStack
                    - cloudformation:UpdateStack
                    - cloudformation:DeleteStack
                    - cloudformation:ValidateTemplate
                    - s3:GetObject
                    - s3:ListAllMyBuckets
                    - s3:ListBucket
                    - iam:AttachRolePolicy
                    - iam:CreatePolicy
                    - iam:CreatePolicyVersion
                    - iam:CreateRole
                    - iam:DeletePolicy
                    - iam:DeletePolicyVersion
                    - iam:DeleteRole
                    - iam:DeleteRolePermissionsBoundary
                    - iam:DeleteRolePolicy
                    - iam:DetachRolePolicy
                    - iam:GetPolicy
                    - iam:GetPolicyVersion
                    - iam:GetRole
                    - iam:GetRolePolicy
                    - iam:ListAttachedRolePolicies
                    - iam:ListInstanceProfilesForRole
                    - iam:ListPolicyTags
                    - iam:ListPolicyVersions
                    - iam:ListRolePolicies
                    - iam:ListRoles
                    - iam:ListRoleTags
                    - iam:PassRole
                    - iam:PutRolePermissionsBoundary
                    - iam:PutRolePolicy
                    - iam:SetDefaultPolicyVersion
                    - iam:TagPolicy
                    - iam:TagRole
                    - iam:UntagPolicy
                    - iam:UntagRole
                    - iam:UpdateAssumeRolePolicy
                    - iam:UpdateRole
                    - iam:UpdateRoleDescription
                    - acm:*
                    - lambda:*
                    - route53:*
                    - secretsmanager:*
                    - wafv2:*
                  Resource:
                    - '*'
                - Effect: Allow
                  Action:
                    - logs:CreateLogGroup
                  Resource:
                    - 'arn:aws:logs:*:*:*'
                - Effect: Allow
                  Action:
                    - logs:CreateLogStream
                    - logs:PutLogEvents
                  Resource:
                    - !Sub 'arn:aws:logs:*:*:log-group:/aws/lambda/LambdaCustomResourceToDeployCFnStack:*'
    

    AWS CloudFormationテンプレート(呼び出し元)

    入力パラメータ例

    ACMCFnStackName: WebHostCFnACMCertificate
    ACMCustomDomainName: cfn-acm-edge-waf-cfnt-s3.h-o2k.com
    ACMHostedZoneId: ZZZZZZZZZZZZZZZZZZZZZ
    ACMS3BucketNameOfStoringTemplate: h-o2k
    ACMS3KeyOfStoringTemplate: WebHostCFnACMCertificate.yml
    CloudFrontCachePolicyName: CachingDisabled
    CloudFrontDistributionID: 
    CloudFrontOriginRequestPolicyName: NONE
    CloudFrontResponseHeaderPolicyName: CORS-with-preflight-and-SecurityHeadersPolicy
    CustomResourceLambdaARN: arn:aws:lambda:ap-northeast-1:XXXXXXXXXXXX:function:LambdaCustomResourceToDeployCFnStack
    Env: dev
    LambdaEdgeBasicAuthFuncName: WebHostCFnBasicAuthLambdaEdge
    LambdaEdgeBasicAuthID: Iam
    LambdaEdgeBasicAuthPW: Nobody
    LambdaEdgeCFnStackName: WebHostCFnBasicAuthLambdaEdge
    LambdaEdgeS3BucketNameOfStoringTemplate: h-o2k
    LambdaEdgeS3KeyOfStoringTemplate: WebHostCFnBasicAuthLambdaEdge.yml
    S3BucketName: cfn-acm-edge-waf-cfnt-s3-20230311144618
    S3BucketVersioningStatus: Suspended
    WAFWebACLAllowIPList: XXX.XXX.XXX.XXX/16,YYY.YYY.YYY.YYY/24,ZZZ.ZZZ.ZZZ.ZZZ/32
    WAFWebACLCFnStackName: WebHostCFnWAFWebACL
    WAFWebACLResourcePrefix: WebHostIPRestrictions
    WAFWebACLS3BucketNameOfStoringTemplate: h-o2k
    WAFWebACLS3KeyOfStoringTemplate: WebHostCFnWAFWebACL.yml
    

    テンプレート本体

    ファイル名:WebHostCFnS3CloudFrontWithAcmLambdaEdgeWaf.yml

    AWSTemplateFormatVersion: '2010-09-09'
    Description: 'CFn Template for a stack that creates ACM, Lambda@Edge, WAF, and S3+CloudFront Hosting.'
    #<作成する順番>
    #ACM証明書[US]→OriginAccessControl[JP]→S3バケットポリシー(作成)[JP]→S3バケット[JP]→CloudFront(作成)[JP]→Lambda@Edge(CloudFrontDistributionIDで作成)[US]
    #→S3バケットポリシー(更新:CloudFrontDistributionIDでOCA設定)[JP]→CloudFront(更新:Lambda@Edge設定)[JP]
    Parameters:
      Env: #【共通】作成するS3バケットのサフィックスに追加する環境名
        Type: String
        Default: dev
      S3BucketName: #【共通】静的ウェブサイトホスティングに使用するS3バケット名
        Type: String
        Default: cfn-acm-edge-waf-cfnt-s3-20230311144618
      S3BucketVersioningStatus: #【共通】静的ウェブサイトホスティングに使用するS3バケットのバージョニング設定
        Type: String
        Default: Suspended
        AllowedValues:
        - Enabled
        - Suspended
      CustomResourceLambdaARN: #【共通】ACM証明書、基本認証用Lambda@Edge、IP制限用AWS WAFを作成するスタックをus-east1にデプロイするLambdaカスタムリソースのARN
        Type: String
        Default: arn:aws:lambda:ap-northeast-1:XXXXXXXXXXXX:function:LambdaCustomResourceToDeployCFnStack
      ACMCFnStackName: #【ACM用】ACM証明書を作成するためにus-east1にデプロイするCloudformationスタック名
        Type: String
        Default: WebHostCFnACMCertificate
      ACMS3BucketNameOfStoringTemplate: #【ACM用】ACM証明書を作成するCloudformationテンプレートを保存しているバケット名
        Type: String
        Default: h-o2k
      ACMS3KeyOfStoringTemplate: #【ACM用】ACM証明書を作成するCloudformationテンプレートを保存しているバケット以降のオブジェクトキー
        Type: String
        Default: WebHostCFnACMCertificate.yml
      ACMCustomDomainName: #【ACM用】ACM証明書を発行する対象ドメイン名
        Type: String
        Default: cfn-acm-edge-waf-cfnt-s3.h-o2k.com
      ACMHostedZoneId: #【ACM用】ACM証明書を発行する対象ドメイン名を管理するRoute53のホストゾーンID
        Type: String
        Default: ZZZZZZZZZZZZZZZZZZZZZ
      WAFWebACLCFnStackName: #【WAFWebACL用】AWS WAF WebACLを作成するためにus-east1にデプロイするCloudformationスタック名
        Type: String
        Default: WebHostCFnWAFWebACL
      WAFWebACLS3BucketNameOfStoringTemplate: #【WAFWebACL用】AWS WAF WebACLを作成するCloudformationテンプレートを保存しているバケット名
        Type: String
        Default: h-o2k
      WAFWebACLS3KeyOfStoringTemplate: #【WAFWebACL用】AWS WAF WebACLを作成するCloudformationテンプレートを保存しているバケット以降のオブジェクトキー
        Type: String
        Default: WebHostCFnWAFWebACL.yml
      WAFWebACLResourcePrefix: #【WAFWebACL用】AWS WAF WebACLのリソースにつけるPrefix  
        Type: String
        Default: WebHostIPRestrictions
      WAFWebACLAllowIPList: #【WAFWebACL用】AWS WAF WebACLのルールでIP制限をするCIDR
        #Type: CommaDelimitedList
        Type: String
        Default: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
      LambdaEdgeCFnStackName: #【Lambda@Edge用】Lambda@Edgeを作成するためにus-east1にデプロイするCloudformationスタック名
        Type: String
        Default: WebHostCFnBasicAuthLambdaEdge
      LambdaEdgeS3BucketNameOfStoringTemplate: #【Lambda@Edge用】Lambda@Edgeを作成するCloudformationテンプレートを保存しているバケット名
        Type: String
        Default: h-o2k
      LambdaEdgeS3KeyOfStoringTemplate: #【Lambda@Edge用】Lambda@Edgeを作成するCloudformationテンプレートを保存しているバケット以降のオブジェクトキー
        Type: String
        Default: WebHostCFnBasicAuthLambdaEdge.yml
      LambdaEdgeBasicAuthFuncName: #【Lambda@Edge用】基本認証をするLambda@Edgeの関数名
        Type: String
        Default: WebHostCFnBasicAuthLambdaEdge
      LambdaEdgeBasicAuthID: #【Lambda@Edge用】基本認証をするLambda@Edgeで認証するID。AWS Secrets Managerシークレットとして保管する。
        Type: String
        Default: Iam
      LambdaEdgeBasicAuthPW: #【Lambda@Edge用】基本認証をするLambda@Edgeで認証するパスワード。AWS Secrets Managerシークレットとして保管する。
        Type: String
        Default: Nobody
      CloudFrontCachePolicyName: #【共通】Amazon CloudFrontのキャッシュポリシーの指定。マネージドポリシーからの選択式。  
        Type: String
        Default: CachingDisabled
        AllowedValues:
          - NONE
          - Amplify
          - CachingDisabled
          - CachingOptimized
          - CachingOptimizedForUncompressedObjects
          - Elemental-MediaPackage
      CloudFrontOriginRequestPolicyName: #【共通】Amazon CloudFrontのオリジンリクエストポリシー。マネージドポリシーからの選択式。  
        Type: String
        Default: NONE
        AllowedValues:
          - NONE
          - AllViewer
          - AllViewerAndCloudFrontHeaders-2022-06
          - AllViewerExceptHostHeader
          - CORS-CustomOrigin
          - CORS-S3Origin
          - Elemental-MediaTailor-PersonalizedManifests
          - UserAgentRefererHeaders
      CloudFrontResponseHeaderPolicyName: #【共通】Amazon CloudFrontのレスポンスヘッダーポリシー。マネージドポリシーからの選択式。  
        Type: String
        Default: CORS-with-preflight-and-SecurityHeadersPolicy
        AllowedValues:
          - NONE
          - SimpleCORS
          - CORS-With-Preflight
          - SecurityHeadersPolicy
          - CORS-and-SecurityHeadersPolicy
          - CORS-with-preflight-and-SecurityHeadersPolicy
      CloudFrontDistributionID: #【共通】各リソースの関連付けに使用するAmazon CloudFrontのDistribution ID。
        #Amazon S3バケットポリシーのOriginAccessControl関連付け、Lambda@Edgeで使用するAWS Secrets Managerシークレットの一意識別、AWS WAFの関連付けで使用する。
        Type: String #※スタックの初回Create処理でAmazon CloudFrontを作成してから、2回目のUpdate処理で入力する。
    Mappings: 
      CloudFrontCachePolicyIds:
        NONE:
          Id: ""
        Amplify:
          Id: 2e54312d-136d-493c-8eb9-b001f22f67d2
        CachingDisabled:
          Id: 4135ea2d-6df8-44a3-9df3-4b5a84be39ad
        CachingOptimized:
          Id: 658327ea-f89d-4fab-a63d-7e88639e58f6
        CachingOptimizedForUncompressedObjects:
          Id: b2884449-e4de-46a7-ac36-70bc7f1ddd6d
        Elemental-MediaPackage:
          Id: 08627262-05a9-4f76-9ded-b50ca2e3a84f
      CloudFrontOriginRequestPolicyIds:
        NONE:
          Id: ""
        AllViewer:
          Id: 216adef6-5c7f-47e4-b989-5492eafa07d3
        AllViewerAndCloudFrontHeaders-2022-06:
          Id: 33f36d7e-f396-46d9-90e0-52428a34d9dc
        AllViewerExceptHostHeader:
          Id: b689b0a8-53d0-40ab-baf2-68738e2966ac
        CORS-CustomOrigin:
          Id: 59781a5b-3903-41f3-afcb-af62929ccde1
        CORS-S3Origin:
          Id: 88a5eaf4-2fd4-4709-b370-b4c650ea3fcf
        Elemental-MediaTailor-PersonalizedManifests:
          Id: 775133bc-15f2-49f9-abea-afb2e0bf67d2
        UserAgentRefererHeaders:
          Id: acba4595-bd28-49b8-b9fe-13317c0390fa
      CloudFrontResponseHeaderPolicyIds:
        NONE:
          Id: ""
        CORS-and-SecurityHeadersPolicy:
          Id: e61eb60c-9c35-4d20-a928-2b84e02af89c
        CORS-With-Preflight:
          Id: 5cc3b908-e619-4b99-88e5-2cf7f45965bd
        CORS-with-preflight-and-SecurityHeadersPolicy:
          Id: eaab4381-ed33-4a86-88ca-d9558dc6cd63
        SecurityHeadersPolicy:
          Id: 67f7725c-6f97-4210-82d7-5512b31e9d03
        SimpleCORS:
          Id: 60669652-455b-4ae9-85a4-c4c02393f86c
    Conditions:
      IsEnv:
        !Equals [!Ref Env, NONE]
      IsCloudFrontDistributionID: #入力パラメータにAmazon CloudFrontのDistribution IDがある場合はTrueでLambda@Edgeを作成、ない場合はFalseでLambda@Edge作成をスキップ。
        !Not [!Equals [!Ref CloudFrontDistributionID, ""]]
    Resources:
      S3Bucket:
        Type: AWS::S3::Bucket
        #DependsOn:
        #DeletionPolicy: Retain
        Properties:
          BucketName:
            !If [IsEnv, !Ref S3BucketName, !Join ["", [!Ref S3BucketName, "-", !Ref Env]]]
          WebsiteConfiguration:
            IndexDocument: index.html
            ErrorDocument: index.html
          CorsConfiguration:
            CorsRules:
              - {
                  AllowedHeaders: ["*"],
                  AllowedMethods: ["GET","PUT","POST","DELETE","HEAD"],
                  AllowedOrigins: ["*"],
                  MaxAge: 3000,
                }
          VersioningConfiguration:
            Status: !Ref S3BucketVersioningStatus
    
      PrivateBucketPolicy:
        Type: AWS::S3::BucketPolicy
        DependsOn:
          - OriginAccessControl
          - S3Bucket
        Properties:
          Bucket: !Ref S3Bucket
          PolicyDocument:
            Statement:
            - Action: s3:GetObject
              Effect: Allow
              Resource: !Sub ${S3Bucket.Arn}/*
              Principal:
                Service: cloudfront.amazonaws.com
              Condition:
                StringEquals:
                  AWS:SourceArn:
                    !If
                      - IsCloudFrontDistributionID
                      - !Sub arn:aws:cloudfront::${AWS::AccountId}:distribution/${CloudFrontDistributionID}
                      - !Sub arn:aws:cloudfront::${AWS::AccountId}:distribution/*
      OriginAccessControl: 
        Type: AWS::CloudFront::OriginAccessControl
        Properties: 
          OriginAccessControlConfig:
            Description: !Join ["", ["OAC-", !If [IsEnv, !Ref S3BucketName, !Join ["", [!Ref S3BucketName, "-", !Ref Env]]]]]
            Name: !Join ["", ["OAC-", !If [IsEnv, !Ref S3BucketName, !Join ["", [!Ref S3BucketName, "-", !Ref Env]]]]]
            OriginAccessControlOriginType: s3
            SigningBehavior: always
            SigningProtocol: sigv4
      #CloudFrontをエイリアスレコードとしてRoute53ホストゾーンに登録するRoute53レコードセット
      Route53RecordSetGroup:
        Type: AWS::Route53::RecordSetGroup
        DependsOn:
          - CloudFrontDistribution
        Properties:
          HostedZoneId:
            !Ref ACMHostedZoneId
          RecordSets:
            - Name: !Ref ACMCustomDomainName
              Type: A
              #CloudFrontをエイリアスレコードとして登録する場合はエイリアスターゲットのHostedZoneIdが次の固定値となる
              #参考:https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/quickref-route53.html
              AliasTarget:
                HostedZoneId: Z2FDTNDATAQYW2
                DNSName: !GetAtt CloudFrontDistribution.DomainName
      #ACM証明書を発行するために呼び出すカスタムリソース
      CustomResourceForACM:
        Type: Custom::CustomResourceForACM
        Properties:
          ServiceToken: !Ref CustomResourceLambdaARN
          StackName: !Ref ACMCFnStackName
          CfnTplS3Bucket: !Ref ACMS3BucketNameOfStoringTemplate
          CfnTplS3Key: !Ref ACMS3KeyOfStoringTemplate
          CustomDomainName: !Ref ACMCustomDomainName
          HostedZoneId: !Ref ACMHostedZoneId
      #AWS WAF WebACLを作成するために呼び出すカスタムリソース
      CustomResourceForWAFWebACL:
        Type: Custom::CustomResourceForWAFWebACL
        Properties:
          ServiceToken: !Ref CustomResourceLambdaARN
          StackName: !Ref WAFWebACLCFnStackName
          CfnTplS3Bucket: !Ref WAFWebACLS3BucketNameOfStoringTemplate
          CfnTplS3Key: !Ref WAFWebACLS3KeyOfStoringTemplate
          ResourcePrefix: !Ref WAFWebACLResourcePrefix
          AllowIPList: !Ref WAFWebACLAllowIPList
          IsSkip: ##入力パラメータにAmazon CloudFrontのDistribution IDがない場合はカスタムリソースにIsSkip:trueを送信し、AWS WAF WebACL作成処理をスキップする。
            !If [IsCloudFrontDistributionID, "false", "true"]
      #Lambda@Edgeを作成するために呼び出すカスタムリソース
      CustomResourceForLambdaEdge:
        Type: Custom::CustomResourceForLambdaEdge
        Properties:
          ServiceToken: !Ref CustomResourceLambdaARN
          StackName: !Ref LambdaEdgeCFnStackName
          CfnTplS3Bucket: !Ref LambdaEdgeS3BucketNameOfStoringTemplate
          CfnTplS3Key: !Ref LambdaEdgeS3KeyOfStoringTemplate
          CloudFrontDistId: !Ref CloudFrontDistributionID
          BasicAuthFuncName: !Ref LambdaEdgeBasicAuthFuncName
          BasicAuthID: !Ref LambdaEdgeBasicAuthID
          BasicAuthPW: !Ref LambdaEdgeBasicAuthPW
          IsSkip: ##入力パラメータにAmazon CloudFrontのDistribution IDがない場合はカスタムリソースにIsSkip:trueを送信し、Lambda@Edge作成処理をスキップする。
            !If [IsCloudFrontDistributionID, "false", "true"]
      CloudFrontDistribution:
        Type: AWS::CloudFront::Distribution
        DependsOn:
          #OriginAccessControlが作成されてからCloudFrontに関連付けるためDependsOnにカスタムリソースを追加
          - OriginAccessControl
          #Amazon S3バケットが作成されてからCloudFrontに関連付けるためDependsOnにカスタムリソースを追加
          - S3Bucket
          #カスタムリソースでACM証明書が発行されてからCloudFrontに関連付けるためDependsOnにカスタムリソースを追加
          - CustomResourceForACM
          #カスタムリソースでAWS WAF WebACLが作成されてからCloudFrontに関連付けるためDependsOnにカスタムリソースを追加
          - CustomResourceForWAFWebACL
          #カスタムリソースで基本認証用Lambda@Edgeバージョンが作成されてからCloudFrontに関連付けるためDependsOnにカスタムリソースを追加
          - CustomResourceForLambdaEdge
        Properties:
          DistributionConfig:
            #CloudFrontのエイリアスにACM証明書を発行したドメイン名を設定する
            Aliases:
              - !Ref ACMCustomDomainName
            #CloudFrontにカスタムリソースで作成したACM証明書を設定する
            ViewerCertificate:
              SslSupportMethod: sni-only
              MinimumProtocolVersion: TLSv1.2_2021
              AcmCertificateArn: !GetAtt CustomResourceForACM.AcmCertificateArn
            WebACLId:
              !If
                - IsCloudFrontDistributionID
                - !GetAtt CustomResourceForWAFWebACL.WAFv2WebACLArn
                - !Ref AWS::NoValue
            HttpVersion: http2
            Origins:
              - DomainName: !GetAtt S3Bucket.RegionalDomainName
                Id: StaticWebsiteHostingS3Bucket
                OriginAccessControlId: !GetAtt OriginAccessControl.Id
                S3OriginConfig: 
                  OriginAccessIdentity: ""
            Enabled: true
            DefaultCacheBehavior:
              AllowedMethods: [GET, HEAD, OPTIONS]
              TargetOriginId: StaticWebsiteHostingS3Bucket #オリジンをターゲットに指定する
              ForwardedValues:
                QueryString: false
              ViewerProtocolPolicy: redirect-to-https
              CachePolicyId: !FindInMap [ CloudFrontCachePolicyIds, !Ref CloudFrontCachePolicyName , Id ]
              OriginRequestPolicyId: !FindInMap [ CloudFrontOriginRequestPolicyIds, !Ref CloudFrontOriginRequestPolicyName , Id ]
              ResponseHeadersPolicyId: !FindInMap [ CloudFrontResponseHeaderPolicyIds, !Ref CloudFrontResponseHeaderPolicyName , Id ]
              #DefaultTTL: 86400
              #MaxTTL: 31536000
              #MinTTL: 60
              #Compress: true
              LambdaFunctionAssociations:
                !If
                  - IsCloudFrontDistributionID
                  - [
                      {
                        EventType: viewer-request,
                        IncludeBody: "true",
                        LambdaFunctionARN: !GetAtt CustomResourceForLambdaEdge.LambdaFunctionVersionArn
                      }
                    ]
                  - !Ref AWS::NoValue
            DefaultRootObject: index.html
            CustomErrorResponses:
              - {
                  ErrorCachingMinTTL: 10,
                  ErrorCode: 400,
                  ResponseCode: 200,
                  ResponsePagePath: /,
                }
              - {
                  ErrorCachingMinTTL: 10,
                  ErrorCode: 404,
                  ResponseCode: 200,
                  ResponsePagePath: /,
                }
    Outputs:
      Region:
        Value:
          !Ref AWS::Region
      HostingS3BucketName:
        Description: "Hosting bucket name"
        Value:
          !Ref S3Bucket
      ACMCustomDomainURL:
        Value:
          !Join ["", ["https://", !Ref ACMCustomDomainName]]
        Description: "Web hosting URL with Certificate"
      S3BucketWebsiteURL:
        Value:
          !GetAtt S3Bucket.WebsiteURL
        Description: "URL for website hosted on S3"
      S3BucketSecureURL:
        Value:
          !Join ["", ["https://", !GetAtt S3Bucket.DomainName]]
        Description: "Name of S3 bucket to hold website content"
      CloudFrontDistributionID:
        Value:
          !Ref CloudFrontDistribution
      CloudFrontDomainName:
        Value:
          !GetAtt CloudFrontDistribution.DomainName
      CloudFrontSecureURL:
        Value:
          !Join ["", ["https://", !GetAtt CloudFrontDistribution.DomainName]]
      CloudFrontOriginAccessControl:
        Value:
          !Ref OriginAccessControl
      CustomResourceSkiped:
        Value:
          !If [IsCloudFrontDistributionID, "false", "true"]
    

    AWS CloudFormationテンプレート(SSL/TLS証明書用AWS Certificate Manager)

    テンプレート本体

    ファイル名:WebHostWebHostCFnACMCertificate.yml

    AWSTemplateFormatVersion: "2010-09-09"
    Description: "CFn Template for a stack that creates AWS CertificateManager Certificate."
    Parameters:
      CustomDomainName: #ACM証明書を発行する対象のカスタムドメイン名
        Type: String
      HostedZoneId: #ACM証明書を発行する対象のカスタムドメインを管理しているAmazon Route53ホストゾーンID
        Type: String
    Resources:
      CertificateManagerCertificate:
        Type: AWS::CertificateManager::Certificate
        Properties:
          DomainName: !Ref CustomDomainName
          DomainValidationOptions:
            - DomainName: !Ref CustomDomainName
              HostedZoneId: !Ref HostedZoneId
          ValidationMethod: DNS #カスタムドメインの検証はRoute53のDNSを使用する方法で実施する
    Outputs:
      Region:
        Value: !Ref AWS::Region
      AcmCertificateArn:
        Value: !Ref CertificateManagerCertificate #返却値はACM証明書のARN
    

    AWS CloudFormationテンプレート(基本認証用Lambda@Edge)

    テンプレート本体

    ファイル名:WebHostWebHostCFnBasicAuthLambdaEdge.yml

    AWSTemplateFormatVersion: '2010-09-09'
    Description: 'CFn Template for a stack that creates Lambda@Edge Version and AWS Secrets Manager Secret.'
    Parameters:
      CloudFrontDistId: #基本認証用Lambda@Edgeを呼び出すAmazon CloudFrontのDistribution ID。AWS Secrets ManagerのシークレットIDに使用する。
        Type: String    #※このCloudFormationテンプレートではAmazon CloudFrontとLambda@Edgeバージョンの関連付けはしないため注意。関連付けは呼出元で実施する想定。
      BasicAuthFuncName: #基本認証用Lambda@Edgeの関数名
        Type: String
      BasicAuthID: #AWS Secrets Managerシークレットに保管し、基本認証で使用するID。
        Type: String
      BasicAuthPW: #AWS Secrets Managerシークレットに保管し、基本認証で使用するパスワード。
        Type: String
    Resources:
      LambdaEdgeBasicAuth:
        Type: AWS::Lambda::Function
        DependsOn: 
          - SecretsManagerSecret
          - LambdaEdgeBasicAuthRole
        Properties:
          FunctionName: !Ref BasicAuthFuncName
          Runtime: python3.8
          MemorySize: 128 #Lambda@Edgeのクォータ最大値を設定
          Timeout: 5 #Lambda@Edgeのクォータ最大値を設定
          Role: !GetAtt LambdaEdgeBasicAuthRole.Arn
          Handler: index.lambda_handler
          Code:
            ZipFile: |
              # 基本認証を実施する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
              
      LambdaEdgeBasicAuthVersion: #呼出元スタックでAmazon CloudFrontと関連付けるためのLambdaEdgeバージョンを作成する
        Type: AWS::Lambda::Version
        DependsOn: 
          - LambdaEdgeBasicAuth
        Properties:
          FunctionName: !Ref LambdaEdgeBasicAuth
          Description: 'Basic Auth Lambda Edge for Amazon CloudFront'
      LambdaEdgeBasicAuthRole: #基本認証用Lambda@Edgeに適用するIAMロールとIAMポリシーの設定
        Type: AWS::IAM::Role
        Properties:
          RoleName: !Sub 'IAMRole-${BasicAuthFuncName}'
          Path: /
          AssumeRolePolicyDocument:
            Version: 2012-10-17
            Statement:
              - Effect: Allow
                Principal:
                  Service:
                    - edgelambda.amazonaws.com
                    - lambda.amazonaws.com
                Action:
                  - sts:AssumeRole
          ManagedPolicyArns:
            - arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole
          Policies:
          - PolicyName: !Sub 'IAMPolicy-${BasicAuthFuncName}'
            PolicyDocument:
              Version: '2012-10-17'
              Statement:
              - Effect: Allow
                Action:
                - iam:CreateServiceLinkedRole
                Resource:
                - '*'
              - Effect: Allow
                Action:
                - lambda:GetFunction
                - lambda:EnableReplication
                Resource:
                - !Sub 'arn:aws:lambda:us-east-1:${AWS::AccountId}:function:${BasicAuthFuncName}'
              - Effect: Allow
                Action:
                - cloudfront:UpdateDistribution
                Resource:
                - !Sub 'arn:aws:cloudfront::${AWS::AccountId}:distribution/${CloudFrontDistId}'
          - PolicyName: SecretsManagerGetSecretValue
            PolicyDocument:
              Version: '2012-10-17'
              Statement:
              - Effect: Allow
                Action:
                - secretsmanager:GetSecretValue
                Resource:
                - !Sub 'arn:aws:secretsmanager:us-east-1:${AWS::AccountId}:secret:CloudFrontBasicAuth/${CloudFrontDistId}/*'
      SecretsManagerSecret: #基本認証に使用するID、パスワードを格納するAWS Secrets Managerシークレットの作成
        Type: 'AWS::SecretsManager::Secret'
        Properties:
          Name: !Sub CloudFrontBasicAuth/${CloudFrontDistId}/${BasicAuthID}
          SecretString: !Sub '{"Password":"${BasicAuthPW}"}'
          Description: "SecretsManagerSecret of LambdaEdgeBasicAuth"
    Outputs:
      Region:
        Value:
          !Ref 'AWS::Region'
      LambdaFunctionArn:
        Value:
          !Ref LambdaEdgeBasicAuth #基本認証用Lambda@EdgeのARN
      LambdaFunctionVersionArn:
        Value:
          !Ref LambdaEdgeBasicAuthVersion #基本認証用Lambda@EdgeバージョンのARN
      SecretsManagerSecretArn:
        Value:
          !Ref SecretsManagerSecret #基本認証情報を格納したAWS Secrets ManagerシークレットのARN
          
    

    AWS CloudFormationテンプレート(IP制限用AWS WAF)

    テンプレート本体

    ファイル名:WebHostCFnWAFWebACL.yml

    AWSTemplateFormatVersion: '2010-09-09'
    Description: 'CFn Template for a stack that creates AWS WAF WebACL.'
    Parameters:
      ResourcePrefix: #AWS WAFのリソースにつけるPrefix
        Type: String
        Default: IPRestrictions
      AllowIPList: #IP制限をするCIDR
        #Type: CommaDelimitedList
        Type: String
        Default: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
    Conditions:
      IsAllowIPList:
        !Not [!Equals [!Ref AllowIPList, ""]]
    Resources:
      WAFv2WebACL:
        Type: AWS::WAFv2::WebACL
        Properties:
          Name: !Sub "${ResourcePrefix}-WebACL"
          Scope: CLOUDFRONT
          DefaultAction:
            Block: {}
          VisibilityConfig:
            SampledRequestsEnabled: true
            CloudWatchMetricsEnabled: true
            MetricName: !Sub "${ResourcePrefix}-WebACL-Metric"
          Rules:
            - Name: !Sub "${ResourcePrefix}-WebACL-Rule"
              Action:
                Allow: {}
              Priority: 0
              Statement:
                IPSetReferenceStatement:
                  Arn: !GetAtt WAFv2IPSet.Arn
              VisibilityConfig:
                SampledRequestsEnabled: true
                CloudWatchMetricsEnabled: true
                MetricName: !Sub "${ResourcePrefix}-WebACL-Rule-Metric"
      WAFv2IPSet:
        Type: AWS::WAFv2::IPSet
        Properties:
          Name: !Sub "${ResourcePrefix}-IPSet"
          Scope: CLOUDFRONT
          IPAddressVersion: IPV4
          Addresses: !If [IsAllowIPList, !Split [ ",", !Ref AllowIPList ], []]
    Outputs:
      Region:
        Value: !Ref AWS::Region
      WAFv2WebACLArn:
        Value: !GetAtt WAFv2WebACL.Arn #返却値はWAF WebACLのARN
    

    構築手順

    1. 呼び出し元リージョンで「WebHostCFnCustomResourceToDeployAWS CloudFormationStack.yml」を使用してAWS CloudFormationスタックを作成し、AWS Lambdaカスタムリソースをデプロイする
      us-east-1リージョンに「AWS Certificate Manager(ACM)証明書」、「基本認証をするAWS Lambda@Edgeとそのバージョン」、「IP制限をするAWS WAF Web ACL」を作成するためのAWS Lambdaカスタムリソースを呼び出し元リージョンへ予めデプロイしておく。
    2. 「AWS Certificate Manager(ACM)証明書」、「基本認証をするAWS Lambda@Edgeとそのバージョン」、「IP制限をするAWS WAF Web ACL」を作成するAWS CloudFormationテンプレートファイルを任意のAmazon S3バケットに置く
      「WebHostCFnACMCertificate.yml」「WebHostCFnBasicAuthLambdaEdge.yml」「WebHostCFnWAFWebACL.yml」を作成するAWS CloudFormationテンプレートをAmazon S3バケット(前述のパラメータ例では「h-o2k」)に予め配置しておく。
    3. 呼出元AWS CloudFormationテンプレートファイル「WebHostCFnS3CloudFrontWithAcmLambdaEdgeWaf.yml 」をAWS CloudFormationで実行する
      呼出元AWS CloudFormationテンプレートをAWSマネジメントコンソールなどからパラメータを入力の上でAWS CloudFormationスタックとして実行する(CloudFrontDistributionIDは入力しない)。
    4. 静的ウェブサイトホスティングに使用するS3バケットにコンテンツを追加する
      静的ウェブサイトホスティング用S3バケット(前述のパラメータ例では「cfn-acm-edge-waf-cfnt-s3-20230311144618-dev」)にindex.htmlなどコンテンツを追加する。
      今回の例では「I will always remember that day and all of you.」とだけ表示されるindex.htmlを追加しました。
    5. 初回実行(Create処理でAmazon CloudFrontが作成され、Distribution IDが発行される。カスタムリソースによる「基本認証をするAWS Lambda@Edgeとそのバージョン」、「IP制限をするAWS WAF Web ACL」の作成は実行しない。)
      最初の呼出元AWS CloudFormationスタックの実行では「CloudFrontDistributionID」にAmazon CloudFrontのDistribution IDを入力しないことで、「基本認証をするAWS Lambda@Edgeとそのバージョン」、「IP制限をするAWS WAF Web ACL」を作成するAWS CloudFormationスタックの作成処理はスキップして、それ以外のリソースを作成します。

      ■初回実行時(Create)のパラメータ入力例
      ~省略~
      CloudFrontDistributionID: #初回時は入力しない(Distribution IDが発行されていないので入力できない)
      ~省略~
      
    6. 2回目実行(Update処理でAmazon CloudFrontのDistribution IDをカスタムリソースに渡し、「基本認証をするAWS Lambda@Edgeとそのバージョン」、「IP制限をするAWS WAF Web ACL」を作成する。)
      2回目の呼出元AWS CloudFormationスタックの実行では「CloudFrontDistributionID」に初回実行で発行されたAmazon CloudFrontのDistribution IDを入力することで、「基本認証をするAWS Lambda@Edgeとそのバージョン」、「IP制限をするAWS WAF Web ACL」を作成するAWS CloudFormationスタックの作成処理を実行して、これらのリソースと呼出元のAmazon CloudFrontを関連付けます。

      ■2回目実行時(Update)のパラメータ入力例
      #2回目実行時(Update)はAmazon CloudFrontのDistribution IDを含め、すべてのパラメータを入力
      ~省略~
      CloudFrontDistributionID: XXXXXXXXXXXXX
      ~省略~
      
    7. 結果確認
      正しく実行されていればパラメータで指定した許可IPアドレスからACM証明書を発行する対象ドメインにhttpsでアクセスすると基本認証のダイアログが表示され、パラメータで指定した基本認証のIDとパスワードで認証を通過します。
      許可IPアドレス以外からアクセスすると「403 ERROR」でアクセス拒否されます。
      正しく動作しない場合は呼出元AWS CloudFormationスタックのイベント内容、カスタムリソースでデプロイしたスタックのイベント内容、AWS LambdaカスタムリソースのAmazon CloudWatch Logsの内容、AWS CloudTrailのログから原因を特定して不具合を修正します。

    削除手順

    AWS CloudFormationではLambda@EdgeおよびAWS WAF Web ACLとAmazon CloudFrontの相互関係のある関連付け解除を順序制御することはしないので一度にすべてのスタックを削除することはできません。
    そのため、削除する場合は次の手順のように関連付けを解除した後、呼出元AWS CloudFormationスタックと「基本認証をするAWS Lambda@Edgeとそのバージョン」、「IP制限をするAWS WAF Web ACL」を作成したスタックをそれぞれ削除する必要があります。

    1. 呼出元AWS CloudFormationスタックのパラメータでAmazon CloudFrontのDistribution IDを空にしてUpdate処理をする
      呼出元AWS CloudFormationスタックのパラメータ「CloudFrontDistributionID」を空にしてスタックを更新する。
      (us-east-1の基本認証用Lambda@EdgeのスタックはバージョンでAmazon CloudFrontと関連付けがあるので失敗しますが、呼び出し元のスタックではAmazon CloudFrontとLambda@Edgeの関連付けが削除されます。)
    2. 呼出元AWS CloudFormationスタックを削除する
      呼出元AWS CloudFormationスタックと「基本認証をするAWS Lambda@Edgeとそのバージョン」、「IP制限をするAWS WAF Web ACL」のスタックの関連付けが解除されているため、呼出元AWS CloudFormationスタックが削除できる。
    3. us-east-1で基本認証用Lambda@Edgeバージョンを作成したAWS CloudFormationスタックを削除する
      呼出元AWS CloudFormationスタックと基本認証用Lambda@Edgeのスタックの関連付けが解除されているため、基本認証用Lambda@Edgeバージョンを作成したAWS CloudFormationスタックを単独で削除する。


    参考:
    Route 53 template snippets - AWS CloudFormation
    cfn-response module - AWS CloudFormation
    Tech Blog with related articles referenced

    まとめ

    今回は「AWS LambdaカスタムリソースでAWS CloudFormationスタックを別リージョンにデプロイする」の記事で説明したAWS CloudFormationスタックを別リージョンにデプロイするAWS Lambdaカスタムリソースを使用し、SSL/TLS証明書(AWS Certificate Manager)・基本認証(Lambda@Edge)・IP制限(AWS WAF)をそれぞれus-east-1で作成して、呼出元AWS CloudFormationスタックで作成したリソースと関連付ける例を紹介しました。
    今後もAWS CloudFormation、AWS Amplify、AWS CDKなどのデプロイ関連サービスや静的ウェブホスティングに関するアップデートについて様々なパターンを試してみたいと考えています。

    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 | 
    Link Kit | 小西 秀和 - Amazon著者ページ |