AWSTemplateFormatVersion: "2010-09-09" Description: "This is the base AWS CloudFormation template that provisions resources for the Audience Uploader from AWS Clean Rooms." Parameters: AdminEmail: Description: "Email address of the Audience Uploader from AWS Clean Rooms administrator" Type: String DataBucketName: Description: "Name of the S3 bucket from which source data will be uploaded. Bucket is NOT created by this CFT." Type: String Mappings: Application: Solution: Id: "SO0226" Version: "%%VERSION%%" SourceCode: GlobalS3Bucket: "%%BUCKET_NAME%%" TemplateKeyPrefix: "%%SOLUTION_NAME%%/%%VERSION%%" RegionalS3Bucket: "%%BUCKET_NAME%%" CodeKeyPrefix: "%%SOLUTION_NAME%%/%%VERSION%%" Version: "%%VERSION%%" Resources: # S3 ArtifactBucketPolicy: Type: AWS::S3::BucketPolicy Properties: Bucket: !Ref ArtifactBucket PolicyDocument: Version: 2012-10-17 Statement: - Effect: Deny Principal: "*" Action: "*" Resource: !Sub "arn:aws:s3:::${ArtifactBucket}/*" Condition: Bool: aws:SecureTransport: false ArtifactLogsBucket: DeletionPolicy: "Delete" Type: AWS::S3::Bucket Properties: OwnershipControls: Rules: - ObjectOwnership: ObjectWriter AccessControl: LogDeliveryWrite BucketEncryption: ServerSideEncryptionConfiguration: - ServerSideEncryptionByDefault: SSEAlgorithm: AES256 PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true IgnorePublicAcls: true RestrictPublicBuckets: true LifecycleConfiguration: Rules: - Id: "Keep access logs for 3 days" Status: Enabled Prefix: "access_logs/" ExpirationInDays: 3 AbortIncompleteMultipartUpload: DaysAfterInitiation: 1 Tags: - Key: "environment" Value: "uploader-from-clean-rooms" Metadata: cfn_nag: rules_to_suppress: - id: W35 reason: "Used to store access logs for other buckets" - id: W51 reason: "Bucket is private and does not need a bucket policy" ArtifactBucket: Type: AWS::S3::Bucket DependsOn: - GetShortUUID - ArtifactLogsBucket DeletionPolicy: "Delete" Properties: BucketName: "Fn::Join": - "" - - !Sub "uploader-etl-artifacts-" - !GetAtt GetShortUUID.Data BucketEncryption: ServerSideEncryptionConfiguration: - ServerSideEncryptionByDefault: SSEAlgorithm: AES256 LoggingConfiguration: DestinationBucketName: !Ref ArtifactLogsBucket LogFilePrefix: "access_logs/" LifecycleConfiguration: Rules: - Id: "Keep ETL results for 3 days" Status: Enabled Prefix: "access_logs/" ExpirationInDays: 3 AbortIncompleteMultipartUpload: DaysAfterInitiation: 1 NotificationConfiguration: EventBridgeConfiguration: EventBridgeEnabled: true Tags: - Key: "environment" Value: "uploader-from-clean-rooms" # Helper function # - Generates a unique name for the ArtifactBucket # - Also purges ArtifactBucket when stack is deleted HelperFunction: Type: AWS::Lambda::Function Metadata: cfn_nag: rules_to_suppress: - id: W89 reason: "This Lambda function does not need to access any resource provisioned within a VPC." - id: W92 reason: "This function does not require performance optimization, so the default concurrency limits suffice." Properties: Environment: Variables: DESIGNATED_LOGGING_BUCKET: !Ref ArtifactLogsBucket Code: ZipFile: | import string import cfnresponse import random import boto3 import json import os import logging from urllib.request import build_opener, HTTPHandler, Request LOGGER = logging.getLogger() LOGGER.setLevel(logging.INFO) def id_generator(size=6, chars=string.ascii_lowercase + string.digits): return "".join(random.choices(chars, k=size)) def handler(event, context): print("We got the following event:\n", event) try: LOGGER.info('REQUEST RECEIVED:\n {s}'.format(s=event)) LOGGER.info('REQUEST RECEIVED:\n {s}'.format(s=context)) if event['ResourceProperties']['FunctionKey'] == 'get_short_uuid': response_data = {'Data': id_generator()} cfnresponse.send(event, context, cfnresponse.SUCCESS, response_data, "CustomResourcePhysicalID") if event['RequestType'] == 'Delete': LOGGER.info('DELETE!') purge_bucket(event, context) send_response(event, context, "SUCCESS", {"Message": "Resource deletion successful!"}) except Exception as e: LOGGER.info('FAILED!') send_response(event, context, "FAILED", {"Message": "Exception during processing: {e}".format(e=e)}) def purge_bucket(event, context): try: s3 = boto3.resource('s3') bucket_name = os.environ["DESIGNATED_LOGGING_BUCKET"] LOGGER.info("Purging bucket, " + bucket_name) bucket = s3.Bucket(bucket_name) bucket.objects.all().delete() except Exception as e: LOGGER.info("Unable to purge artifact bucket while deleting stack: {e}".format(e=e)) send_response(event, context, "FAILED", {"Message": "Unexpected event received from CloudFormation"}) else: send_response(event, context, "SUCCESS", {"Message": "Resource creation successful!"}) def send_response(event, context, response_status, response_data): """ Send a resource manipulation status response to CloudFormation """ response_body = json.dumps({ "Status": response_status, "Reason": "See the details in CloudWatch Log Stream: " + context.log_stream_name, "PhysicalResourceId": context.log_stream_name, "StackId": event['StackId'], "RequestId": event['RequestId'], "LogicalResourceId": event['LogicalResourceId'], "Data": response_data }) LOGGER.info('ResponseURL: {s}'.format(s=event['ResponseURL'])) LOGGER.info('ResponseBody: {s}'.format(s=response_body)) opener = build_opener(HTTPHandler) request = Request(event['ResponseURL'], data=response_body.encode('utf-8')) request.add_header('Content-Type', '') request.add_header('Content-Length', len(response_body)) request.get_method = lambda: 'PUT' response = opener.open(request) Handler: index.handler Runtime: python3.9 Role: !GetAtt HelperFunctionRole.Arn Tags: - Key: "environment" Value: "uploader-from-clean-rooms" HelperFunctionRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Principal: Service: - lambda.amazonaws.com Action: - sts:AssumeRole Path: / Policies: - PolicyName: root PolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Action: - logs:CreateLogGroup - logs:CreateLogStream - logs:PutLogEvents Resource: - !Join [ "", ["arn:aws:logs:", Ref: "AWS::Region", ":", Ref: "AWS::AccountId", ":log-group:/aws/lambda/*"], ] - Effect: Allow Action: - "s3:DeleteObject" Resource: - !Join ["", ["arn:aws:s3:::", Ref: ArtifactLogsBucket, "/*"]] - Effect: Allow Action: - "s3:ListBucket" Resource: - !Join ["", ["arn:aws:s3:::", Ref: ArtifactLogsBucket]] Tags: - Key: "environment" Value: "uploader-from-clean-rooms" HelperFunctionPermissions: Type: AWS::Lambda::Permission Properties: Action: "lambda:InvokeFunction" FunctionName: !GetAtt HelperFunction.Arn Principal: "cloudformation.amazonaws.com" GetShortUUID: Type: Custom::CustomResource DependsOn: - ArtifactLogsBucket Properties: ServiceToken: !GetAtt HelperFunction.Arn FunctionKey: "get_short_uuid" # Auth stack AuthStack: Type: "AWS::CloudFormation::Stack" Properties: TemplateURL: !Join - "" - - "https://" - !FindInMap - Application - SourceCode - GlobalS3Bucket - ".s3.amazonaws.com/" - !FindInMap - Application - SourceCode - TemplateKeyPrefix - "/uploader-from-clean-rooms-auth.template" Parameters: AdminEmail: !Ref AdminEmail DataBucketName: !Ref DataBucketName RestApiId: !GetAtt ApiStack.Outputs.RestAPIId # Glue ETL stack GlueStack: Type: "AWS::CloudFormation::Stack" DependsOn: ArtifactBucket Properties: TemplateURL: !Join - "" - - "https://" - !FindInMap - Application - SourceCode - GlobalS3Bucket - ".s3.amazonaws.com/" - !FindInMap - Application - SourceCode - TemplateKeyPrefix - "/uploader-from-clean-rooms-glue.template" Parameters: ArtifactBucketName: !Ref ArtifactBucket DataBucketName: !Ref DataBucketName # Web stack WebStack: Type: "AWS::CloudFormation::Stack" Properties: TemplateURL: !Join - "" - - "https://" - !FindInMap - Application - SourceCode - GlobalS3Bucket - ".s3.amazonaws.com/" - !FindInMap - Application - SourceCode - TemplateKeyPrefix - "/uploader-from-clean-rooms-web.template" Parameters: DataBucketName: !Ref DataBucketName ArtifactBucketName: !Ref ArtifactBucket UserPoolId: !GetAtt AuthStack.Outputs.UserPoolId IdentityPoolId: !GetAtt AuthStack.Outputs.IdentityPoolId PoolClientId: !GetAtt AuthStack.Outputs.UserPoolClientId ApiEndpoint: !GetAtt ApiStack.Outputs.EndpointURL RestAPIId: !GetAtt ApiStack.Outputs.RestAPIId # API stack ApiStack: DependsOn: GlueStack Type: "AWS::CloudFormation::Stack" Properties: TemplateURL: !Join - "" - - "https://" - !FindInMap - Application - SourceCode - GlobalS3Bucket - ".s3.amazonaws.com/" - !FindInMap - Application - SourceCode - TemplateKeyPrefix - "/uploader-from-clean-rooms-api.template" Parameters: botoConfig: !Join - "" - - '{"user_agent_extra": "AwsSolution/' - !FindInMap - Application - Solution - Id - "/" - !FindInMap - Application - Solution - Version - '"}' Version: !FindInMap - Application - SourceCode - Version DeploymentPackageBucket: !Join - "-" - - !FindInMap - Application - SourceCode - RegionalS3Bucket - Ref: "AWS::Region" DeploymentPackageKey: !Join - "/" - - !FindInMap - Application - SourceCode - CodeKeyPrefix - "uploader-from-clean-rooms-api.zip" DataBucketName: !Ref DataBucketName AmcGlueJobName: !GetAtt GlueStack.Outputs.AmcGlueJobName Outputs: UserInterface: Value: !GetAtt WebStack.Outputs.CloudfrontUrl