--- ## This is a sample template for demonstration purposes. ## Edit according to your requirements prior to deployment. ## It creates a CloudFront distribution with a test website. ## The website is a simple "Hello World" message coming from a Lambda function. The Lambda function is integrated with API Gateway (HTTP Type). The API Gateway is set as origin for CloudFront AWSTemplateFormatVersion: '2010-09-09' Transform: 'AWS::SecretsManager-2020-07-23' Description: CloudFront - API Gateway - Lambda - Secrets Manager - Demo Parameters: RotateInterval: Default: "7" Description: Rotation interval in days for origin secret value. Type: Number MinValue: "1" LogRetention: Default: "30" Description: Log retention period for HTTP API access logs Type: Number MinValue: "1" Resources: # Sample Lambda function. This should be replaced with the application that is integrated with API Gateway sampleWebsiteLambdaFunction: Type: AWS::Lambda::Function Properties: Description: "Sample Hello World Lambda" Runtime: python3.7 Handler: index.lambda_handler Role: !GetAtt sampleWebsiteLambdaFunctionRole.Arn Code: ZipFile: | import json def lambda_handler(event, context): return { 'statusCode': 200, 'body': json.dumps('Sample Website') } # IAM Role for Sample Website Lambda Function sampleWebsiteLambdaFunctionRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Principal: Service: - lambda.amazonaws.com Action: - sts:AssumeRole Path: "/" Policies: - PolicyName: "SampleWebsiteLambdaPolicy" PolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Action: - logs:CreateLogStream - logs:CreateLogGroup Resource: !Sub 'arn:${AWS::Partition}:logs:*:${AWS::AccountId}:log-group:*' - Effect: Allow Action: - logs:PutLogEvents Resource: - !Sub 'arn:${AWS::Partition}:logs:*:${AWS::AccountId}:log-group:*:log-stream:*' # Secret to store custom header OriginVerifySecret: Type: 'AWS::SecretsManager::Secret' Properties: Name: OriginVerifySecret GenerateSecretString: SecretStringTemplate: '{"HEADERVALUE": "RandomPassword"}' GenerateStringKey: "HEADERVALUE" ExcludePunctuation: true # Permission for Secrets Manager to invoke secret rotation Lambda function RotateFunctionInvokePermission: Type: AWS::Lambda::Permission Properties: FunctionName: !Ref OriginSecretRotateFunction Action: lambda:InvokeFunction Principal: 'secretsmanager.amazonaws.com' # Enabling Secret Rotation OriginVerifyRotateSchedule: Type: AWS::SecretsManager::RotationSchedule Properties: RotationLambdaARN: !GetAtt OriginSecretRotateFunction.Arn RotationRules: AutomaticallyAfterDays: !Ref RotateInterval SecretId: !Ref OriginVerifySecret # IAM Role for the Origin Secret Rotation Lambda Function. Permissions for Secrets Manager and CloudFront are needed OriginSecretRotateExecutionRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Principal: Service: lambda.amazonaws.com Action: sts:AssumeRole Policies: - PolicyName: OriginVerifyRotatePolicy PolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Action: - logs:CreateLogStream - logs:CreateLogGroup Resource: !Sub 'arn:${AWS::Partition}:logs:*:${AWS::AccountId}:log-group:*' - Effect: Allow Action: - logs:PutLogEvents Resource: - !Sub 'arn:${AWS::Partition}:logs:*:${AWS::AccountId}:log-group:*:log-stream:*' # Lambda needs the following permissions to be able to get secret and rotate it - Effect: Allow Action: - secretsmanager:DescribeSecret - secretsmanager:GetSecretValue - secretsmanager:PutSecretValue - secretsmanager:UpdateSecretVersionStage Resource: !Ref OriginVerifySecret - Effect: Allow Action: - secretsmanager:GetRandomPassword Resource: '*' # Lambda needs cloudfront permissions as it will update the origin for API Gateway with the rotated secret value - Effect: Allow Action: - cloudfront:GetDistribution - cloudfront:GetDistributionConfig - cloudfront:ListDistributions - cloudfront:UpdateDistribution Resource: !Sub 'arn:${AWS::Partition}:cloudfront::${AWS::AccountId}:distribution/${cloudFrontDistribution}' # Lambda function which will rotate the secret. It will also update CloudFront with the rotated secret value OriginSecretRotateFunction: Type: AWS::Lambda::Function Properties: Description: Secrets Manager Rotation Lambda Handler: index.lambda_handler Runtime: python3.7 Environment: Variables: CFDISTROID: !Ref cloudFrontDistribution HEADERNAME: "x-origin-verify" ORIGINURL: !Sub "${apiGateway.ApiEndpoint}" Role: !GetAtt OriginSecretRotateExecutionRole.Arn Code: ZipFile: |- from __future__ import print_function import json import re import os import boto3 import logging import requests import time from botocore.exceptions import ClientError logger = logging.getLogger() logger.setLevel(logging.INFO) #====================================================================================================================== # Variables #====================================================================================================================== CFDistroId = os.environ['CFDISTROID'] HeaderName = os.environ['HEADERNAME'] OriginUrl = os.environ['ORIGINURL'] #StackName = os.environ['STACKNAME'] #====================================================================================================================== # Helpers #====================================================================================================================== def get_cfdistro(distroid): client = boto3.client('cloudfront') response = client.get_distribution( Id = distroid ) return response def get_cfdistro_config(distroid): client = boto3.client('cloudfront') response = client.get_distribution_config( Id = distroid ) return response def update_cfdistro(distroid, headervalue): client = boto3.client('cloudfront') diststatus = get_cfdistro(distroid) if 'Deployed' in diststatus['Distribution']['Status']: distconfig = get_cfdistro_config(distroid) headercount = 0 #logger.info(distconfig) for k in distconfig['DistributionConfig']['Origins']['Items']: if k['CustomHeaders']['Quantity'] > 0: for h in k['CustomHeaders']['Items']: if HeaderName in h['HeaderName']: logger.info("Update custom header, %s for origin, %s." % (h['HeaderName'], k['Id'])) headercount = headercount + 1 h['HeaderValue'] = headervalue else: logger.info("Ignore custom header, %s for origin, %s." % (h['HeaderName'], k['Id'])) pass else: logger.info("No custom headers found in origin, %s." % k['Id']) pass if headercount < 1: logger.error("No custom header, %s found in distribution Id, %s." % (HeaderName, distroid)) raise ValueError("No custom header found in distribution Id, %s." % distroid) else: response = client.update_distribution( Id = distroid, IfMatch = distconfig['ResponseMetadata']['HTTPHeaders']['etag'], DistributionConfig = distconfig['DistributionConfig'] ) return response else: logger.error("Distribution Id, %s status is not Deployed." % distroid) raise ValueError("Distribution Id, %s status is not Deployed." % distroid) def test_origin(url, secret): response = requests.get( url, headers={HeaderName: secret}, ) logger.info("Testing URL, %s - response code, %s " % (url, response.status_code)) if response.status_code == 200: return True else: return False def create_secret(service_client, arn, token): """Create the secret This method first checks for the existence of a secret for the passed in token. If one does not exist, it will generate a new secret and put it with the passed in token. Args: service_client (client): The secrets manager service client arn (string): The secret ARN or other identifier token (string): The ClientRequestToken associated with the secret version Raises: ResourceNotFoundException: If the secret with the specified arn and stage does not exist """ # Make sure the current secret exists service_client.get_secret_value( SecretId=arn, VersionStage="AWSCURRENT" ) # Now try to get the secret version, if that fails, put a new secret try: service_client.get_secret_value( SecretId=arn, VersionId=token, VersionStage="AWSPENDING" ) logger.info("createSecret: Successfully retrieved secret for %s." % arn) except service_client.exceptions.ResourceNotFoundException: # Generate a random password passwd = service_client.get_random_password( ExcludePunctuation = True ) # Put the secret service_client.put_secret_value( SecretId=arn, ClientRequestToken=token, SecretString='{\"HEADERVALUE\":\"%s\"}' % passwd['RandomPassword'], VersionStages=['AWSPENDING']) logger.info("createSecret: Successfully put secret for ARN %s and version %s." % (arn, token)) def set_secret(service_client, arn, token): """Set the secret This method should set the AWSPENDING secret in the service that the secret belongs to. For example, if the secret is a database credential, this method should take the value of the AWSPENDING secret and set the user's password to this value in the database. Args: service_client (client): The secrets manager service client arn (string): The secret ARN or other identifier token (string): The ClientRequestToken associated with the secret version """ # This is where the secret should be set in the service # First check to confirm CloudFront distribution is in Deployed state diststatus = get_cfdistro(CFDistroId) if 'Deployed' not in diststatus['Distribution']['Status']: logger.error("Distribution Id, %s status is not Deployed." % CFDistroId) raise ValueError("Distribution Id, %s status is not Deployed." % CFDistroId) # Obtain secret value for AWSPENDING pending = service_client.get_secret_value( SecretId=arn, VersionId=token, VersionStage="AWSPENDING" ) # Obtain secret value for AWSCURRENT metadata = service_client.describe_secret(SecretId=arn) for version in metadata["VersionIdsToStages"]: logger.info("Getting current version %s for %s" % (version, arn)) if "AWSCURRENT" in metadata["VersionIdsToStages"][version]: currenttoken = version current = service_client.get_secret_value( SecretId=arn, VersionId=currenttoken, VersionStage="AWSCURRENT" ) pendingsecret = json.loads(pending['SecretString']) currentsecret = json.loads(current['SecretString']) # Update CloudFront custom header with AWSPENDING and AWSCURRENT try: update_cfdistro(CFDistroId, pendingsecret['HEADERVALUE']) except ClientError as e: logger.error('Error: {}'.format(e)) raise ValueError("Failed to update resources CloudFront Distro Id %s " % (CFDistroId)) def test_secret(service_client, arn, token): """Test the secret This method should validate that the AWSPENDING secret works in the service that the secret belongs to. For example, if the secret is a database credential, this method should validate that the user can login with the password in AWSPENDING and that the user has all of the expected permissions against the database. Args: service_client (client): The secrets manager service client arn (string): The secret ARN or other identifier token (string): The ClientRequestToken associated with the secret version """ # This is where the secret should be tested against the service # Obtain secret value for AWSPENDING pending = service_client.get_secret_value( SecretId=arn, VersionId=token, VersionStage="AWSPENDING" ) # Obtain secret value for AWSCURRENT metadata = service_client.describe_secret(SecretId=arn) for version in metadata["VersionIdsToStages"]: if "AWSCURRENT" in metadata["VersionIdsToStages"][version]: currenttoken = version current = service_client.get_secret_value( SecretId=arn, VersionId=currenttoken, VersionStage="AWSCURRENT" ) logger.info("Getting current version %s for %s" % (version, arn)) pendingsecret = json.loads(pending['SecretString']) currentsecret = json.loads(current['SecretString']) secrets = [pendingsecret['HEADERVALUE'], currentsecret['HEADERVALUE']] # Test origin URL access functional using validation headers for AWSPENDING and AWSCURRENT try: for s in secrets: if test_origin(OriginUrl, s): pass else: logger.error("Tests failed for URL, %s " % OriginUrl) raise ValueError("Tests failed for URL, %s " % OriginUrl) except ClientError as e: logger.error('Error: {}'.format(e)) raise e def finish_secret(service_client, arn, token): """Finish the secret This method finalizes the rotation process by marking the secret version passed in as the AWSCURRENT secret. Args: service_client (client): The secrets manager service client arn (string): The secret ARN or other identifier token (string): The ClientRequestToken associated with the secret version Raises: ResourceNotFoundException: If the secret with the specified arn does not exist """ # First describe the secret to get the current version metadata = service_client.describe_secret(SecretId=arn) current_version = None for version in metadata["VersionIdsToStages"]: if "AWSCURRENT" in metadata["VersionIdsToStages"][version]: if version == token: # The correct version is already marked as current, return logger.info("finishSecret: Version %s already marked as AWSCURRENT for %s" % (version, arn)) return current_version = version break # Finalize by staging the secret version current service_client.update_secret_version_stage(SecretId=arn, VersionStage="AWSCURRENT", MoveToVersionId=token, RemoveFromVersionId=current_version) logger.info("finishSecret: Successfully set AWSCURRENT stage to version %s for secret %s." % (token, arn)) #====================================================================================================================== # Lambda entry point #====================================================================================================================== def lambda_handler(event, context): print(event) logger.info("log -- Event: %s " % json.dumps(event)) arn = event['SecretId'] token = event['ClientRequestToken'] step = event['Step'] service_client = boto3.client('secretsmanager') # Make sure the version is staged correctly metadata = service_client.describe_secret(SecretId=arn) if not metadata['RotationEnabled']: logger.error("Secret %s is not enabled for rotation" % arn) raise ValueError("Secret %s is not enabled for rotation" % arn) versions = metadata['VersionIdsToStages'] if token not in versions: logger.error("Secret version %s has no stage for rotation of secret %s." % (token, arn)) raise ValueError("Secret version %s has no stage for rotation of secret %s." % (token, arn)) if "AWSCURRENT" in versions[token]: logger.info("Secret version %s already set as AWSCURRENT for secret %s." % (token, arn)) return elif "AWSPENDING" not in versions[token]: logger.error("Secret version %s not set as AWSPENDING for rotation of secret %s." % (token, arn)) raise ValueError("Secret version %s not set as AWSPENDING for rotation of secret %s." % (token, arn)) if step == "createSecret": create_secret(service_client, arn, token) elif step == "setSecret": set_secret(service_client, arn, token) elif step == "testSecret": test_secret(service_client, arn, token) elif step == "finishSecret": finish_secret(service_client, arn, token) else: raise ValueError("Invalid step parameter") # This Lambda function is used to authorize the requests sent to API Gateway. It check the value of custom header x-origin-verify. # The request is authorized if the value matches the value stored in the Secret Manager authorizerLambda: Type: AWS::Lambda::Function Properties: Description: "Authorizer Lambda Function" Runtime: python3.7 # Timeout has to be atleast 30 sec Timeout: 900 Handler: index.lambda_handler Role: !GetAtt authorizerLambdaFunctionRole.Arn Code: ZipFile: !Sub - |- import json import boto3 import base64 from botocore.exceptions import ClientError region_name = '${region}' # Create a Secrets Manager client session = boto3.session.Session() client = session.client( service_name='secretsmanager', region_name=region_name ) def lambda_handler(event, context): secretName = 'OriginVerifySecret' secretValue='' try: get_secret_value_response = client.get_secret_value(SecretId=secretName) get_pending_secret_value_response = "" try: get_pending_secret_value_response = client.get_secret_value(SecretId=secretName,VersionStage='AWSPENDING') except ClientError as e: print (e.response["Error"]["Code"]) secret_HeaderValue = json.loads(get_secret_value_response['SecretString'])['HEADERVALUE'] if get_pending_secret_value_response: pending_secret_HeaderValue = json.loads(get_pending_secret_value_response['SecretString'])['HEADERVALUE'] except ClientError as e: if e.response['Error']['Code'] == 'DecryptionFailureException': # Secrets Manager can't decrypt the protected secret text using the provided KMS key. # Deal with the exception here, and/or rethrow at your discretion. raise e elif e.response['Error']['Code'] == 'InternalServiceErrorException': # An error occurred on the server side. # Deal with the exception here, and/or rethrow at your discretion. raise e elif e.response['Error']['Code'] == 'InvalidParameterException': # You provided an invalid value for a parameter. # Deal with the exception here, and/or rethrow at your discretion. raise e elif e.response['Error']['Code'] == 'InvalidRequestException': # You provided a parameter value that is not valid for the current state of the resource. # Deal with the exception here, and/or rethrow at your discretion. raise e elif e.response['Error']['Code'] == 'ResourceNotFoundException': # We can't find the resource that you asked for. # Deal with the exception here, and/or rethrow at your discretion. raise e else: #default raise e response={ "isAuthorized":False } if (event['headers']['x-origin-verify'] == secret_HeaderValue or event['headers']['x-origin-verify'] == pending_secret_HeaderValue): response={ "isAuthorized":True } print (response) return response - region: !Ref "AWS::Region" # IAM Role for the authorizer Lambda function. Authorizer Lambda should be able to get secret from the origin verify secret. authorizerLambdaFunctionRole: Type: AWS::IAM::Role Properties: # Rolename will be randomly generated in CDK AssumeRolePolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Principal: Service: - lambda.amazonaws.com Action: - sts:AssumeRole Path: "/" Policies: - PolicyName: AuthorizerRolePolicy PolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Action: - logs:CreateLogStream - logs:CreateLogGroup Resource: - !Sub 'arn:${AWS::Partition}:logs:*:${AWS::AccountId}:log-group:*' - Effect: Allow Action: - logs:PutLogEvents Resource: - !Sub 'arn:${AWS::Partition}:logs:*:${AWS::AccountId}:log-group:*:log-stream:*' - Effect: Allow Action: - secretsmanager:GetSecretValue Resource: - !Sub 'arn:${AWS::Partition}:secretsmanager:${AWS::Region}:${AWS::AccountId}:secret:OriginVerifySecret-*' apiGateway: Type: AWS::ApiGatewayV2::Api Properties: ProtocolType: HTTP Name: APIGateway apiGwRoute: Type: AWS::ApiGatewayV2::Route Properties: ApiId: !Ref apiGateway RouteKey: 'GET /' AuthorizationType: 'CUSTOM' AuthorizerId: !Ref apiGwAuthorizer Target: !Join - / - - integrations - !Ref apiGwIntegration apiGwIntegration: Type: AWS::ApiGatewayV2::Integration Properties: ApiId: !Ref apiGateway IntegrationType: AWS_PROXY IntegrationMethod: "POST" IntegrationUri: !Sub - arn:${AWS::Partition}:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${lambdaArn}/invocations - lambdaArn: !GetAtt sampleWebsiteLambdaFunction.Arn PayloadFormatVersion: '2.0' apiGwStage: Type: AWS::ApiGatewayV2::Stage Properties: StageName: $default AutoDeploy: true ApiId: !Ref apiGateway AccessLogSettings: DestinationArn: !GetAtt apiLogGroup.Arn Format: >- {"requestId":"$context.requestId", "ip": "$context.identity.sourceIp","caller":"$context.identity.caller","user":"$context.identity.user","requestTime":"$context.requestTime","routeKey":"$context.routeKey","status":"$context.status"} apiGwAuthorizer: Type: 'AWS::ApiGatewayV2::Authorizer' Properties: Name: LambdaAuthorizer ApiId: !Ref apiGateway AuthorizerType: REQUEST EnableSimpleResponses: true AuthorizerPayloadFormatVersion: '2.0' AuthorizerUri: 'Fn::Sub': >- arn:${AWS::Partition}:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${authorizerLambda.Arn}/invocations # Identity Source will check if x-origin-verify header is present. If not, the response will be 403: forbidden and authorizer lambda will not be triggerd. # If x-origin-verify is present authorizerLambda will be triggered which will compare the value of this header with the secret IdentitySource: - '$request.header.x-origin-verify' authorizerLambdaPermission: Type: 'AWS::Lambda::Permission' Properties: Action: 'lambda:InvokeFunction' Principal: apigateway.amazonaws.com FunctionName: !Ref authorizerLambda SourceArn: 'Fn::Sub': - >- arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${__ApiId__}/${__Stage__}/* - __Stage__: '*' __ApiId__: Ref: apiGateway sampleWebsiteLambdaPermission: Type: 'AWS::Lambda::Permission' Properties: Action: 'lambda:InvokeFunction' Principal: apigateway.amazonaws.com FunctionName: !Ref sampleWebsiteLambdaFunction SourceArn: 'Fn::Sub': - >- arn:${AWS::Partition}:execute-api:${AWS::Region}:${AWS::AccountId}:${__ApiId__}/${__Stage__}/* - __Stage__: '*' __ApiId__: Ref: apiGateway apiLogGroup: Type: AWS::Logs::LogGroup Properties: LogGroupName: 'HTTPApiAccessLogs' RetentionInDays: !Ref LogRetention cloudFrontDistribution: Type: AWS::CloudFront::Distribution Properties: DistributionConfig: Origins: - Id: apiGwOrigin CustomOriginConfig: HTTPSPort: 443 OriginProtocolPolicy: https-only OriginSSLProtocols: - TLSv1.2 DomainName: !Sub '${apiGateway}.execute-api.${AWS::Region}.amazonaws.com' OriginCustomHeaders: - HeaderName: 'x-origin-verify' HeaderValue: !Join ['', ['{{resolve:secretsmanager:', !Ref OriginVerifySecret, ':SecretString:HEADERVALUE}}' ]] Enabled: true DefaultCacheBehavior: AllowedMethods: - GET - HEAD - OPTIONS TargetOriginId: apiGwOrigin ViewerProtocolPolicy: "redirect-to-https" ForwardedValues: QueryString: 'false' Outputs: OriginVerifySecret: Description: Secrets Manager Secret for Origin Validation Value: !Sub https://console.aws.amazon.com/secretsmanager/home?region=${AWS::Region}#/secret?name=${OriginVerifySecret} cfEndpoint: Description: Test website - CloudFront endpoint Value: !Join ['', ['https://', !GetAtt 'cloudFrontDistribution.DomainName']] cfDistro: Description: CloudFront distribution associated with test website Value: !Sub https://${AWS::Region}.console.aws.amazon.com/cloudfront/v3/home?region=${AWS::Region}#/distributions/${cloudFrontDistribution} apiEndpoint: Description: HTTP API Domain URL Value: !Sub "${apiGateway.ApiEndpoint}" HTTPApi: Description: CloudFront distribution associated with test website Value: !Sub https://console.aws.amazon.com/apigateway/main/api-detail?api=${apiGateway}®ion=${AWS::Region} OriginSecretRotateFunction: Value: !Sub https://console.aws.amazon.com/lambda/home?region=${AWS::Region}#/functions/${OriginSecretRotateFunction} Description: Secrets Manager Rotation Lambda Function LambdaAuthorizerFunction: Description: The API Gateway Authorizer Lambda Function Value: !Sub https://console.aws.amazon.com/lambda/home?region=${AWS::Region}#/functions/${authorizerLambda}