cdk/lib/payment-api.ts (444 lines of code) (raw):

import { GuPlayApp } from "@guardian/cdk"; import { AccessScope } from "@guardian/cdk/lib/constants"; import { GuAlarm } from "@guardian/cdk/lib/constructs/cloudwatch"; import type { GuStackProps } from "@guardian/cdk/lib/constructs/core"; import { GuStack, GuStringParameter } from "@guardian/cdk/lib/constructs/core"; import { GuAllowPolicy, GuGetS3ObjectsPolicy, GuPutCloudwatchMetricsPolicy, } from "@guardian/cdk/lib/constructs/iam"; import type { GuAsgCapacity } from "@guardian/cdk/lib/types"; import { Duration } from "aws-cdk-lib"; import type { App } from "aws-cdk-lib"; import { ComparisonOperator, MathExpression, Metric, TreatMissingData, } from "aws-cdk-lib/aws-cloudwatch"; import { InstanceClass, InstanceSize, InstanceType, UserData, } from "aws-cdk-lib/aws-ec2"; import { ApplicationListenerRule, ListenerAction, ListenerCondition, } from "aws-cdk-lib/aws-elasticloadbalancingv2"; interface PaymentApiProps extends GuStackProps { domainName: string; scaling: GuAsgCapacity; } export class PaymentApi extends GuStack { constructor(scope: App, id: string, props: PaymentApiProps) { super(scope, id, props); const emailSqsCodeArn = new GuStringParameter( this, "EmailSqsQueueCodeArn", { description: "For the PROD stack you still need to supply this, because PROD instances need access to both PROD and CODE email queues.", } ); const emailSqsProdArn = new GuStringParameter( this, "EmailSqsQueueProdArn", { description: "For the CODE stack you can leave this empty since it won't be used. For the PROD stack you need to set it.", } ); const ophanRole = new GuStringParameter(this, "OphanRole", { description: "ARN of the Ophan cross-account role", }); const kinesisStreamArn = new GuStringParameter(this, "KinesisStreamArn", { description: "ARN of the kinesis stream to write events to", }); const contributionsStoreSqsQueueCodeArn = new GuStringParameter( this, "ContributionsStoreSqsQueueCodeArn", { description: "For the PROD stack you still need to supply this, because PROD instances need access to both PROD and CODE contribution store queues.", } ); const contributionsStoreSqsQueueProdArn = new GuStringParameter( this, "ContributionsStoreSqsQueueProdArn", { description: "For the CODE stack you can leave this empty since it won't be used. For the PROD stack you need to set it.", } ); const sqsKmsArn = new GuStringParameter(this, "SqsKmsArn", { description: "ARN of the KMS key for encrypting SQS data", }); // TODO: Should these remain as cloudformation parameters? const projectName = "payment-api"; const projectVersion = "0.1"; const app = "payment-api"; const userData = UserData.forLinux(); userData.addCommands(`#!/bin/bash -ev mkdir /etc/gu echo ${this.stage} > /etc/gu/stage aws --region ${this.region} s3 cp s3://membership-dist/${this.stack}/${this.stage}/${app}/${projectName}_${projectVersion}_all.deb /tmp dpkg -i /tmp/${projectName}_${projectVersion}_all.deb /opt/cloudwatch-logs/configure-logs application ${this.stack} ${this.stage} ${app} /var/log/${app}/application.log`); const playApp = new GuPlayApp(this, { app: "payment-api", access: { scope: AccessScope.PUBLIC }, certificateProps: { domainName: props.domainName, hostedZoneId: "Z1E4V12LQGXFEC", }, monitoringConfiguration: { noMonitoring: true }, instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.SMALL), scaling: props.scaling, userData, roleConfiguration: { additionalPolicies: [ new GuAllowPolicy(this, "SupporterProductDataDynamo", { actions: [ "dynamodb:GetItem", "dynamodb:Scan", "dynamodb:UpdateItem", "dynamodb:DeleteItem", "dynamodb:Query", "dynamodb:DescribeTable", ], resources: this.stage === "PROD" ? [ "arn:aws:dynamodb:*:*:table/SupporterProductData-PROD", "arn:aws:dynamodb:*:*:table/SupporterProductData-CODE", ] : ["arn:aws:dynamodb:*:*:table/SupporterProductData-CODE"], }), new GuAllowPolicy(this, "CloudwatchLogs", { actions: [ "logs:CreateLogGroup", "logs:CreateLogStream", "logs:PutLogEvents", ], // TODO: Should we use a more specific resource here? resources: ["arn:aws:logs:*:*:*"], }), new GuAllowPolicy(this, "SSMConfigParams", { actions: ["ssm:GetParametersByPath"], resources: [ `arn:aws:ssm:${this.region}:${this.account}:parameter/${app}/*`, ], }), new GuAllowPolicy(this, "EmailSqsMessages", { actions: ["sqs:GetQueueUrl", "sqs:SendMessage"], resources: this.stage === "PROD" ? [emailSqsCodeArn.valueAsString, emailSqsProdArn.valueAsString] : [emailSqsCodeArn.valueAsString], }), new GuAllowPolicy(this, "SoftOptInsSqsMessages", { actions: ["sqs:GetQueueUrl", "sqs:SendMessage"], resources: this.stage === "PROD" ? [ `arn:aws:sqs:${this.region}:${this.account}:soft-opt-in-consent-setter-queue-PROD`, `arn:aws:sqs:${this.region}:${this.account}:soft-opt-in-consent-setter-queue-CODE`, ] : [ `arn:aws:sqs:${this.region}:${this.account}:soft-opt-in-consent-setter-queue-CODE`, ], }), new GuPutCloudwatchMetricsPolicy(this), new GuAllowPolicy(this, "AssumeOphanRole", { actions: ["sts:AssumeRole"], resources: [ophanRole.valueAsString], }), new GuAllowPolicy(this, "KinesisPut", { actions: ["kinesis:*"], resources: [kinesisStreamArn.valueAsString], }), new GuAllowPolicy(this, "EventBusPut", { actions: ["events:PutEvents"], resources: this.stage === "PROD" ? [ `arn:aws:events:eu-west-1:865473395570:event-bus/acquisitions-bus-CODE`, `arn:aws:events:eu-west-1:865473395570:event-bus/acquisitions-bus-PROD`, ] : [ `arn:aws:events:eu-west-1:865473395570:event-bus/acquisitions-bus-CODE`, ], }), new GuAllowPolicy(this, "SQSPut", { actions: ["sqs:SendMessage"], resources: this.stage === "PROD" ? [ contributionsStoreSqsQueueCodeArn.valueAsString, contributionsStoreSqsQueueProdArn.valueAsString, ] : [contributionsStoreSqsQueueCodeArn.valueAsString], }), new GuAllowPolicy(this, "SqsKmsEncryption", { actions: ["kms:Encrypt"], resources: [sqsKmsArn.valueAsString], }), new GuGetS3ObjectsPolicy(this, "SettingsBucket", { bucketName: "support-admin-console", paths: this.stage === "PROD" ? ["*/*"] : ["CODE/*"], }), ], }, }); // Rule to only allow known http methods new ApplicationListenerRule(this, "AllowKnownMethods", { listener: playApp.listener, priority: 1, conditions: [ ListenerCondition.httpRequestMethods([ "GET", "POST", "OPTIONS", "DELETE", "HEAD", ]), ], targetGroups: [playApp.targetGroup], }); // Default rule to block requests which don't match the above rule new ApplicationListenerRule(this, "BlockUnknownMethods", { listener: playApp.listener, priority: 2, conditions: [ListenerCondition.pathPatterns(["*"])], // anything action: ListenerAction.fixedResponse(400, { contentType: "application/json", messageBody: "Unsupported http method", }), }); // ---- Alarms ---- // new GuAlarm(this, "NoHealthyInstancesAlarm", { app, alarmName: `[CDK] No healthy instances for ${app} in ${this.stage}`, actionsEnabled: props.stage === "PROD", threshold: 0.5, evaluationPeriods: 2, comparisonOperator: ComparisonOperator.LESS_THAN_OR_EQUAL_TO_THRESHOLD, metric: new Metric({ metricName: "HealthyHostCount", namespace: "AWS/ApplicationELB", dimensionsMap: { LoadBalancer: playApp.loadBalancer.loadBalancerFullName, TargetGroup: playApp.targetGroup.targetGroupFullName, }, statistic: "Average", period: Duration.seconds(60), }), snsTopicName: `alarms-handler-topic-${this.stage}`, }); new GuAlarm(this, "High5XXRateAlarm", { app, alarmName: `[CDK] High 5XX rate for ${app} in ${this.stage}`, actionsEnabled: props.stage === "PROD", threshold: 3, evaluationPeriods: 2, comparisonOperator: ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD, metric: new Metric({ metricName: "HTTPCode_Target_5XX_Count", namespace: "AWS/ApplicationELB", dimensionsMap: { LoadBalancer: playApp.loadBalancer.loadBalancerFullName, TargetGroup: playApp.targetGroup.targetGroupFullName, }, statistic: "Sum", period: Duration.seconds(60), }), snsTopicName: `alarms-handler-topic-${this.stage}`, }); new GuAlarm(this, "NoPaypalPaymentsInTwoHours247Alarm", { app, alarmName: `[CDK] ${app} ${this.stage} CP One-off contributions with PayPal might be down`, alarmDescription: "There have been no one-off contributions using paypal in the last 2 hours", actionsEnabled: props.stage === "PROD", threshold: 0, evaluationPeriods: 12, comparisonOperator: ComparisonOperator.LESS_THAN_OR_EQUAL_TO_THRESHOLD, metric: new Metric({ metricName: "payment-success", namespace: `support-payment-api-${this.stage}`, dimensionsMap: { "payment-provider": "Paypal", }, statistic: "Sum", period: Duration.seconds(600), }), treatMissingData: TreatMissingData.BREACHING, snsTopicName: `alarms-handler-topic-${this.stage}`, }); new GuAlarm(this, "NoStripePaymentsInThreeHours247Alarm", { app, alarmName: `[CDK] ${app} ${this.stage} CP One-off contributions with Card might be down`, alarmDescription: "There have been no one-off contributions using card payment in the last 3 hours", actionsEnabled: props.stage === "PROD", threshold: 0, evaluationPeriods: 12, comparisonOperator: ComparisonOperator.LESS_THAN_OR_EQUAL_TO_THRESHOLD, metric: new Metric({ metricName: "payment-success", namespace: `support-payment-api-${this.stage}`, dimensionsMap: { "payment-provider": "Stripe", }, statistic: "Sum", period: Duration.seconds(900), }), treatMissingData: TreatMissingData.BREACHING, snsTopicName: `alarms-handler-topic-${this.stage}`, }); new GuAlarm(this, "NoPaypalPaymentsInOneHourAlarm", { app, alarmName: `[CDK] ${app} ${this.stage} No successful paypal payments via payment-api for an hour`, actionsEnabled: props.stage === "PROD", threshold: 0, evaluationPeriods: 12, comparisonOperator: ComparisonOperator.LESS_THAN_OR_EQUAL_TO_THRESHOLD, metric: new Metric({ metricName: "payment-success", namespace: `support-payment-api-${this.stage}`, dimensionsMap: { "payment-provider": "Paypal", }, statistic: "Sum", period: Duration.seconds(300), }), treatMissingData: TreatMissingData.BREACHING, snsTopicName: `alarms-handler-topic-${this.stage}`, }); new GuAlarm(this, "NoStripePaymentsInOneHourAlarm", { app, alarmName: `[CDK] ${app} ${this.stage} No successful stripe payments via payment-api for an hour`, actionsEnabled: props.stage === "PROD", threshold: 0, evaluationPeriods: 12, comparisonOperator: ComparisonOperator.LESS_THAN_OR_EQUAL_TO_THRESHOLD, metric: new Metric({ metricName: "payment-success", namespace: `support-payment-api-${this.stage}`, dimensionsMap: { "payment-provider": "Stripe", }, statistic: "Sum", period: Duration.seconds(300), }), treatMissingData: TreatMissingData.BREACHING, snsTopicName: `alarms-handler-topic-${this.stage}`, }); const [applePaySuccessMetric, paymentRequestButtonSuccessMetric] = [ "StripeApplePay", "StripePaymentRequestButton", ].map( (paymentProvider) => new Metric({ metricName: "payment-success", namespace: `support-payment-api-${this.stage}`, dimensionsMap: { "payment-provider": paymentProvider, }, statistic: "Sum", period: Duration.seconds(300), }) ); const combinedApplePayAndPaymentRequestButtonSuccessMetric = new MathExpression({ expression: "SUM(METRICS())", period: Duration.seconds(300), usingMetrics: { m1: applePaySuccessMetric, m2: paymentRequestButtonSuccessMetric, }, }); new GuAlarm(this, "NoStripeExpressPaymentsInOneHourAlarm", { app, alarmName: `[CDK] ${app} ${this.stage} No successful stripe express payments via payment-api for an hour`, actionsEnabled: props.stage === "PROD", threshold: 0, evaluationPeriods: 12, comparisonOperator: ComparisonOperator.LESS_THAN_OR_EQUAL_TO_THRESHOLD, metric: combinedApplePayAndPaymentRequestButtonSuccessMetric, treatMissingData: TreatMissingData.BREACHING, snsTopicName: `alarms-handler-topic-${this.stage}`, }); new GuAlarm(this, "PaypalPaymentError", { app, alarmName: `[CDK] ${app} ${this.stage} Paypal payment error for one-off contribution via the payment-api`, actionsEnabled: props.stage === "PROD", threshold: 1, evaluationPeriods: 1, comparisonOperator: ComparisonOperator.GREATER_THAN_THRESHOLD, metric: new Metric({ metricName: "payment-error", namespace: `support-payment-api-${this.stage}`, dimensionsMap: { "payment-provider": "Paypal", }, statistic: "Sum", period: Duration.seconds(60), }), treatMissingData: TreatMissingData.NOT_BREACHING, snsTopicName: `alarms-handler-topic-${this.stage}`, }); new GuAlarm(this, "StripePaymentError", { app, alarmName: `[CDK] ${app} ${this.stage} Stripe payment error for one-off contribution via the payment-api`, actionsEnabled: props.stage === "PROD", threshold: 1, evaluationPeriods: 1, comparisonOperator: ComparisonOperator.GREATER_THAN_THRESHOLD, metric: new Metric({ metricName: "payment-error", namespace: `support-payment-api-${this.stage}`, dimensionsMap: { "payment-provider": "Stripe", }, statistic: "Sum", period: Duration.seconds(60), }), treatMissingData: TreatMissingData.NOT_BREACHING, snsTopicName: `alarms-handler-topic-${this.stage}`, }); new GuAlarm(this, "PostPaymentError", { app, alarmName: `[CDK] ${app} ${this.stage} Failed post-payment task for one-off contribution via the payment-api`, actionsEnabled: props.stage === "PROD", threshold: 1, evaluationPeriods: 1, comparisonOperator: ComparisonOperator.GREATER_THAN_THRESHOLD, metric: new Metric({ metricName: "payment-error", namespace: "post-payment-tasks-error", statistic: "Sum", period: Duration.seconds(60), }), treatMissingData: TreatMissingData.NOT_BREACHING, snsTopicName: `alarms-handler-topic-${this.stage}`, }); new GuAlarm(this, "StripeRateLimitingAlarm", { app, alarmName: `[CDK] ${app} ${this.stage} One or more requests have exceeded the rate limit for Stripe one-off contribution via the payment-api in the last 15 mins`, actionsEnabled: props.stage === "PROD", threshold: 0, evaluationPeriods: 1, comparisonOperator: ComparisonOperator.GREATER_THAN_THRESHOLD, metric: new Metric({ metricName: "stripe-rate-limit-exceeded", namespace: `support-payment-api-${this.stage}`, dimensionsMap: { "payment-provider": "Stripe", }, statistic: "Sum", period: Duration.seconds(900), }), treatMissingData: TreatMissingData.NOT_BREACHING, snsTopicName: `alarms-handler-topic-${this.stage}`, }); } }