cdk/lib/newsletters-tool.ts (269 lines of code) (raw):
import { GuNodeApp } from '@guardian/cdk';
import { AccessScope } from '@guardian/cdk/lib/constants';
import {
GuDistributionBucketParameter,
GuStack,
type GuStackProps,
GuStringParameter,
} from '@guardian/cdk/lib/constructs/core';
import { GuCname } from '@guardian/cdk/lib/constructs/dns';
import { GuHttpsEgressSecurityGroup } from '@guardian/cdk/lib/constructs/ec2';
import { type App, aws_ses, Duration, SecretValue, Tags } from 'aws-cdk-lib';
import { InstanceClass, InstanceSize, InstanceType } from 'aws-cdk-lib/aws-ec2';
import {
ApplicationListenerRule,
ListenerAction,
ListenerCondition,
UnauthenticatedAction,
} from 'aws-cdk-lib/aws-elasticloadbalancingv2';
import { StringParameter } from 'aws-cdk-lib/aws-ssm';
import { Effect, PolicyStatement } from 'aws-cdk-lib/aws-iam';
import { GuPolicy } from '@guardian/cdk/lib/constructs/iam';
import { GuS3Bucket } from '@guardian/cdk/lib/constructs/s3';
import { EmailIdentity } from 'aws-cdk-lib/aws-ses';
export interface NewslettersToolProps extends GuStackProps {
domainNameTool: string;
domainNameApi: string;
}
export class NewslettersTool extends GuStack {
constructor(scope: App, id: string, props: NewslettersToolProps) {
super(scope, id, props);
this.setUpNodeEc2(props);
}
/**
* Generates user data for startup of EC2 instance
* User data is a set of instructions to supply to the instance at launch
* @see https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/user-data.html
*/
private getUserData = (
app: string,
bucketName: string,
readOnly: boolean,
userPermissions: string,
enableEmailService: string,
) => {
// Fetches distribution S3 bucket name from account
const distributionBucketParameter =
GuDistributionBucketParameter.getInstance(this);
return [
'#!/bin/bash', // "Shebang" to instruct the program loader to run this as a bash script
'set -e', // Exits immediately if something returns a non-zero status (errors)
'set +x', // Prevents shell from printing statements before execution
`aws s3 cp s3://${distributionBucketParameter.valueAsString}/${this.stack}/${this.stage}/${app}/${app}.zip /tmp`, // copies zipped file from s3
`mkdir -p /opt/${app}`, // make more permanent directory for app to be unzipped into
`unzip /tmp/${app}.zip -d /opt/${app}`, // unzip the downloaded zip from /tmp into directory in /opt instead
`chown -R ubuntu /opt/${app}`, // change ownership of the copied files to ubuntu user
`# write out systemd file
cat >/etc/systemd/system/newsletters-api.service <<EOL
[Unit]
Description=${app}
After=network.target
[Service]
WorkingDirectory=/opt/${app}
Type=simple
User=ubuntu
StandardError=journal
StandardOutput=journal
ExecStart=/usr/bin/node /opt/${app}/dist/apps/newsletters-api/index.cjs
Restart=on-failure
Environment=STAGE=${this.stage}
Environment=STACK=${this.stack}
Environment=APP=${app}
Environment=NEWSLETTERS_API_READ=${readOnly ? 'true' : 'false'}
Environment=NEWSLETTERS_UI_SERVE=${readOnly ? 'false' : 'true'}
Environment=NEWSLETTER_BUCKET_NAME=${bucketName}
Environment=USE_IN_MEMORY_STORAGE=false
Environment=ENABLE_DYNAMIC_IMAGE_SIGNING=${readOnly ? 'true' : 'false'}
Environment=ENABLE_EMAIL_SERVICE=${enableEmailService}
[Install]
WantedBy=multi-user.target
EOL`,
`systemctl enable newsletters-api`, // enable the service
`systemctl start newsletters-api`, // start the service
].join('\n');
};
private setUpNodeEc2 = (props: NewslettersToolProps) => {
const { domainNameTool, domainNameApi } = props;
const toolAppName = 'newsletters-tool';
const apiAppName = 'newsletters-api';
// To avoid exposing the bucket name publicly, fetches the bucket name from SSM (parameter store).
const bucketSSMParameterName = `/${this.stage}/${this.stack}/${apiAppName}/s3BucketName`;
const bucketName = StringParameter.valueForStringParameter(
this,
bucketSSMParameterName,
);
const enableEmailSSMParameterName = `/${this.stage}/${this.stack}/${toolAppName}/enableEmailService`;
const enableEmailService = StringParameter.valueForStringParameter(
this,
enableEmailSSMParameterName,
);
const dataStorageBucket = new GuS3Bucket(this, 'DataBucket', {
bucketName,
app: toolAppName,
versioned: true,
});
Tags.of(dataStorageBucket).add('Name', bucketName);
const sesVerifiedIdentity = new EmailIdentity(this, 'EmailIdentity', {
identity: aws_ses.Identity.domain(domainNameTool),
});
sesVerifiedIdentity.dkimRecords.forEach(({ name, value }, index) => {
new GuCname(this, `EmailIdentityDkim${index}`, {
app: toolAppName,
domainName: name,
resourceRecord: value,
ttl: Duration.hours(1),
});
});
const s3AccessPolicy = new GuPolicy(this, `s3-access-policy`, {
policyName: 'readWriteAccessToDataBucket',
statements: [
new PolicyStatement({
sid: 'writeToDataStorageBucketPolicy',
effect: Effect.ALLOW,
actions: [
's3:PutObject',
's3:GetObject',
's3:GetObjectVersion',
's3:DeleteObject',
's3:ListBucket',
's3:DeleteObject',
's3:DeleteObjectVersion',
],
resources: [
`${dataStorageBucket.bucketArn}/*`,
`${dataStorageBucket.bucketArn}`,
],
}),
],
});
const sendEmailPolicy = new GuPolicy(this, `send-email-policy`, {
policyName: 'sendEmailPolicy',
statements: [
new PolicyStatement({
effect: Effect.ALLOW,
actions: ['ses:SendEmail'],
resources: [
`arn:aws:ses:${this.region}:${this.account}:identity/${sesVerifiedIdentity.emailIdentityName}`,
],
}),
],
});
const userPermissions = new GuStringParameter(this, 'User Permissions', {
description: 'The JSON string of user permissions',
default: `/${this.stage}/${this.stack}/${toolAppName}/userPermissions`,
fromSSM: true,
});
const alarmsTopicName = 'newsletters-alerts';
/** Sets up Node app to be run in EC2 */
const ec2AppTool = new GuNodeApp(this, {
access: { scope: AccessScope.PUBLIC },
certificateProps: { domainName: domainNameTool },
monitoringConfiguration: {
http5xxAlarm: { tolerated5xxPercentage: 5 },
snsTopicName: alarmsTopicName,
unhealthyInstancesAlarm: true,
},
instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.MICRO),
// Minimum of 1 EC2 instance running at a time. If one fails, scales up to 2 before dropping back to 1 again
scaling: { minimumInstances: 1, maximumInstances: 2 },
// Instructions to set up the environment in the instance
userData: this.getUserData(
toolAppName,
bucketName,
false,
userPermissions.valueAsString,
enableEmailService,
),
roleConfiguration: {
additionalPolicies: [s3AccessPolicy, sendEmailPolicy],
},
app: toolAppName,
accessLogging: {
enabled: true,
prefix: `ELBLogs/${this.stack}/${toolAppName}/${this.stage}`,
},
applicationLogging: {
enabled: true,
systemdUnitName: 'newsletters-api',
},
});
const ec2AppApi = new GuNodeApp(this, {
access: { scope: AccessScope.PUBLIC },
certificateProps: { domainName: domainNameApi },
monitoringConfiguration: {
http5xxAlarm: { tolerated5xxPercentage: 5 },
snsTopicName: alarmsTopicName,
unhealthyInstancesAlarm: true,
},
instanceType: InstanceType.of(InstanceClass.T4G, InstanceSize.MICRO),
scaling: { minimumInstances: 1, maximumInstances: 2 },
userData: this.getUserData(
apiAppName,
bucketName,
true,
userPermissions.valueAsString,
'false',
),
roleConfiguration: {
additionalPolicies: [s3AccessPolicy],
},
app: apiAppName,
applicationLogging: {
enabled: true,
systemdUnitName: 'newsletters-api',
},
});
const readOnlyEndpointApiKeyParam = `/${this.stage}/${this.stack}/${apiAppName}/readOnlyEndpointApiKey`;
const readOnlyEndpointApiKey = StringParameter.valueForStringParameter(
this,
readOnlyEndpointApiKeyParam,
);
new ApplicationListenerRule(this, 'ReadOnlyApiHeaderRule', {
listener: ec2AppApi.listener,
priority: 1,
conditions: [
ListenerCondition.httpHeader('X-Gu-API-Key', [readOnlyEndpointApiKey]),
],
targetGroups: [ec2AppApi.targetGroup],
});
new ApplicationListenerRule(this, 'BlockRequests', {
listener: ec2AppApi.listener,
priority: 2,
conditions: [ListenerCondition.pathPatterns(['*'])],
action: ListenerAction.fixedResponse(403, {
contentType: 'application/json',
messageBody:
'{"error": "You are not authorised to access this endpoint"}',
}),
});
/** Security group to allow load balancer to egress to 443 for OIDC flow using Google auth */
const lbEgressSecurityGroup = new GuHttpsEgressSecurityGroup(
this,
'IdP Access',
{ app: toolAppName, vpc: ec2AppTool.vpc },
);
/** Add security group to EC2 load balancer */
ec2AppTool.loadBalancer.addSecurityGroup(lbEgressSecurityGroup);
/** Fetch params from SSM */
const clientId = new GuStringParameter(this, 'Google Client ID', {
description: 'Google OAuth client ID',
default: `/${this.stage}/${this.stack}/${toolAppName}/googleClientId`,
fromSSM: true,
});
/** Add Google authentication layer to the EC2 load balancer */
ec2AppTool.listener.addAction('Google Auth', {
action: ListenerAction.authenticateOidc({
authorizationEndpoint: 'https://accounts.google.com/o/oauth2/v2/auth',
issuer: 'https://accounts.google.com',
scope: 'openid email profile',
authenticationRequestExtraParams: { hd: 'guardian.co.uk' },
onUnauthenticatedRequest: UnauthenticatedAction.AUTHENTICATE,
tokenEndpoint: 'https://oauth2.googleapis.com/token',
userInfoEndpoint: 'https://openidconnect.googleapis.com/v1/userinfo',
clientId: clientId.valueAsString,
clientSecret: SecretValue.secretsManager(
`/${this.stage}/deploy/newsletters/clientSecret`,
),
next: ListenerAction.forward([ec2AppTool.targetGroup]),
}),
});
/**
* Sets up CNAME record for domainName specified
* @see https://en.wikipedia.org/wiki/CNAME_record
*/
new GuCname(this, 'CNAME', {
app: toolAppName,
domainName: domainNameTool,
ttl: Duration.hours(1),
resourceRecord: ec2AppTool.loadBalancer.loadBalancerDnsName,
});
new GuCname(this, 'NewslettersAPICname', {
app: apiAppName,
domainName: domainNameApi,
ttl: Duration.hours(1),
resourceRecord: ec2AppApi.loadBalancer.loadBalancerDnsName,
});
};
}