AWSTemplateFormatVersion: "2010-09-09" Description: "(SO0222) - Amazon Marketing Cloud Uploader from AWS v2.3.1. This solution provides a mechanism to extract, transform, and load first party data from S3 to the Amazon Marketing Cloud." Parameters: AdminEmail: Description: "Email address of the webapp 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 AllowedPattern: ^[a-zA-Z0-9.\-_]{1,255}$ MinLength: 1 CustomerManagedKey: Description: "(Optional) ARN of a customer managed KMS encryption key (CMK) to use for encryption and decryption of original data files during the ETL pipeline and query computation in AMC." Type: String Default: "" Conditions: EnableCmkEncryptionCondition: !Not [!Equals [!Ref CustomerManagedKey, ""]] EnableAnonymousData: !Equals [ !FindInMap [AnonymizedData,SendAnonymizedData,Data], Yes] Mappings: Application: Solution: Id: "SO0222" Name: "amcufa" Version: "v2.3.1" AppRegistryApplicationName: 'amazon-marketing-cloud-uploader-from-aws' ApplicationType: 'AWS-Solutions' SourceCode: GlobalS3Bucket: "solutions-reference" TemplateKeyPrefix: "amazon-marketing-cloud-uploader-from-aws/v2.3.1" RegionalS3Bucket: "solutions" CodeKeyPrefix: "amazon-marketing-cloud-uploader-from-aws/v2.3.1" AnonymizedData: SendAnonymizedData: Data: Yes Resources: # Encryption SystemKey: Type: 'AWS::KMS::Key' Properties: Description: KMS key for encrypting stack resources EnableKeyRotation: true KeyPolicy: Version: '2012-10-17' Statement: - Effect: Allow Principal: AWS: !Sub 'arn:aws:iam::${AWS::AccountId}:root' Action: 'kms:*' Resource: '*' - Effect: Allow Principal: Service: "s3.amazonaws.com" Action: - 'kms:Decrypt' - 'kms:GenerateDataKey*' Resource: '*' Condition: StringEquals: 'aws:SourceAccount': !Ref 'AWS::AccountId' - Effect: Allow Principal: Service: 'dynamodb.amazonaws.com' # At a minimum, DynamoDB requires the following permissions on a customer managed key: # (https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/encryption.usagenotes.html) Action: - 'kms:Encrypt' - 'kms:Decrypt' - 'kms:ReEncrypt*' - 'kms:GenerateDataKey*' - 'kms:DescribeKey' - 'kms:CreateGrant' Resource: '*' Condition: StringEquals: 'aws:SourceAccount': !Ref 'AWS::AccountId' SystemKeyAlias: Type: 'AWS::KMS::Alias' Properties: AliasName: !Sub alias/${AWS::StackName} TargetKeyId: !Ref SystemKey # 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 ArtifactLogsBucketPolicy: Type: AWS::S3::BucketPolicy Properties: Bucket: !Ref ArtifactLogsBucket PolicyDocument: Version: 2012-10-17 Statement: - Effect: Deny Principal: "*" Action: "*" Resource: !Sub "arn:aws:s3:::${ArtifactLogsBucket}/*" Condition: Bool: aws:SecureTransport: false - Sid: "S3ServerAccessLogsPolicy" Effect: Allow Principal: Service: - logging.s3.amazonaws.com Action: 's3:PutObject' Resource: - !Join [ "", [ "arn:", Ref: "AWS::Partition", ":s3:::", Ref: ArtifactLogsBucket, "/access_logs/*" ] ] Condition: ArnLike: aws:SourceArn: - !Join [ "", [ "arn:", Ref: "AWS::Partition", ":s3:::", Ref: ArtifactLogsBucket ] ] ArtifactLogsBucket: Type: AWS::S3::Bucket DeletionPolicy: "Delete" Properties: BucketName: 'Fn::Join': - "" - - !GetAtt GetLowerStackName.Data - "-etl-artifacts-logs" - !GetAtt GetShortUUID2.Data BucketEncryption: ServerSideEncryptionConfiguration: - ServerSideEncryptionByDefault: !If - EnableCmkEncryptionCondition - SSEAlgorithm: "aws:kms" KMSMasterKeyID: !Ref CustomerManagedKey - SSEAlgorithm: "aws:kms" KMSMasterKeyID: !Ref SystemKeyAlias 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: "amcufa" 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: - AmcUploadLambdaPermission DeletionPolicy: "Retain" Properties: BucketName: 'Fn::Join': - "" - - !GetAtt GetLowerStackName.Data - "-etl-artifacts-" - !GetAtt GetShortUUID2.Data BucketEncryption: ServerSideEncryptionConfiguration: - ServerSideEncryptionByDefault: !If - EnableCmkEncryptionCondition - SSEAlgorithm: "aws:kms" KMSMasterKeyID: !Ref CustomerManagedKey - SSEAlgorithm: "AES256" LoggingConfiguration: DestinationBucketName: !Ref ArtifactLogsBucket LogFilePrefix: "access_logs/" PublicAccessBlockConfiguration: BlockPublicAcls: true BlockPublicPolicy: true IgnorePublicAcls: true RestrictPublicBuckets: true LifecycleConfiguration: Rules: - Id: "Keep ETL artifacts for 3 days" Status: Enabled Prefix: "amc/" ExpirationInDays: 3 AbortIncompleteMultipartUpload: DaysAfterInitiation: 1 NotificationConfiguration: LambdaConfigurations: - Event: 's3:ObjectCreated:*' Function: !GetAtt AmcUploadLambdaFunction.Arn Filter: S3Key: Rules: - Name: suffix Value: '.gz' - Name: prefix Value: 'amc/' Tags: - Key: "Environment" Value: "amcufa" # Tables SystemTable: Type: AWS::DynamoDB::Table Properties: PointInTimeRecoverySpecification: PointInTimeRecoveryEnabled: true SSESpecification: SSEType: KMS SSEEnabled: true KMSMasterKeyId: !Ref SystemKeyAlias BillingMode: PAY_PER_REQUEST AttributeDefinitions: - AttributeName: Name AttributeType: S KeySchema: - AttributeName: Name KeyType: HASH Tags: - Key: "Environment" Value: "amcufa" UploadFailuresTable: Type: AWS::DynamoDB::Table Properties: PointInTimeRecoverySpecification: PointInTimeRecoveryEnabled: true SSESpecification: SSEType: KMS SSEEnabled: true KMSMasterKeyId: !Ref SystemKeyAlias BillingMode: PAY_PER_REQUEST AttributeDefinitions: - AttributeName: destination_endpoint AttributeType: S - AttributeName: dataset_id AttributeType: S KeySchema: - AttributeName: destination_endpoint KeyType: HASH - AttributeName: dataset_id KeyType: RANGE Tags: - Key: "Environment" Value: "amcufa" # Lambda AmcUploadLambdaPermission: Type: AWS::Lambda::Permission Properties: Action: 'lambda:InvokeFunction' FunctionName: !Ref AmcUploadLambdaFunction Principal: s3.amazonaws.com SourceArn: 'Fn::Join': - "" - - "arn:aws:s3:::" - !GetAtt GetLowerStackName.Data - "-etl-artifacts-" - !GetAtt GetShortUUID2.Data SourceAccount: !Ref AWS::AccountId AmcUploadLambdaExecutionRole: Type: AWS::IAM::Role Metadata: cfn_nag: rules_to_suppress: - id: W11 reason: "The X-Ray policy uses actions that must be applied to all resources. See https://docs.aws.amazon.com/xray/latest/devguide/security_iam_id-based-policy-examples.html#xray-permissions-resources" Properties: AssumeRolePolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Principal: Service: - lambda.amazonaws.com Action: - sts:AssumeRole Policies: - PolicyName: allowLogging PolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Action: - "logs:CreateLogStream" - "logs:PutLogEvents" Resource: arn:aws:logs:*:*:* - Effect: Allow Action: - "xray:PutTraceSegments" - "xray:PutTelemetryRecords" Resource: "*" - PolicyName: recordUploadFailures PolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Action: - "dynamodb:GetItem" - "dynamodb:PutItem" - "dynamodb:DeleteItem" Resource: !GetAtt UploadFailuresTable.Arn - Effect: Allow Action: - "kms:Decrypt" - "kms:GenerateDataKey" Resource: !GetAtt SystemKey.Arn - PolicyName: getAndDeleteObjects PolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Action: - s3:GetObject Resource: 'Fn::Join': - "" - - "arn:aws:s3:::" - !GetAtt GetLowerStackName.Data - "-etl-artifacts-" - !GetAtt GetShortUUID2.Data - "/*" - PolicyName: invokeAmcApi PolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Action: - sts:AssumeRole Resource: !Sub "arn:aws:iam::${AWS::AccountId}:role/${AWS::StackName}-AmcApiAccessRole" LambdaLayer: Type: "AWS::Lambda::LayerVersion" Properties: CompatibleRuntimes: - python3.10 Content: S3Bucket: !Join ["-", [!FindInMap ["Application", "SourceCode", "RegionalS3Bucket"], Ref: "AWS::Region"]] S3Key: !Join [ "/", [ !FindInMap ["Application", "SourceCode", "CodeKeyPrefix"], "lambda_layer_python3.10.zip", ], ] Description: !Join [ "", [ "Python 3.10 packages for ", !FindInMap ["Application", "Solution", "Name"] ] ] LayerName: !Join [ "", [ !FindInMap [ "Application", "Solution", "Name" ], "-python310" ] ] LicenseInfo: Apache-2.0 AmcUploadLambdaFunction: Type: AWS::Lambda::Function Metadata: cfn_nag: rules_to_suppress: - id: W58 reason: "The role includes permission to write to CloudWatch Logs" - 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: Code: S3Bucket: !Join [ "-", [ !FindInMap [ "Application", "SourceCode", "RegionalS3Bucket" ], Ref: "AWS::Region" ] ] S3Key: !Join [ "/", [ !FindInMap [ "Application", "SourceCode", "CodeKeyPrefix" ], "amc_uploader.zip", ], ] Handler: amc_uploader.lambda_handler Role: !GetAtt AmcUploadLambdaExecutionRole.Arn Runtime: python3.10 MemorySize: 256 Timeout: 60 Layers: - !Ref "LambdaLayer" Environment: Variables: AMC_API_ROLE_ARN: !Sub 'arn:aws:iam::${AWS::AccountId}:role/${AWS::StackName}-AmcApiAccessRole' SOLUTION_NAME: !FindInMap ["Application", "Solution", "Name"] SOLUTION_VERSION: !FindInMap ["Application", "Solution", "Version"] UPLOAD_FAILURES_TABLE_NAME: !Ref UploadFailuresTable botoConfig: !Join - '' - - '{"region_name": "' - !Ref "AWS::Region" - '","user_agent_extra": "AwsSolution/' - !FindInMap - Application - Solution - Id - '/' - !FindInMap - Application - Solution - Version - '"}' TracingConfig: Mode: "Active" AmcUploadLambdaFunctionLogGroup: Type: AWS::Logs::LogGroup Metadata: cfn_nag: rules_to_suppress: - id: W84 reason: "The data generated via this role does not need to be encrypted." Properties: LogGroupName: !Join ['/', ['/aws/lambda', !Ref AmcUploadLambdaFunction]] RetentionInDays: 30 # Bucket Name Helper function # - Generates a UUID to use for uniquely naming for the ArtifactBucket and ArtifactLogsBucket BucketNameHelper: Type: AWS::Lambda::Function Metadata: cfn_nag: rules_to_suppress: - id: W58 reason: "The role includes permission to write to CloudWatch Logs" - 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: Code: ZipFile: | import string import cfnresponse import random import json 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_lower_stackname': stack_name = event['StackId'].split('/')[1] response_data = {'Data': stack_name.lower()} cfnresponse.send(event, context, cfnresponse.SUCCESS, response_data, "CustomResourcePhysicalID") if event['ResourceProperties']['FunctionKey'] == 'get_short_uuid': response_data = {'Data': id_generator()} cfnresponse.send(event, context, cfnresponse.SUCCESS, response_data, "CustomResourcePhysicalID") except Exception as e: LOGGER.info('FAILED!') send_response(event, context, "FAILED", {"Message": "Exception during processing: {e}".format(e=e)}) 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.10 Timeout: 900 Role: !GetAtt BucketNameHelperRole.Arn Tags: - Key: "Environment" Value: "amcufa" # Bucket Removal Helper function # - Purges the ArtifactBucket and ArtifactLogsBucket, so they can be removed # when the stack is deleted. BucketRemovalHelper: Type: AWS::Lambda::Function Metadata: cfn_nag: rules_to_suppress: - id: W58 reason: "The role includes permission to write to CloudWatch Logs" - 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: ARTIFACT_LOGS_BUCKET: !Ref ArtifactLogsBucket ARTIFACT_BUCKET: !Ref ArtifactBucket Code: ZipFile: | import string import cfnresponse import boto3 import json import os import logging from urllib.request import build_opener, HTTPHandler, Request LOGGER = logging.getLogger() LOGGER.setLevel(logging.INFO) 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)) request_type = event["RequestType"] if request_type in ("Create", "Update"): # Here we handle the CloudFormation CREATE and UPDATE events. # sent by the ArtifactBucketManager custom resource. # # When users upgrade from v2.x.x to a subsequent version, # then the nested stack for Glue ETL resources will be replaced. # When that nested stack is replaced, then it removes files that # are needed for Glue ETL job. We can't retroactively go back an # fix all the previously published versions, but we can change the # bucket policy to prevent those old stacks from removing the files. # We do that here: add_statement_to_bucket_policy() send_response(event, context, "SUCCESS", {"Message": "Bucket policy update successful!"}) elif request_type == "Delete": LOGGER.info('DELETE!') purge_bucket(event, context) send_response(event, context, "SUCCESS", {"Message": "Buckets purged and ready for deletion."}) else: send_response(event, context, "SUCCESS", {"Message": "Unknown RequestType."}) except Exception as e: LOGGER.info('FAILED!') send_response(event, context, "FAILED", {"Message": "Exception during processing: {e}".format(e=e)}) def add_statement_to_bucket_policy(): # This statement prevents anything from removing the files # that are needed by the Glue ETL job. bucket_name = os.environ["ARTIFACT_BUCKET"] # Define the bucket policy statement that we want to add. new_statement = { "Sid": "prevent-accidental-etl-script-deletion-during-upgrades", "Effect": "Deny", "Principal": "*", "Action": "s3:DeleteObject", "Resource": [ f"arn:aws:s3:::{bucket_name}/amc_transformations.py", f"arn:aws:s3:::{bucket_name}/library.zip" ] } # Get the current bucket policy s3 = boto3.client('s3') bucket_policy = s3.get_bucket_policy(Bucket=bucket_name) current_policy = json.loads(bucket_policy['Policy']) # Add the new statement to the existing policy. for statement in current_policy['Statement']: if statement.get("Sid") == new_statement.get("Sid"): # Exit if bucket policy already contains new_statement. return current_policy['Statement'].append(new_statement) # Update the bucket policy updated_policy = json.dumps(current_policy) s3.put_bucket_policy(Bucket=bucket_name, Policy=updated_policy) LOGGER.info("Updated bucket policy to deny DeleteObject") def remove_statement_from_bucket_policy(bucket_name): # This function reinstates the ability to delete Glue ETL files # from the Artifact Bucket. s3 = boto3.client('s3') # Retrieve the current bucket policy bucket_policy = s3.get_bucket_policy(Bucket=bucket_name) # Parse the bucket policy policy_json = json.loads(bucket_policy['Policy']) # Check if the statement to remove exists statement_id_to_remove = 'prevent-accidental-etl-script-deletion-during-upgrades' found = False updated_statements = [] for statement in policy_json['Statement']: if statement.get('Sid') != statement_id_to_remove: updated_statements.append(statement) else: found = True # If the statement to remove was found, update the policy if found: LOGGER.info("Updating bucket policy to allow DeleteObject") policy_json['Statement'] = updated_statements # Convert the updated policy back to JSON string updated_policy = json.dumps(policy_json) # Update the bucket policy s3.put_bucket_policy(Bucket=bucket_name, Policy=updated_policy) LOGGER.info(f"Statement with ID '{statement_id_to_remove}' removed from bucket policy.") else: LOGGER.info(f"Statement with ID '{statement_id_to_remove}' not found in bucket policy.") def purge_bucket(event, context): try: s3 = boto3.resource('s3') bucket_name = os.environ["ARTIFACT_BUCKET"] LOGGER.info("Validating DeleteObject permission") remove_statement_from_bucket_policy(bucket_name) LOGGER.info("Purging bucket, " + bucket_name) bucket = s3.Bucket(bucket_name) bucket.objects.all().delete() bucket_name = os.environ["ARTIFACT_LOGS_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)) 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.10 Timeout: 900 Role: !GetAtt BucketRemovalHelperRole.Arn Tags: - Key: "Environment" Value: "amcufa" BucketRemovalHelperLogGroup: Type: AWS::Logs::LogGroup Metadata: cfn_nag: rules_to_suppress: - id: W84 reason: "The data generated via this role does not need to be encrypted." Properties: LogGroupName: !Join ['/', ['/aws/lambda', !Ref BucketRemovalHelper]] RetentionInDays: 30 BucketNameHelperLogGroup: Type: AWS::Logs::LogGroup Metadata: cfn_nag: rules_to_suppress: - id: W84 reason: "The data generated via this role does not need to be encrypted." Properties: LogGroupName: !Join ['/', ['/aws/lambda', !Ref BucketNameHelper]] RetentionInDays: 30 BucketNameHelperRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Principal: Service: - lambda.amazonaws.com Action: - sts:AssumeRole Path: / Policies: - PolicyName: !Sub "${AWS::StackName}-BucketNameHelperRole" PolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Action: - logs:CreateLogStream - logs:PutLogEvents Resource: - !Join [ "", [ "arn:aws:logs:", Ref: "AWS::Region", ":", Ref: "AWS::AccountId", ":log-group:/aws/lambda/*", ], ] Tags: - Key: "Environment" Value: "amcufa" BucketRemovalHelperRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Principal: Service: - lambda.amazonaws.com Action: - sts:AssumeRole Path: / Policies: - PolicyName: !Sub "${AWS::StackName}-BucketRemovalHelperRole" PolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Action: - 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, "/*" ] ] - !Join [ "", [ "arn:aws:s3:::", Ref: ArtifactBucket, "/*" ] ] - Effect: Allow Action: - "s3:ListBucket" Resource: - !Join [ "", [ "arn:aws:s3:::", Ref: ArtifactLogsBucket ] ] - !Join [ "", [ "arn:aws:s3:::", Ref: ArtifactBucket ] ] - Effect: Allow Action: - "s3:GetBucketPolicy" - "s3:PutBucketPolicy" Resource: - !Join [ "", [ "arn:aws:s3:::", Ref: ArtifactBucket, ] ] Tags: - Key: "Environment" Value: "amcufa" BucketNameHelperPermissions: Type: AWS::Lambda::Permission Properties: Action: 'lambda:InvokeFunction' FunctionName: !GetAtt BucketNameHelper.Arn Principal: 'cloudformation.amazonaws.com' BucketRemovalHelperPermissions: Type: AWS::Lambda::Permission Properties: Action: 'lambda:InvokeFunction' FunctionName: !GetAtt BucketRemovalHelper.Arn Principal: 'cloudformation.amazonaws.com' GetShortUUID2: Type: Custom::CustomResource Properties: ServiceToken: !GetAtt BucketNameHelper.Arn FunctionKey: "get_short_uuid" GetLowerStackName: Type: Custom::CustomResource Properties: ServiceToken: !GetAtt BucketNameHelper.Arn FunctionKey: "get_lower_stackname" ArtifactBucketManager: Type: Custom::CustomResource Properties: ServiceToken: !GetAtt BucketRemovalHelper.Arn # Auth stack AuthStack: Type: "AWS::CloudFormation::Stack" Properties: TemplateURL: !Join - "" - - "https://" - !FindInMap - Application - SourceCode - GlobalS3Bucket - ".s3.amazonaws.com/" - !FindInMap - Application - SourceCode - TemplateKeyPrefix - "/auth.template" Parameters: AdminEmail: !Ref AdminEmail DataBucketName: !Ref DataBucketName RestApiId: !GetAtt ApiStack.Outputs.RestAPIId ParentStackName: !Ref AWS::StackName # Glue ETL stack GlueStack5: Type: "AWS::CloudFormation::Stack" Properties: TemplateURL: !Join - "" - - "https://" - !FindInMap - Application - SourceCode - GlobalS3Bucket - ".s3.amazonaws.com/" - !FindInMap - Application - SourceCode - TemplateKeyPrefix - "/glue.template" Parameters: ArtifactBucketName: !Ref ArtifactBucket DataBucketName: !Ref DataBucketName CustomerManagedKey: !Ref CustomerManagedKey EnableAnonymousData: !FindInMap [AnonymizedData,SendAnonymizedData,Data] AnonymousDataLogger: !Ref AnonymousDataCustomResource SolutionId: !FindInMap [ "Application", "Solution", "Id" ] UUID: !GetAtt AnonymousDataUuid.UUID # Web stack WebStack6: Type: "AWS::CloudFormation::Stack" Properties: TemplateURL: !Join - "" - - "https://" - !FindInMap - Application - SourceCode - GlobalS3Bucket - ".s3.amazonaws.com/" - !FindInMap - Application - SourceCode - TemplateKeyPrefix - "/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 CustomerManagedKey: !Ref CustomerManagedKey # API stack AmcApiAccessRole: Metadata: cfn_nag: rules_to_suppress: - id: W28 reason: "Role name is constructed with stack name. Updates will keep the existing role name." Type: AWS::IAM::Role Properties: Description: "IAM role to grant access to the AMC API." RoleName: !Join [ "-", [ { "Ref": "AWS::StackName" }, "AmcApiAccessRole" ] ] AssumeRolePolicyDocument: Version: '2012-10-17' Statement: - Effect: Allow Principal: AWS: !GetAtt ApiStack.Outputs.ApiHandlerRoleArn Action: - sts:AssumeRole - Effect: Allow Principal: AWS: !GetAtt AmcUploadLambdaExecutionRole.Arn Action: - sts:AssumeRole Policies: - PolicyName: AmcApiAccess PolicyDocument: Version: '2012-10-17' Statement: - Sid: "AmcEndpointAccessPolicy" Effect: Deny Action: - "execute-api:Invoke" Resource: - "arn:aws:execute-api:*:*:*/*" ApiStack: Type: "AWS::CloudFormation::Stack" Properties: TemplateURL: !Join - "" - - "https://" - !FindInMap - Application - SourceCode - GlobalS3Bucket - ".s3.amazonaws.com/" - !FindInMap - Application - SourceCode - TemplateKeyPrefix - "/api.template" Parameters: botoConfig: !Join - '' - - '{"region_name": "' - !Ref "AWS::Region" - '","user_agent_extra": "AwsSolution/' - !FindInMap - Application - Solution - Id - '/' - !FindInMap - Application - Solution - Version - '"}' Version: !FindInMap - Application - Solution - Version Name: !FindInMap - Application - Solution - Name DeploymentPackageBucket: !Join - "-" - - !FindInMap - Application - SourceCode - RegionalS3Bucket - Ref: "AWS::Region" DeploymentPackageKey: !Join - "/" - - !FindInMap - Application - SourceCode - CodeKeyPrefix - "api.zip" ArtifactBucket: !Ref ArtifactBucket DataBucketName: !Ref DataBucketName AmcApiRoleArn: !Sub 'arn:aws:iam::${AWS::AccountId}:role/${AWS::StackName}-AmcApiAccessRole' AmcGlueJobName: !GetAtt GlueStack5.Outputs.AmcGlueJobName TracingConfigMode: "Active" CustomerManagedKey: !Ref CustomerManagedKey LambdaLayer: !Ref LambdaLayer SystemTableName: !Ref SystemTable UploadFailuresTableName: !Ref UploadFailuresTable SystemKmsKeyId: !Ref SystemKey # Resources for sending anonymous operational metrics to AWS AnonymousDataCustomResourceRole: Type: AWS::IAM::Role Properties: AssumeRolePolicyDocument: Version: 2012-10-17 Statement: - Effect: Allow Principal: Service: - lambda.amazonaws.com Action: - sts:AssumeRole Path: "/" Policies: - PolicyName: !Sub "${AWS::StackName}-anonymous-data-logger" PolicyDocument: 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: - ssm:PutParameter Resource: - !Join ["", ["arn:aws:ssm:", Ref: "AWS::Region", ":", Ref: "AWS::AccountId", ":parameter/*"]] AnonymousDataCustomResource: 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: Description: Used to send anonymous data Handler: anonymous_data_logger.handler Role: !GetAtt AnonymousDataCustomResourceRole.Arn Code: S3Bucket: !Join [ "-", [ !FindInMap [ "Application", "SourceCode", "RegionalS3Bucket" ], Ref: "AWS::Region" ] ] S3Key: !Join [ "/", [ !FindInMap [ "Application", "SourceCode", "CodeKeyPrefix" ], "anonymous_data_logger.zip" ] ] Runtime: python3.10 Timeout: 180 # SendAnonymousData AnonymousDataUuid: Type: "Custom::UUID" Properties: ServiceToken: !GetAtt AnonymousDataCustomResource.Arn Resource: UUID AnonymousMetric: Condition: EnableAnonymousData Type: "Custom::AnonymousMetric" Properties: ServiceToken: !GetAtt AnonymousDataCustomResource.Arn Resource: AnonymousMetric SolutionId: !FindInMap ["Application", "Solution", "Id"] UUID: !GetAtt AnonymousDataUuid.UUID Version: !FindInMap ["Application", "Solution", "Version"] # App Registry Application: Type: AWS::ServiceCatalogAppRegistry::Application Properties: Description: Service Catalog application to track and manage all your resources for the solution Amazon Marketing Cloud uploader from AWS Name: !Join - "-" - - !FindInMap [Application, Solution, "AppRegistryApplicationName"] - !Ref AWS::Region - !Ref AWS::AccountId - !Ref AWS::StackName Tags: { 'Solutions:SolutionID': !FindInMap [Application, Solution, "Id"], 'Solutions:SolutionVersion': !FindInMap [Application, Solution, "Version"], 'Solutions:SolutionName': !FindInMap [Application, Solution, "Name"], 'Solutions:ApplicationType': !FindInMap [Application, Solution, "ApplicationType"], } AppRegistryApplicationStackAssociation: Type: AWS::ServiceCatalogAppRegistry::ResourceAssociation Properties: Application: !GetAtt Application.Id Resource: !Ref AWS::StackId ResourceType: CFN_STACK AppRegistryApplicationStackAssociationNestedApiStack: Type: AWS::ServiceCatalogAppRegistry::ResourceAssociation Properties: Application: !GetAtt Application.Id Resource: !Ref ApiStack ResourceType: CFN_STACK AppRegistryApplicationStackAssociationNestedWebStack: Type: AWS::ServiceCatalogAppRegistry::ResourceAssociation Properties: Application: !GetAtt Application.Id Resource: !Ref WebStack6 ResourceType: CFN_STACK AppRegistryApplicationStackAssociationNestedGlueStack: Type: AWS::ServiceCatalogAppRegistry::ResourceAssociation Properties: Application: !GetAtt Application.Id Resource: !Ref GlueStack5 ResourceType: CFN_STACK AppRegistryApplicationStackAssociationNestedAuthStack: Type: AWS::ServiceCatalogAppRegistry::ResourceAssociation Properties: Application: !GetAtt Application.Id Resource: !Ref AuthStack ResourceType: CFN_STACK DefaultApplicationAttributes: Type: AWS::ServiceCatalogAppRegistry::AttributeGroup Properties: Name: !Join ['-', [!Ref 'AWS::Region', !Ref 'AWS::StackName']] Description: Attribute group for solution information. Attributes: { "ApplicationType" : !FindInMap [Application, Solution, "ApplicationType"], "Version": !FindInMap [Application, Solution, "Version"], "SolutionID": !FindInMap [Application, Solution, "Id"], "SolutionName": !FindInMap [Application, Solution, "Name"] } AppRegistryApplicationAttributeAssociation: Type: AWS::ServiceCatalogAppRegistry::AttributeGroupAssociation Properties: Application: !GetAtt Application.Id AttributeGroup: !GetAtt DefaultApplicationAttributes.Id Outputs: UserInterface: Value: !GetAtt WebStack6.Outputs.CloudfrontUrl