cdk/lib/stripe-webhook-endpoints.ts (213 lines of code) (raw):
import path from 'path';
import { GuApiGatewayWithLambdaByPath } from '@guardian/cdk';
import { GuAlarm } from '@guardian/cdk/lib/constructs/cloudwatch';
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 { Duration } from 'aws-cdk-lib';
import { CfnBasePathMapping, CfnDomainName } from 'aws-cdk-lib/aws-apigateway';
import { ComparisonOperator, Metric } from 'aws-cdk-lib/aws-cloudwatch';
import { Effect, Policy, PolicyStatement } from 'aws-cdk-lib/aws-iam';
import { Runtime } from 'aws-cdk-lib/aws-lambda';
import { CfnRecordSet } from 'aws-cdk-lib/aws-route53';
import { CfnInclude } from 'aws-cdk-lib/cloudformation-include';
export interface StripeWebhookEndpointsProps extends GuStackProps {
stack: string;
stage: string;
certificateId: string;
domainName: string;
hostedZoneId: string;
}
export class StripeWebhookEndpoints extends GuStack {
constructor(scope: App, id: string, props: StripeWebhookEndpointsProps) {
super(scope, id, props);
const app = 'stripe-webhook-endpoints';
// ---- Existing CFN template ---- //
const yamlTemplateFilePath = path.join(
__dirname,
'../..',
'handlers/stripe-webhook-endpoints/cfn.yaml',
);
new CfnInclude(this, 'YamlTemplate', {
templateFile: yamlTemplateFilePath,
});
// ---- API-triggered lambda functions ---- //
const paymentIntentIssuesLambda = new GuLambdaFunction(
this,
'payment-intent-issues-cdk-lambda',
{
app: app,
description:
'A lambda for handling payment intent issues (cancellation, failure, action required)',
functionName: `stripe-payment-intent-issues-cdk-${this.stage}`,
fileName: `${app}.jar`,
handler: 'com.gu.paymentIntentIssues.Lambda::handler',
runtime: Runtime.JAVA_21,
memorySize: 1536,
timeout: Duration.seconds(300),
environment: {
App: app,
Stack: this.stack,
Stage: this.stage,
},
},
);
const customerUpdatedLambda = new GuLambdaFunction(
this,
'customer-updated-cdk-lambda',
{
app: app,
description: 'A lambda for handling customer updates',
functionName: `stripe-customer-updated-cdk-${this.stage}`,
fileName: `${app}.jar`,
handler: 'com.gu.stripeCardUpdated.Lambda::apply',
runtime: Runtime.JAVA_21,
memorySize: 1536,
timeout: Duration.seconds(900),
environment: {
App: app,
Stack: this.stack,
Stage: this.stage,
},
},
);
// Wire up the API
// ---- API gateway ---- //
const stripeWebhookEndpointsApi = new GuApiGatewayWithLambdaByPath(this, {
app: 'stripe-webhook-endpoints',
targets: [
{
path: '/payment-intent-issue',
httpMethod: 'POST',
lambda: paymentIntentIssuesLambda,
},
{
path: '/customer-updated',
httpMethod: 'POST',
lambda: customerUpdatedLambda,
},
],
// Create an alarm
monitoringConfiguration: {
snsTopicName: 'conversion-dev',
http5xxAlarm: {
tolerated5xxPercentage: 1,
},
},
});
// ---- Alarms ---- //
const alarmName = (shortDescription: string) =>
`Stripe Webhook Endpoints CDK ${this.stage} ${shortDescription}`;
const alarmDescription = (description: string) =>
`Impact - ${description}. Follow the process in https://docs.google.com/document/d/1_3El3cly9d7u_jPgTcRjLxmdG2e919zCLvmcFCLOYAk/edit`;
new GuAlarm(this, 'ApiGateway4XXAlarmCDK', {
app,
alarmName: alarmName('API gateway 4XX response'),
alarmDescription: alarmDescription(
'Stripe Webhook Endpoints received an invalid request',
),
evaluationPeriods: 1,
threshold: 1,
snsTopicName: 'conversion-dev',
actionsEnabled: this.stage === 'PROD',
comparisonOperator: ComparisonOperator.GREATER_THAN_OR_EQUAL_TO_THRESHOLD,
metric: new Metric({
metricName: '4XXError',
namespace: 'AWS/ApiGateway',
statistic: 'Sum',
period: Duration.seconds(300),
dimensionsMap: {
ApiName: `${app}-${this.stage}`,
},
}),
});
new GuAlarm(this, 'ApiGateway5XXAlarmCDK', {
app,
alarmName: alarmName('API gateway 5XX error'),
alarmDescription: `stripe-webhook-endpoints-${this.stage} exceeded 1% 5XX error rate`,
evaluationPeriods: 1,
threshold: 1,
actionsEnabled: this.stage === 'PROD',
snsTopicName: 'conversion-dev',
comparisonOperator: ComparisonOperator.GREATER_THAN_THRESHOLD,
metric: new Metric({
metricName: '5XXError',
namespace: 'AWS/ApiGateway',
statistic: 'Sum',
period: Duration.seconds(60),
dimensionsMap: {
ApiName: `${app}-${this.stage}`,
},
}),
});
// ---- DNS ---- //
const certificateArn = `arn:aws:acm:${this.region}:${this.account}:certificate/${props.certificateId}`;
const cfnDomainName = new CfnDomainName(
this,
'StripeWebhookEndpointsDomainName',
{
domainName: props.domainName,
regionalCertificateArn: certificateArn,
endpointConfiguration: {
types: ['REGIONAL'],
},
},
);
new CfnBasePathMapping(this, 'BasePathMapping', {
domainName: cfnDomainName.ref,
// Uncomment the lines below to reroute traffic to the new API Gateway instance
restApiId: stripeWebhookEndpointsApi.api.restApiId,
stage: stripeWebhookEndpointsApi.api.deploymentStage.stageName,
// Uncomment the lines below to reroute traffic to the old (existing) API Gateway instance
// restApiId: yamlDefinedResources.getResource("ServerlessRestApi").ref,
// stage: props.stage,
});
new CfnRecordSet(this, 'DNSRecord', {
name: props.domainName,
type: 'CNAME',
hostedZoneId: props.hostedZoneId,
ttl: '120',
resourceRecords: [cfnDomainName.attrRegionalDomainName],
});
// ---- Apply policies ---- //
const ssmInlinePolicy: Policy = new Policy(this, 'SSM inline policy', {
statements: [
new PolicyStatement({
effect: Effect.ALLOW,
actions: ['ssm:GetParametersByPath'],
resources: [
`arn:aws:ssm:${this.region}:${this.account}:parameter/${props.stage}/membership/payment-intent-issues/*`,
],
}),
],
});
const s3InlinePolicyForPaymentIntentIssues: Policy = new Policy(
this,
'S3 inline policy',
{
statements: [
new PolicyStatement({
effect: Effect.ALLOW,
actions: ['s3:GetObject'],
resources: ['arn:aws:s3::*:membership-dist/*'],
}),
],
},
);
const s3InlinePolicyForCustomerUpdated: Policy = new Policy(
this,
'S3 inline policy For Customer Updated lambda',
{
statements: [
new PolicyStatement({
effect: Effect.ALLOW,
actions: ['s3:GetObject'],
resources: [
`arn:aws:s3::*:membership-dist/*`,
`arn:aws:s3:::gu-reader-revenue-private/membership/support-service-lambdas/${props.stage}/zuoraRest-${props.stage}.*.json`,
`arn:aws:s3:::gu-reader-revenue-private/membership/support-service-lambdas/${props.stage}/trustedApi-${props.stage}.*.json`,
`arn:aws:s3:::gu-reader-revenue-private/membership/support-service-lambdas/${props.stage}/stripe-${props.stage}.*.json`,
],
}),
],
},
);
paymentIntentIssuesLambda.role?.attachInlinePolicy(ssmInlinePolicy);
paymentIntentIssuesLambda.role?.attachInlinePolicy(
s3InlinePolicyForPaymentIntentIssues,
);
customerUpdatedLambda.role?.attachInlinePolicy(ssmInlinePolicy);
customerUpdatedLambda.role?.attachInlinePolicy(
s3InlinePolicyForCustomerUpdated,
);
}
}