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:
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
- 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
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
-
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. -
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). -
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). -
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."
-
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--
-
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--
-
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.
-
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.)
-
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.
-
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.