hidekazu-konishi.com

AWS CloudFormation Templates and AWS Lambda Custom Resources for Associating AWS Certificate Manager, Lambda@Edge, and AWS WAF with a Website on Amazon S3 and Amazon CloudFront Cross-Region

First Published:
Last Updated:

In a previous article, I explained an implementation example of AWS Lambda custom resources that deploy AWS CloudFormation stacks cross-region.

Deploy AWS Cloudformation Stack Cross-Region with AWS Lambda Custom Resources

In this article, as a continuation, I use the AWS Lambda custom resources created in the previous article to associate SSL/TLS certificates (AWS Certificate Manager), basic authentication (Lambda@Edge), and IP restrictions (AWS WAF) with static website hosting using Amazon S3 and Amazon CloudFront cross-region.

* The source code published in this article and other articles by this author was developed as part of independent research and is provided 'as is' without any warranty of operability or fitness for a particular purpose. Please use it at your own risk. The code may be modified without prior notice.

Overview Diagram of the Configuration Tried in this Article

In this article, I am trying to create the configuration shown in the overview diagram in the following steps using AWS CloudFormation and custom resources. The reason for separating the processing into the initial creation and the second update is that the Amazon CloudFront Distribution ID can only be obtained after creating Amazon CloudFront, and Amazon CloudFront needs to be updated again for association after creating AWS resources that require the Distribution ID.

[Initial Creation]
  • Create the calling AWS CloudFormation stack with parameters without inputting the Amazon CloudFront Distribution ID
  • The calling AWS CloudFormation stack creates OriginAccessControl (OAC), Amazon S3 bucket, and Amazon CloudFront distribution in the deployed region
  • The calling AWS CloudFormation stack uses a custom resource to deploy the following to us-east-1 and returns the created results to the calling AWS CloudFormation stack
    • AWS Certificate Manager (ACM) certificate
  • The calling AWS CloudFormation stack creates resources in the calling region and associates the "AWS Certificate Manager (ACM) certificate" in the us-east-1 region with Amazon CloudFront using the return values from the custom resource
[Second Update]
  • Update the calling AWS CloudFormation stack with parameters by inputting the Amazon CloudFront Distribution ID
  • The calling AWS CloudFormation stack calls a custom resource to deploy the following to us-east-1 and returns the created results to the calling AWS CloudFormation stack
    • AWS WAF Web ACL for IP restrictions
    • AWS Lambda@Edge and its version for basic authentication
  • The calling AWS CloudFormation stack updates resources in the calling region to associate OriginAccessControl (OAC), and associates the "AWS Lambda@Edge and its version for basic authentication" and "AWS WAF Web ACL for IP restrictions" in the us-east-1 region with Amazon CloudFront using the return values from the custom resource

Examples of Stack Deployment and Association with SSL/TLS Certificate, Basic Authentication, and IP Restriction using AWS Lambda Custom Resources
Examples of Stack Deployment and Association with SSL/TLS Certificate, Basic Authentication, and IP Restriction using AWS Lambda Custom Resources

Setting Template Names

The AWS resources defined in the AWS CloudFormation template can have arbitrary file names as long as they can be uniquely identified and linked to the necessary ones, but for explanation purposes, the file names are set as follows.
  • AWS CloudFormation template that creates a static website using Amazon S3 and Amazon CloudFront in any region, and deploys SSL/TLS certificate (AWS Certificate Manager), basic authentication (Lambda@Edge), and IP restriction (AWS WAF) to us-east-1 using custom resources and associates them (calling template)
    File Name: WebHostCFnS3CloudFrontWithAcmLambdaEdgeWaf.yml
  • AWS CloudFormation template that creates a custom resource called from the calling template to deploy resources to us-east-1 using the AWS CloudFormation template stored in the specified Amazon S3 (custom resource creation)
    File Name: WebHostCFnCustomResourceToDeployAWS CloudFormationStack.yml
  • AWS CloudFormation template that is deployed from the custom resource and creates AWS Certificate Manager for SSL/TLS certificate in us-east-1 (AWS Certificate Manager for SSL/TLS certificate)
    File Name: WebHostWebHostCFnACMCertificate.yml
  • AWS CloudFormation template that is deployed from the custom resource and creates Lambda@Edge for basic authentication in us-east-1 (Lambda@Edge for basic authentication)
    File Name: WebHostWebHostCFnBasicAuthLambdaEdge.yml
  • AWS CloudFormation template that is deployed from the custom resource and creates AWS WAF for IP restriction in us-east-1 (AWS WAF for IP restriction)
    File Name: WebHostCFnWAFWebACL.yml

Examples of Each File and Parameters

I will provide examples of each file and parameter.
The input parameters, in particular, are examples to let you know the input format, so if you use them, you need to set each parameter according to your requirements.

AWS CloudFormation Template (for Custom Resource Creation)

For the AWS CloudFormation template for custom resource creation, please refer to the following article.

Deploy AWS Cloudformation Stack Cross-Region with AWS Lambda Custom Resources

Template Body

File Name: 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: |
          # ## Features of this custom resource
          # * Created a method that returns responses based on the custom resource specification on its own
          #   When called from a CloudFormation stack, cfnresponse.send can be used, but it cannot be used for standalone execution or testing of Lambda, so I created my own method called cfn_response_send.
          #   Reference: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/cfn-lambda-function-code-cfnresponsemodule.html
          # * Enabled selecting the template to deploy from S3 or hardcoding
          #   The method of obtaining the template from S3 allows various templates to be executed. On the other hand, if you want to specialize in executing a specific template, you can select hardcoding to prevent other templates from being executed.
          # * Simplified the specification of arguments for the CloudFormation template deployed from the custom resource
          #   By specifying keys not used as arguments for the CloudFormation template to deploy and passing the rest, the argument passing is made generic.
          # * Adjusted to a policy where resources are created even in situations different from the requested processing
          #   Instead of directly using the requested Create and Update processing for CloudFormation deployment, the policy is adjusted to create resources as much as possible, such as switching to Create processing when the stack does not exist in Update processing.
          # * Enabled selecting to skip processing
          #   It is possible to skip processing so that, for example, it can be used from Update processing while maintaining the dependency relationship in the calling side without using it in Create processing.

          import urllib3
          import json
          import boto3
          import botocore
          import datetime
          # import cfnresponse

          SUCCESS = 'SUCCESS'
          FAILED = 'FAILED'

          http = urllib3.PoolManager()

          # Keys that are not used as arguments for the CloudFormation stack to deploy, among the arguments received from the calling CloudFormation stack
          config = {
              "ExcludeKeys": ["ServiceToken", "StackName", "CfnTplS3Bucket", "CfnTplS3Key", "IsSkip"]
          }

          # If you want to directly describe the CloudFormation template to deploy in the code, describe it here
          fixed_cfn_template = '''
          '''

          # Client that deploys CloudFormation stack to us-east-1 region
          cfn_client = boto3.client('cloudformation', region_name='us-east-1')
          # S3 resource that obtains CloudFormation template
          s3_resource = boto3.resource('s3')

          # Custom method that returns responses to CloudFormation according to the custom resource specification
          # When called from a CloudFormation stack, cfnresponse.send can be used, but a custom method is used for standalone execution of 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')

                  # If 'true' is passed as IsSkip, return an empty response without any processing
                  # Used when skipping custom resource processing on the calling side
                  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:
                      # If the template is not directly described in the fixed_cfn_template variable, obtain the template from the object of the specified S3 bucket
                      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')
                          # In the case of Delete processing, simply delete
                          print('Run cfn_client.delete_stack.')
                          response = cfn_client.delete_stack(
                              StackName=stack_name
                          )

                          print('cfn_client.delete_stack response:')
                          print(response)

                          # Wait until the stack Delete processing is complete
                          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)
                          #Do not raise an exception to avoid deletion error loop between mutually associated resources.

                  if event['RequestType'] == 'Create' or event['RequestType'] == 'Update':
                      # Execution policy for Create and Update processing
                      # Create processing request and stack does not exist ⇒ Create processing
                      # Create processing request and stack exists ⇒ Do nothing
                      # Update processing request and stack does not exist ⇒ Create processing
                      # Update processing request and stack exists and there are changes ⇒ Update processing
                      # Update processing request and stack exists and there are no changes ⇒ Do nothing

                      # Flag indicating whether a stack with the same name exists
                      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)

                          # If the stack exists and describe_stacks is processed successfully, exist_stack is True
                          exist_stack = True
                      except (botocore.exceptions.ValidationError, botocore.exceptions.ClientError) as err:
                          err_msg = str(err)
                          print('err_msg:')
                          print(err_msg)
                          # If the stack does not exist and describe_stacks results in an error, exist_stack is False
                          if 'DescribeStack' in err_msg and 'does not exist' in err_msg:
                              print(('describe_stacks error: ' + err_msg))
                              exist_stack = False
                          else:
                              raise

                      # Flag indicating whether Create processing is required
                      need_create = True

                      if event['RequestType'] == 'Update':
                          print('Update Stacks')
                          if exist_stack == False:
                              # In the case of Update processing and the stack does not exist, create the stack with Create processing
                              need_create = True
                          else:
                              # Flag indicating whether Create processing is required
                              need_create = False
                              # If a stack with a matching name already exists, process with 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)

                                  # Wait until the stack Update processing is complete
                                  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)
                                  # If Update processing is requested but there are no changes, do nothing
                                  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')
                          # Perform stack Create processing if Create processing is requested and the stack does not exist, or if Update processing is requested and the stack does not exist
                          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)

                              # Wait until the stack Create processing is complete
                              waiter = cfn_client.get_waiter('stack_create_complete')
                              waiter.wait(StackName=stack_name)

                      # Obtain the created or updated stack
                      stacks = cfn_client.describe_stacks(StackName=stack_name)

                      # Obtain the contents of Outputs and format them as return values
                      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)))
                  # Return a response to the calling stack according to the CloudFormation custom resource specification
                  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)
                  # Return a response to the calling stack according to the CloudFormation custom resource specification
                  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 Template (Calling)

Input Parameter Examples

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-20240311144618
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

Template Body

File Name: WebHostCFnS3CloudFrontWithAcmLambdaEdgeWaf.yml
AWSTemplateFormatVersion: '2010-09-09'
Description: 'CFn Template for a stack that creates ACM, Lambda@Edge, WAF, and S3+CloudFront Hosting.'
#[Order of Creation]
#ACM Certificate[US] -> OriginAccessControl[JP] -> S3 Bucket Policy (create)[JP] -> S3 Bucket[JP] -> CloudFront (create)[JP] -> Lambda@Edge (create with CloudFrontDistributionID)[US]
# -> S3 Bucket Policy (update: set OCA with CloudFrontDistributionID)[JP] -> CloudFront (update: set Lambda@Edge)[JP]
Parameters:
  Env: #[Common] Environment name to add to the suffix of the S3 bucket to be created
    Type: String
    Default: dev
  S3BucketName: #[Common] S3 bucket name to use for static website hosting
    Type: String
    Default: cfn-acm-edge-waf-cfnt-s3-20240311144618
  S3BucketVersioningStatus: #[Common] Versioning setting for the S3 bucket used for static website hosting
    Type: String
    Default: Suspended
    AllowedValues:
    - Enabled
    - Suspended
  CustomResourceLambdaARN: #[Common] ARN of the AWS Lambda custom resource that deploys the AWS CloudFormation stack to us-east1 to create the SSL/TLS certificate (AWS Certificate Manager), basic authentication Lambda@Edge, and IP restriction AWS WAF
    Type: String
    Default: arn:aws:lambda:ap-northeast-1:XXXXXXXXXXXX:function:LambdaCustomResourceToDeployCFnStack
  ACMCFnStackName: #[For ACM] CloudFormation stack name to deploy to us-east1 to create the ACM certificate
    Type: String
    Default: WebHostCFnACMCertificate
  ACMS3BucketNameOfStoringTemplate: #[For ACM] Bucket name where the CloudFormation template to create the ACM certificate is stored
    Type: String
    Default: h-o2k
  ACMS3KeyOfStoringTemplate: #[For ACM] Object key after the bucket where the CloudFormation template to create the ACM certificate is stored
    Type: String
    Default: WebHostCFnACMCertificate.yml
  ACMCustomDomainName: #[For ACM] Custom domain name to issue the ACM certificate for
    Type: String
    Default: cfn-acm-edge-waf-cfnt-s3.h-o2k.com
  ACMHostedZoneId: #[For ACM] Route53 hosted zone ID that manages the custom domain name to issue the ACM certificate for
    Type: String
    Default: ZZZZZZZZZZZZZZZZZZZZZ
  WAFWebACLCFnStackName: #[For WAFWebACL] CloudFormation stack name to deploy to us-east1 to create the AWS WAF WebACL
    Type: String
    Default: WebHostCFnWAFWebACL
  WAFWebACLS3BucketNameOfStoringTemplate: #[For WAFWebACL] Bucket name where the CloudFormation template to create the AWS WAF WebACL is stored
    Type: String
    Default: h-o2k
  WAFWebACLS3KeyOfStoringTemplate: #[For WAFWebACL] Object key after the bucket where the CloudFormation template to create the AWS WAF WebACL is stored
    Type: String
    Default: WebHostCFnWAFWebACL.yml
  WAFWebACLResourcePrefix: #[For WAFWebACL] Prefix to add to the AWS WAF WebACL resources  
    Type: String
    Default: WebHostIPRestrictions
  WAFWebACLAllowIPList: #[For WAFWebACL] CIDR for IP restriction in AWS WAF WebACL rules
    #Type: CommaDelimitedList
    Type: String
    Default: "10.0.0.0/8,172.16.0.0/12,192.168.0.0/16"
  LambdaEdgeCFnStackName: #[For Lambda@Edge] CloudFormation stack name to deploy to us-east1 to create Lambda@Edge
    Type: String
    Default: WebHostCFnBasicAuthLambdaEdge
  LambdaEdgeS3BucketNameOfStoringTemplate: #[For Lambda@Edge] Bucket name where the CloudFormation template to create Lambda@Edge is stored
    Type: String
    Default: h-o2k
  LambdaEdgeS3KeyOfStoringTemplate: #[For Lambda@Edge] Object key after the bucket where the CloudFormation template to create Lambda@Edge is stored
    Type: String
    Default: WebHostCFnBasicAuthLambdaEdge.yml
  LambdaEdgeBasicAuthFuncName: #[For Lambda@Edge] Function name of Lambda@Edge for basic authentication
    Type: String
    Default: WebHostCFnBasicAuthLambdaEdge
  LambdaEdgeBasicAuthID: #[For Lambda@Edge] ID to authenticate with Lambda@Edge for basic authentication. Stored as an AWS Secrets Manager secret.
    Type: String
    Default: Iam
  LambdaEdgeBasicAuthPW: #[For Lambda@Edge] Password to authenticate with Lambda@Edge for basic authentication. Stored as an AWS Secrets Manager secret.
    Type: String
    Default: Nobody
  CloudFrontCachePolicyName: #[Common] Amazon CloudFront cache policy specification. Selected from managed policies.  
    Type: String
    Default: CachingDisabled
    AllowedValues:
      - NONE
      - Amplify
      - CachingDisabled
      - CachingOptimized
      - CachingOptimizedForUncompressedObjects
      - Elemental-MediaPackage
  CloudFrontOriginRequestPolicyName: #[Common] Amazon CloudFront origin request policy. Selected from managed policies.  
    Type: String
    Default: NONE
    AllowedValues:
      - NONE
      - AllViewer
      - AllViewerAndCloudFrontHeaders-2022-06
      - AllViewerExceptHostHeader
      - CORS-CustomOrigin
      - CORS-S3Origin
      - Elemental-MediaTailor-PersonalizedManifests
      - UserAgentRefererHeaders
  CloudFrontResponseHeaderPolicyName: #[Common] Amazon CloudFront response header policy. Selected from managed policies.  
    Type: String
    Default: CORS-with-preflight-and-SecurityHeadersPolicy
    AllowedValues:
      - NONE
      - SimpleCORS
      - CORS-With-Preflight
      - SecurityHeadersPolicy
      - CORS-and-SecurityHeadersPolicy
      - CORS-with-preflight-and-SecurityHeadersPolicy
  CloudFrontDistributionID: #[Common] Amazon CloudFront Distribution ID used for associating each resource.
    #Used for Amazon S3 bucket policy OriginAccessControl association, unique identification of AWS Secrets Manager secret used by Lambda@Edge, and AWS WAF association.
    Type: String #* Input during the second Update process after creating Amazon CloudFront with the initial Create process of the stack.
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: #If the input parameter contains the Amazon CloudFront Distribution ID, create Lambda@Edge as True, otherwise skip Lambda@Edge creation as False.
    !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
  #Route 53 record set that registers CloudFront as an alias record in the Route 53 hosted zone
  Route53RecordSetGroup:
    Type: AWS::Route53::RecordSetGroup
    DependsOn:
      - CloudFrontDistribution
    Properties:
      HostedZoneId:
        !Ref ACMHostedZoneId
      RecordSets:
        - Name: !Ref ACMCustomDomainName
          Type: A
          #When registering CloudFront as an alias record, the HostedZoneId of the alias target becomes the following fixed value
          #Reference: https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/quickref-route53.html
          AliasTarget:
            HostedZoneId: Z2FDTNDATAQYW2
            DNSName: !GetAtt CloudFrontDistribution.DomainName
  #Custom resource called to issue the ACM certificate
  CustomResourceForACM:
    Type: Custom::CustomResourceForACM
    Properties:
      ServiceToken: !Ref CustomResourceLambdaARN
      StackName: !Ref ACMCFnStackName
      CfnTplS3Bucket: !Ref ACMS3BucketNameOfStoringTemplate
      CfnTplS3Key: !Ref ACMS3KeyOfStoringTemplate
      CustomDomainName: !Ref ACMCustomDomainName
      HostedZoneId: !Ref ACMHostedZoneId
  #Custom resource called to create 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: #* If the input parameter does not contain the Amazon CloudFront Distribution ID, send IsSkip:true to the custom resource and skip the AWS WAF WebACL creation process.
        !If [IsCloudFrontDistributionID, "false", "true"]
  #Custom resource called to create 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: #* If the input parameter does not contain the Amazon CloudFront Distribution ID, send IsSkip:true to the custom resource and skip the Lambda@Edge creation process.
        !If [IsCloudFrontDistributionID, "false", "true"]
  CloudFrontDistribution:
    Type: AWS::CloudFront::Distribution
    DependsOn:
      #Add the custom resource to DependsOn so that CloudFront is associated after OriginAccessControl is created
      - OriginAccessControl
      #Add the custom resource to DependsOn so that CloudFront is associated after the Amazon S3 bucket is created
      - S3Bucket
      #Add the custom resource to DependsOn so that CloudFront is associated after the ACM certificate is issued by the custom resource
      - CustomResourceForACM
      #Add the custom resource to DependsOn so that CloudFront is associated after the AWS WAF WebACL is created by the custom resource
      - CustomResourceForWAFWebACL
      #Add the custom resource to DependsOn so that CloudFront is associated after the basic authentication Lambda@Edge version is created by the custom resource
      - CustomResourceForLambdaEdge
    Properties:
      DistributionConfig:
        #Set the domain name for which the ACM certificate was issued as the CloudFront alias
        Aliases:
          - !Ref ACMCustomDomainName
        #Set the ACM certificate created by the custom resource to CloudFront
        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 #Specify the origin as the target
          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 Template (AWS Certificate Manager for SSL/TLS Certificate)

Template Body

File Name: WebHostWebHostCFnACMCertificate.yml
AWSTemplateFormatVersion: "2010-09-09"
Description: "CFn Template for a stack that creates AWS CertificateManager Certificate."
Parameters:
  CustomDomainName: #Custom domain name to issue the ACM certificate for
    Type: String
  HostedZoneId: #Amazon Route53 hosted zone ID that manages the custom domain to issue the ACM certificate for
    Type: String
Resources:
  CertificateManagerCertificate:
    Type: AWS::CertificateManager::Certificate
    Properties:
      DomainName: !Ref CustomDomainName
      DomainValidationOptions:
        - DomainName: !Ref CustomDomainName
          HostedZoneId: !Ref HostedZoneId
      ValidationMethod: DNS #Perform custom domain validation using the Route53 DNS method
Outputs:
  Region:
    Value: !Ref AWS::Region
  AcmCertificateArn:
    Value: !Ref CertificateManagerCertificate #Returned value is the ARN of the ACM certificate

AWS CloudFormation Template (Lambda@Edge for Basic Authentication)

Template Body

File Name: WebHostWebHostCFnBasicAuthLambdaEdge.yml
AWSTemplateFormatVersion: '2010-09-09'
Description: 'CFn Template for a stack that creates Lambda@Edge Version and AWS Secrets Manager Secret.'
Parameters:
  CloudFrontDistId: #Amazon CloudFront Distribution ID that calls the basic authentication Lambda@Edge. Used for AWS Secrets Manager secret ID.
    Type: String    #* Note that this CloudFormation template does not associate Amazon CloudFront with the Lambda@Edge version. The association is assumed to be done in the calling template.
  BasicAuthFuncName: #Function name of Lambda@Edge for basic authentication
    Type: String
  BasicAuthID: #ID stored in AWS Secrets Manager secret and used for basic authentication.
    Type: String
  BasicAuthPW: #Password stored in AWS Secrets Manager secret and used for basic authentication.
    Type: String
Resources:
  LambdaEdgeBasicAuth:
    Type: AWS::Lambda::Function
    DependsOn: 
      - SecretsManagerSecret
      - LambdaEdgeBasicAuthRole
    Properties:
      FunctionName: !Ref BasicAuthFuncName
      Runtime: python3.8
      MemorySize: 128 #Set the Lambda@Edge quota maximum value
      Timeout: 5 #Set the Lambda@Edge quota maximum value
      Role: !GetAtt LambdaEdgeBasicAuthRole.Arn
      Handler: index.lambda_handler
      Code:
        ZipFile: |
          # Lambda@Edge function that performs basic authentication
          import json
          import boto3
          import base64
          # Create a client to handle AWS Secrets Manager, which stores the ID and password used for authentication
          asm_client = boto3.client('secretsmanager', region_name='us-east-1')
          # Define the response for errors
          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)
                  # Obtain the CloudFront request from the event
                  request = event['Records'][0]['cf']['request']
                  # Obtain the CloudFront Distribution ID from the event
                  cf_dist_id = event['Records'][0]['cf']['config']['distributionId']
                  # Obtain the headers from the request
                  headers = request['headers']
                  if (headers.get('authorization') != None):
                      # If the headers contain authorization, attempt authentication
                      # The content of headers['authorization'][0]['value'] is "Basic "
                      # Decode the authorization content to extract the ID and password
                      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]
          
                      # Obtain the secret using the obtained CloudFront Distribution ID and ID entered for basic authentication
                      response = asm_client.get_secret_value(
                          SecretId='CloudFrontBasicAuth/' +
                          cf_dist_id + '/' + str(target_id)
                      )
                      # If the secret can be obtained and the stored string matches the password entered for basic authentication, consider authentication successful and return the request.
                      # Otherwise, return an error response.
                      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: #Create a LambdaEdge version to associate with Amazon CloudFront in the calling stack
    Type: AWS::Lambda::Version
    DependsOn: 
      - LambdaEdgeBasicAuth
    Properties:
      FunctionName: !Ref LambdaEdgeBasicAuth
      Description: 'Basic Auth Lambda Edge for Amazon CloudFront'
  LambdaEdgeBasicAuthRole: #IAM role and IAM policy settings applied to the basic authentication Lambda@Edge
    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: #Create an AWS Secrets Manager secret that stores the ID and password used for basic authentication
    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 #ARN of the basic authentication Lambda@Edge
  LambdaFunctionVersionArn:
    Value:
      !Ref LambdaEdgeBasicAuthVersion #ARN of the basic authentication Lambda@Edge version
  SecretsManagerSecretArn:
    Value:
      !Ref SecretsManagerSecret #ARN of the AWS Secrets Manager secret that stores the basic authentication information
      

AWS CloudFormation Template (AWS WAF for IP Restriction)

Template Body

File Name: WebHostCFnWAFWebACL.yml
AWSTemplateFormatVersion: '2010-09-09'
Description: 'CFn Template for a stack that creates AWS WAF WebACL.'
Parameters:
  ResourcePrefix: #Prefix added to AWS WAF resources
    Type: String
    Default: IPRestrictions
  AllowIPList: #CIDR for IP restriction
    #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 #Returned value is the ARN of the WAF WebACL

Build Procedure

  1. Create an AWS CloudFormation stack in the calling region using "WebHostCFnCustomResourceToDeployAWS CloudFormationStack.yml" and deploy the AWS Lambda custom resource
    Pre-deploy the AWS Lambda custom resource that creates the AWS CloudFormation stack to deploy the "AWS Certificate Manager (ACM) certificate", "AWS Lambda@Edge and its version for basic authentication", and "AWS WAF Web ACL for IP restriction" to us-east-1 in the calling region.
  2. Place the AWS CloudFormation template files that create the "AWS Certificate Manager (ACM) certificate", "AWS Lambda@Edge and its version for basic authentication", and "AWS WAF Web ACL for IP restriction" in any Amazon S3 bucket
    Pre-place the "WebHostCFnACMCertificate.yml", "WebHostCFnBasicAuthLambdaEdge.yml", and "WebHostCFnWAFWebACL.yml" AWS CloudFormation templates to create in the Amazon S3 bucket (the "h-o2k" bucket in the above parameter example).
  3. Execute the calling AWS CloudFormation template file "WebHostCFnS3CloudFrontWithAcmLambdaEdgeWaf.yml" with AWS CloudFormation
    Execute the calling AWS CloudFormation template as an AWS CloudFormation stack by inputting parameters from the AWS Management Console, etc. (do not input CloudFrontDistributionID).
  4. Add content to the S3 bucket used for static website hosting
    Add content such as index.html to the S3 bucket for static website hosting (the "cfn-acm-edge-waf-cfnt-s3-20240311144618-dev" bucket in the above parameter example).
    In this example, I added an index.html that simply displays "I will always remember that day and all of you."
  5. Initial execution (Amazon CloudFront is created with Create process and Distribution ID is issued. Creation of "AWS Lambda@Edge and its version for basic authentication" and "AWS WAF Web ACL for IP restriction" by custom resources is not executed.)
    In the initial execution of the calling AWS CloudFormation stack, by not inputting the Amazon CloudFront Distribution ID in "CloudFrontDistributionID", the creation process of the AWS CloudFormation stacks that create "AWS Lambda@Edge and its version for basic authentication" and "AWS WAF Web ACL for IP restriction" is skipped, and other resources are created.

    [Example parameter input for initial execution (Create)]
    --omitted--
    CloudFrontDistributionID: #Do not input for the initial execution (cannot input because the Distribution ID has not been issued)
    --omitted--
    
  6. Second execution (Update process to pass the Amazon CloudFront Distribution ID to the custom resource and create "AWS Lambda@Edge and its version for basic authentication" and "AWS WAF Web ACL for IP restriction".)
    In the second execution of the calling AWS CloudFormation stack, by inputting the Amazon CloudFront Distribution ID issued in the initial execution in "CloudFrontDistributionID", the creation process of the AWS CloudFormation stacks that create "AWS Lambda@Edge and its version for basic authentication" and "AWS WAF Web ACL for IP restriction" is executed, and these resources are associated with the calling Amazon CloudFront.

    [Example parameter input for second execution (Update)]
    #For the second execution (Update), input all parameters including the Amazon CloudFront Distribution ID
    --omitted--
    CloudFrontDistributionID: XXXXXXXXXXXXX
    --omitted--
    
  7. Result confirmation
    If executed correctly, accessing the domain for which the ACM certificate was issued via https from the allowed IP address specified in the parameter displays the basic authentication dialog, and authentication passes with the ID and password for basic authentication specified in the parameter.
    Access from IP addresses other than the allowed ones will be denied with "403 ERROR".
    If it does not work correctly, identify the cause and fix the issue from the event contents of the calling AWS CloudFormation stack, the event contents of the stack deployed by the custom resource, the contents of AWS Lambda custom resource Amazon CloudWatch Logs, and AWS CloudTrail logs.

Deletion Procedure

AWS CloudFormation does not control the order of removing the association between Lambda@Edge, AWS WAF Web ACL, and Amazon CloudFront, so all stacks cannot be deleted at once.
Therefore, when deleting, it is necessary to remove the association as shown in the following procedure, and then delete the calling AWS CloudFormation stack and the stacks that created "AWS Lambda@Edge and its version for basic authentication" and "AWS WAF Web ACL for IP restriction" separately.

  1. Update the calling AWS CloudFormation stack by setting the Amazon CloudFront Distribution ID parameter to empty
    Update the stack by setting the parameter "CloudFrontDistributionID" of the calling AWS CloudFormation stack to empty.
    (The stack for the basic authentication Lambda@Edge in us-east-1 will fail because the version is associated with Amazon CloudFront, but the association between Amazon CloudFront and Lambda@Edge will be removed in the calling stack.)
  2. Delete the calling AWS CloudFormation stack
    The calling AWS CloudFormation stack can be deleted because the association with the stacks for "AWS Lambda@Edge and its version for basic authentication" and "AWS WAF Web ACL for IP restriction" has been removed.
  3. Delete the AWS CloudFormation stack that created the basic authentication Lambda@Edge version in us-east-1
    Since the association between the calling AWS CloudFormation stack and the basic authentication Lambda@Edge stack has been removed, delete the AWS CloudFormation stack that created the basic authentication Lambda@Edge version individually.

References:
Tech Blog with curated related content
Route 53 template snippets - AWS CloudFormation
cfn-response module - AWS CloudFormation

Summary

In this article, I introduced an example of using the AWS Lambda custom resource to deploy AWS CloudFormation stacks cross-region, which was explained in the article "Deploy AWS Cloudformation Stack Cross-Region with AWS Lambda Custom Resources", to create SSL/TLS certificate (AWS Certificate Manager), basic authentication (Lambda@Edge), and IP restriction (AWS WAF) each in us-east-1 and associate them with resources created in the calling AWS CloudFormation stack.
In the future, I would like to continue trying various patterns regarding deployment-related services such as AWS CloudFormation, AWS Amplify, AWS CDK, and updates related to static web hosting.

Written by Hidekazu Konishi


Copyright © Hidekazu Konishi ( hidekazu-konishi.com ) All Rights Reserved.