constructor()

in cdk/lib/soft-opt-in-consent-setter.ts [39:447]


	constructor(scope: App, id: string, props: SoftOptInConsentSetterProps) {
		super(scope, id, props);

		// SSM Params
		const mobileAccountId = StringParameter.fromStringParameterName(
			this,
			'MobileAccountId',
			props.mobileAccountIdSSMParam,
		).stringValue;

		// Conditions
		const isProd = new CfnCondition(this, 'IsProd', {
			expression: Fn.conditionEquals(this.stage, 'PROD'),
		});

		// SQS Queues
		const softOptInsDeadLetterQueue = new Queue(
			this,
			'SoftOptInsDeadLetterQueue',
			{
				queueName: `soft-opt-in-consent-setter-dead-letter-queue-${this.stage}`,
				retentionPeriod: Duration.seconds(864000),
			},
		);
		const softOptInsQueue = new Queue(this, 'SoftOptInsQueue', {
			queueName: `soft-opt-in-consent-setter-queue-${this.stage}`,
			visibilityTimeout: Duration.seconds(3000),
			deadLetterQueue: {
				maxReceiveCount: 3,
				queue: softOptInsDeadLetterQueue,
			},
		});

		// Shared Policies
		const sharedPolicies: PolicyStatement[] = [
			new PolicyStatement({
				actions: ['cloudwatch:PutMetricData'],
				resources: ['*'],
			}),
			new PolicyStatement({
				sid: 'readDeployedArtefact',
				actions: ['s3:GetObject'],
				resources: ['arn:aws:s3::*:membership-dist/*'],
			}),
			new PolicyStatement({
				actions: [
					'secretsmanager:DescribeSecret',
					'secretsmanager:GetSecretValue',
				],
				resources: [
					'CODE/Salesforce/ConnectedApp/AwsConnectorSandbox-jaCgRl',
					'PROD/Salesforce/ConnectedApp/TouchpointUpdate-lolLqP',
					'CODE/Salesforce/User/SoftOptInConsentSetterAPIUser-KjHQBG',
					'PROD/Salesforce/User/SoftOptInConsentSetterAPIUser-EonJb0',
					'CODE/Identity/SoftOptInConsentAPI-n7Elrb',
					'PROD/Identity/SoftOptInConsentAPI-sJJo2s',
					'CODE/MobilePurchasesAPI/User/GetSubscriptions-iCUzGN',
					'PROD/MobilePurchasesAPI/User/GetSubscriptions-HZuC6H',
				].map(
					(resource) =>
						`arn:aws:secretsmanager:eu-west-1:${this.account}:secret:` +
						resource,
				),
			}),
		];

		// IAM Roles
		new Role(this, 'SoftOptInsQueueCrossAccountRole', {
			roleName: `membership-${this.stage}-soft-opt-in-consent-setter-QueueCrossAccountRole`,
			assumedBy: new AccountPrincipal(mobileAccountId),
			inlinePolicies: {
				SQSAccess: new PolicyDocument({
					statements: [
						new PolicyStatement({
							actions: [
								'sqs:SendMessage',
								'sqs:ReceiveMessage',
								'sqs:DeleteMessage',
								'sqs:GetQueueAttributes',
							],
							resources: [softOptInsQueue.queueArn],
							effect: Effect.ALLOW,
						}),
					],
				}),
			},
		});

		const lambdaFunctionRole = new Role(this, 'LambdaFunctionRole', {
			roleName: `membership-${this.stage}-soft-opt-in-consent-setter-LambdaFunctionRole`,
			assumedBy: new ServicePrincipal('lambda.amazonaws.com'),
			managedPolicies: [
				ManagedPolicy.fromAwsManagedPolicyName(
					'service-role/AWSLambdaBasicExecutionRole',
				),
			],
		});
		sharedPolicies.forEach((policy) => lambdaFunctionRole.addToPolicy(policy));

		const lambdaFunctionIAPRole = new Role(this, 'LambdaFunctionIAPRole', {
			roleName: `membership-${this.stage}-soft-opt-in-consent-setter-LambdaFunctionIAPRole`,
			assumedBy: new ServicePrincipal('lambda.amazonaws.com'),
			managedPolicies: [
				ManagedPolicy.fromAwsManagedPolicyName(
					'service-role/AWSLambdaBasicExecutionRole',
				),
			],
		});
		lambdaFunctionIAPRole.addToPolicy(
			new PolicyStatement({
				actions: ['dynamodb:PutItem'],
				resources: [
					`arn:aws:dynamodb:${this.region}:${this.account}:table/soft-opt-in-consent-setter-${this.stage}-logging`,
				],
			}),
		);
		lambdaFunctionIAPRole.addToPolicy(
			new PolicyStatement({
				actions: [
					'sqs:DeleteMessage',
					'sqs:GetQueueAttributes',
					'sqs:ReceiveMessage',
				],
				resources: [softOptInsQueue.queueArn],
			}),
		);
		sharedPolicies.forEach((policy) =>
			lambdaFunctionIAPRole.addToPolicy(policy),
		);

		// Lambda Functions
		const lambdaFunction = new GuScheduledLambda(this, 'LambdaFunction', {
			app: 'soft-opt-in-consent-setter',
			fileName: 'soft-opt-in-consent-setter.jar',
			role: lambdaFunctionRole,
			monitoringConfiguration: {
				noMonitoring: true,
			},
			rules: [
				{
					schedule: Schedule.expression(props.schedule),
					description: 'Runs Soft Opt-In Consent Setter',
				},
			],
			functionName: `soft-opt-in-consent-setter-${this.stage}`,
			runtime: Runtime.JAVA_11, // keep on 11 for now due to http PATCH issue
			handler: 'com.gu.soft_opt_in_consent_setter.Handler::handleRequest',
			memorySize: 512,
			timeout: Duration.seconds(900),
			environment: {
				Stage: this.stage,
				sfApiVersion: 'v46.0',
			},
		});

		const lambdaFunctionIAP = new GuLambdaFunction(this, 'LambdaFunctionIAP', {
			app: 'soft-opt-in-consent-setter',
			fileName: 'soft-opt-in-consent-setter.jar',
			role: lambdaFunctionIAPRole,
			functionName: `soft-opt-in-consent-setter-IAP-${this.stage}`,
			runtime: Runtime.JAVA_11, // keep on 11 for now due to http PATCH issue
			handler: 'com.gu.soft_opt_in_consent_setter.HandlerIAP::handleRequest',
			memorySize: 512,
			timeout: Duration.seconds(300),
			environment: {
				Stage: this.stage,
				sfApiVersion: 'v56.0',
			},
		});

		// SQS Triggers
		const sqsTrigger = new aws_lambda.EventSourceMapping(this, 'SQSTrigger', {
			eventSourceArn: softOptInsQueue.queueArn,
			target: lambdaFunctionIAP,
			batchSize: 1,
			enabled: true,
		});

		// DynamoDB Tables
		const softOptInsLoggingTable = new aws_dynamodb.Table(
			this,
			'SoftOptInsLoggingTable',
			{
				tableName: `soft-opt-in-consent-setter-${this.stage}-logging`,
				billingMode: aws_dynamodb.BillingMode.PAY_PER_REQUEST,
				partitionKey: {
					name: 'identityId',
					type: aws_dynamodb.AttributeType.STRING,
				},
				sortKey: { name: 'timestamp', type: aws_dynamodb.AttributeType.NUMBER },
				pointInTimeRecovery: true,
				encryption: aws_dynamodb.TableEncryption.AWS_MANAGED,
			},
		);
		softOptInsLoggingTable.addGlobalSecondaryIndex({
			indexName: 'subscriptionId-index',
			partitionKey: {
				name: 'subscriptionId',
				type: aws_dynamodb.AttributeType.STRING,
			},
			projectionType: aws_dynamodb.ProjectionType.ALL,
		});
		Tags.of(softOptInsLoggingTable).add('Stage', this.stage);
		Tags.of(softOptInsLoggingTable).add('devx-backup-enabled', 'true');

		// Cloudwatch Alarms
		const snsArn = `arn:aws:sns:${this.region}:${this.account}:alarms-handler-topic-PROD`;

		const failedRunAlarm = new CfnAlarm(this, 'failedRunAlarm', {
			alarmActions: [snsArn],
			alarmName: `soft-opt-in-consent-setter-${this.stage} failed to run`,
			alarmDescription:
				'Five or more runs found an error and were unable to complete. See GitHub README for details.',
			comparisonOperator: 'GreaterThanOrEqualToThreshold',
			dimensions: [
				{
					name: 'FunctionName',
					value: lambdaFunction.functionName,
				},
			],
			evaluationPeriods: 2,
			metricName: 'Errors',
			namespace: 'AWS/Lambda',
			period: 3600,
			statistic: 'Sum',
			threshold: 5,
			treatMissingData: 'notBreaching',
		});
		failedRunAlarm.cfnOptions.condition = isProd;

		const exceptionsAlarmIAP = new CfnAlarm(this, 'exceptionsAlarmIAP', {
			alarmActions: [snsArn],
			alarmName: `soft-opt-in-consent-setter-IAP-${this.stage} threw an exception`,
			alarmDescription:
				'Five or more errors for the IAP Lambda. See GitHub README for details.',
			comparisonOperator: 'GreaterThanOrEqualToThreshold',
			dimensions: [
				{
					name: 'FunctionName',
					value: lambdaFunctionIAP.functionName,
				},
			],
			evaluationPeriods: 2,
			metricName: 'Errors',
			namespace: 'AWS/Lambda',
			period: 3600,
			statistic: 'Sum',
			threshold: 5,
			treatMissingData: 'notBreaching',
		});
		exceptionsAlarmIAP.cfnOptions.condition = isProd;

		const deadLetterBuildUpAlarmIAP = new CfnAlarm(
			this,
			'deadLetterBuildUpAlarmIAP',
			{
				alarmActions: [snsArn],
				alarmName: `soft-opt-in-consent-setter-IAP-${this.stage} failed and sent a message to the dead letter queue.`,
				alarmDescription:
					'Alarm when the dead letter queue accumulates messages.',
				comparisonOperator: 'GreaterThanOrEqualToThreshold',
				dimensions: [
					{
						name: 'QueueName',
						value: softOptInsDeadLetterQueue.queueName,
					},
				],
				period: 300,
				evaluationPeriods: 1,
				metricName: 'ApproximateNumberOfMessagesVisible',
				namespace: 'AWS/SQS',
				statistic: 'Sum',
				threshold: 5,
				treatMissingData: 'notBreaching',
			},
		);
		deadLetterBuildUpAlarmIAP.cfnOptions.condition = isProd;

		const failedUpdateAlarm = new CfnAlarm(this, 'failedUpdateAlarm', {
			alarmActions: [snsArn],
			alarmName: `soft-opt-in-consent-setter-${this.stage} failed to update Salesforce records`,
			alarmDescription:
				'A run failed to update some Salesforce records in the last hour.',
			comparisonOperator: 'GreaterThanOrEqualToThreshold',
			dimensions: [
				{
					name: 'Stage',
					value: this.stage,
				},
			],
			evaluationPeriods: 1,
			metricName: 'failed_salesforce_update',
			namespace: 'soft-opt-in-consent-setter',
			period: 3600,
			statistic: 'Sum',
			threshold: 1,
			treatMissingData: 'notBreaching',
		});
		failedUpdateAlarm.cfnOptions.condition = isProd;

		const failedDynamoUpdateAlarm = new CfnAlarm(
			this,
			'failedDynamoUpdateAlarm',
			{
				alarmActions: [snsArn],
				alarmName: `soft-opt-in-consent-setter-${this.stage} failed to update the Dynamo logging table.`,
				alarmDescription:
					'A run failed to update the Dynamo logging table in the last hour.',
				comparisonOperator: 'GreaterThanOrEqualToThreshold',
				dimensions: [
					{
						name: 'Stage',
						value: this.stage,
					},
				],
				evaluationPeriods: 1,
				metricName: 'failed_dynamo_update',
				namespace: 'soft-opt-in-consent-setter',
				period: 3600,
				statistic: 'Sum',
				threshold: 1,
				treatMissingData: 'notBreaching',
			},
		);
		failedDynamoUpdateAlarm.cfnOptions.condition = isProd;

		// Logical ID overrides
		const resourcesKeepingExistingLogicalIds: Array<{
			construct: IConstruct;
			forcedLogicalId: string;
			reason: string;
		}> = [
			{
				construct: softOptInsQueue,
				forcedLogicalId: 'SoftOptInsQueue',
				reason: 'Retaining a stateful resource previously defined in YAML',
			},
			{
				construct: softOptInsDeadLetterQueue,
				forcedLogicalId: 'SoftOptInsDeadLetterQueue',
				reason: 'Retaining a stateful resource previously defined in YAML',
			},
			{
				construct: softOptInsLoggingTable,
				forcedLogicalId: 'SoftOptInsLoggingTable',
				reason: 'Retaining a stateful resource previously defined in YAML',
			},
			{
				construct: lambdaFunction,
				forcedLogicalId: 'LambdaFunction',
				reason: 'Moving existing lambda to CDK',
			},
			{
				construct: lambdaFunctionIAP,
				forcedLogicalId: 'LambdaFunctionIAP',
				reason: 'Moving existing lambda to CDK',
			},
			{
				construct: sqsTrigger,
				forcedLogicalId: 'SQSTrigger',
				reason: 'Moving existing lambda to CDK',
			},
		];
		resourcesKeepingExistingLogicalIds.forEach((resource) => {
			this.overrideLogicalId(resource.construct, {
				logicalId: resource.forcedLogicalId,
				reason: 'Retaining a stateful resource previously defined in YAML',
			});
		});

		// Acquisitions Event Bus (defined in support-frontend CDK)
		const acquisitionsEventBus = EventBus.fromEventBusArn(
			this,
			'AcquisitionsEventBus',
			props.acquisitionsEventBusArn,
		);

		// Rules
		new Rule(this, 'SoftOptInToSQSRule', {
			description:
				'Send all events received via support-workers onto soft opt-in SQS queue',
			eventBus: acquisitionsEventBus,
			eventPattern: {
				region: ['eu-west-1'],
				source: ['support-workers.1'],
			},
			targets: [
				new SqsQueue(softOptInsQueue, {
					message: aws_events.RuleTargetInput.fromObject({
						subscriptionId: aws_events.EventField.fromPath(
							'$.detail.zuoraSubscriptionNumber',
						),
						identityId: aws_events.EventField.fromPath('$.detail.identityId'),
						eventType: 'Acquisition',
						productName: aws_events.EventField.fromPath('$.detail.product'),
						printProduct: aws_events.EventField.fromPath(
							'$.detail.printOptions.product',
						),
						previousProductName: null,
						userConsentsOverrides: {
							similarGuardianProducts: aws_events.EventField.fromPath(
								'$.detail.similarProductsConsent',
							),
						},
					}),
				}),
			],
		});
	}