cdk/lib/soft-opt-in-consent-setter.ts (414 lines of code) (raw):
import { GuScheduledLambda } from '@guardian/cdk';
import type { GuStackProps } from '@guardian/cdk/lib/constructs/core';
import { GuStack } from '@guardian/cdk/lib/constructs/core';
import { GuLambdaFunction } from '@guardian/cdk/lib/constructs/lambda';
import type { App } from 'aws-cdk-lib';
import {
aws_dynamodb,
aws_events,
aws_lambda,
CfnCondition,
Duration,
Fn,
Tags,
} from 'aws-cdk-lib';
import { CfnAlarm } from 'aws-cdk-lib/aws-cloudwatch';
import { EventBus, Rule, Schedule } from 'aws-cdk-lib/aws-events';
import { SqsQueue } from 'aws-cdk-lib/aws-events-targets';
import {
AccountPrincipal,
Effect,
ManagedPolicy,
PolicyDocument,
PolicyStatement,
Role,
ServicePrincipal,
} from 'aws-cdk-lib/aws-iam';
import { Runtime } from 'aws-cdk-lib/aws-lambda';
import { Queue } from 'aws-cdk-lib/aws-sqs';
import { StringParameter } from 'aws-cdk-lib/aws-ssm';
import type { IConstruct } from 'constructs';
export interface SoftOptInConsentSetterProps extends GuStackProps {
mobileAccountIdSSMParam: string;
schedule: string;
acquisitionsEventBusArn: string;
}
export class SoftOptInConsentSetter extends GuStack {
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',
),
},
}),
}),
],
});
}
}