cdk/lib/constructs/database.ts (150 lines of code) (raw):
import type { AppIdentity, GuStack } from '@guardian/cdk/lib/constructs/core';
import {
GuSecurityGroup,
GuVpc,
SubnetType,
} from '@guardian/cdk/lib/constructs/ec2';
import { GuAppAwareConstruct } from '@guardian/cdk/lib/utils/mixin/app-aware-construct';
import { ArnFormat, RemovalPolicy, Stack } from 'aws-cdk-lib';
import type { IVpc } from 'aws-cdk-lib/aws-ec2';
import { Port } from 'aws-cdk-lib/aws-ec2';
import type { IGrantable } from 'aws-cdk-lib/aws-iam';
import { Grant } from 'aws-cdk-lib/aws-iam';
import type { CfnDBInstance, DatabaseInstanceProps } from 'aws-cdk-lib/aws-rds';
import { DatabaseInstance, DatabaseInstanceEngine } from 'aws-cdk-lib/aws-rds';
import {
ParameterDataType,
ParameterTier,
StringParameter,
} from 'aws-cdk-lib/aws-ssm';
interface GuDatabaseProps
extends AppIdentity,
Omit<
DatabaseInstanceProps,
// The following are required props on `DatabaseInstanceProps`.
// Make them optional, with sensible defaults.
// They're still settable though.
| 'engine' // Defaults to Postgres.
| 'vpc' // `vpc` is a required property in `DatabaseInstanceProps`. It's optional here, and defaults to the primary VPC.
// The following are optional props on `DatabaseInstanceProps`.
// Remove them to offer better defaults.
| 'storageEncrypted' // Always encrypted.
| 'securityGroups' // We create our own explicit security group, and optionally wire it into an SSM Parameter.
> {
/**
* The database engine;
*
* @default DatabaseInstanceEngine.POSTGRES
*/
engine?: DatabaseInstanceProps['engine'];
/**
* The VPC network where the DB subnet group should be created.
*
* @default The account's Primary VPC
*/
vpc?: IVpc;
/**
* The identifier of the CA certificate for this DB instance.
* Ensure to add the certificate to the environment's trust store.
*
* @default rds-ca-rsa2048-g1
*
* @see https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.SSL.html#UsingWithRDS.SSL.RegionCertificateAuthorities
* @see https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/UsingWithRDS.SSL.html#UsingWithRDS.SSL.CertificatesAllRegions
*/
caCertificateIdentifier?:
| 'rds-ca-2019'
| 'rds-ca-rsa2048-g1'
| 'rds-ca-rsa4096-g1'
| 'rds-ca-ecc384-g1';
/**
* Create SSM Parameters holding the security group, and endpoint address for easy use by downstream systems.
*
* The SSM Parameters created are:
* - /STAGE/STACK/APP/database/access-security-group
* - /STAGE/STACK/APP/database/endpoint-address
*
* @default false
*/
allowExternalConnection?: boolean;
/**
* Enable standard backup strategy provided by Devx Backups:
* - 14 day rolling window
* - Point in time recovery
* - Stored in AWS Backup vault locked into compliance mode
*
* @default false
*/
devxBackups?: boolean;
}
/**
* A Postgres database instance with the following defaults:
* - Storage encryption
* - Placement in the Primary VPC, and in the private subnets
* - A Certificate Authority of rds-ca-rsa2048-g1, which supports auto-rotation
*
* TODO:
* - Move to the GuCDK library (https://github.com/guardian/cdk/issues/1786)
* - Contribute grantConnect patch upstream to AWS CDK (https://github.com/aws/aws-cdk/issues/11851)
*/
export class GuDatabase extends GuAppAwareConstruct(DatabaseInstance) {
/**
* The AWS Region-unique, immutable identifier for the DB instance.
* This identifier is found in AWS CloudTrail log entries whenever the AWS KMS key for the DB instance is accessed.
*
* @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-rds-dbinstance.html#aws-resource-rds-dbinstance-return-values
*/
public readonly instanceResourceId: string;
/**
* The security group that applications should use to gain access to the database.
*/
public readonly accessSecurityGroup: GuSecurityGroup;
private get cfnResource(): CfnDBInstance {
return this.node.defaultChild as CfnDBInstance;
}
constructor(scope: GuStack, id: string, props: GuDatabaseProps) {
const {
app,
allowExternalConnection = false,
devxBackups = false,
caCertificateIdentifier = 'rds-ca-rsa2048-g1',
vpc = GuVpc.fromIdParameter(scope, 'primary-vpc'),
vpcSubnets = {
subnets: GuVpc.subnetsFromParameter(scope, {
type: SubnetType.PRIVATE,
app,
}),
},
port = 5432,
engine = DatabaseInstanceEngine.POSTGRES,
} = props;
const defaultSecurityGroup = new GuSecurityGroup(
scope,
'DefaultSecurityGroup',
{
vpc,
app,
},
);
const defaults: DatabaseInstanceProps = {
vpc,
vpcSubnets,
engine,
port,
storageEncrypted: true,
deletionProtection: true,
removalPolicy: RemovalPolicy.SNAPSHOT,
publiclyAccessible: false,
iamAuthentication: true,
multiAz: true,
securityGroups: [defaultSecurityGroup],
};
super(scope, id, { ...defaults, ...props });
this.instanceResourceId = this.cfnResource.attrDbiResourceId;
this.accessSecurityGroup = defaultSecurityGroup;
this.cfnResource.caCertificateIdentifier = caCertificateIdentifier;
this.connections.allowFrom(defaultSecurityGroup, Port.tcp(port));
this.cfnResource.tags.setTag('devx-backup-enabled', String(devxBackups));
if (allowExternalConnection) {
const { stack, stage } = scope;
new StringParameter(this, 'AccessSecurityGroupParam', {
parameterName: `/${stage}/${stack}/${app}/database/access-security-group`,
simpleName: false,
stringValue: defaultSecurityGroup.securityGroupId,
tier: ParameterTier.STANDARD,
dataType: ParameterDataType.TEXT,
});
new StringParameter(this, 'EndpointAddressParam', {
parameterName: `/${stage}/${stack}/${app}/database/endpoint-address`,
simpleName: false,
stringValue: this.dbInstanceEndpointAddress,
tier: ParameterTier.STANDARD,
dataType: ParameterDataType.TEXT,
});
new StringParameter(this, 'UsernameParam', {
parameterName: `/${stage}/${stack}/${app}/database/username`,
simpleName: false,
stringValue: props.credentials?.username ?? 'postgres',
tier: ParameterTier.STANDARD,
dataType: ParameterDataType.TEXT,
});
new StringParameter(this, 'PortParam', {
parameterName: `/${stage}/${stack}/${app}/database/port`,
simpleName: false,
stringValue: this.dbInstanceEndpointPort,
tier: ParameterTier.STANDARD,
dataType: ParameterDataType.TEXT,
});
new StringParameter(this, 'DatabaseNameParam', {
parameterName: `/${stage}/${stack}/${app}/database/database-name`,
simpleName: false,
stringValue: props.databaseName ?? 'postgres',
tier: ParameterTier.STANDARD,
dataType: ParameterDataType.TEXT,
});
}
}
// Fixes https://github.com/aws/aws-cdk/issues/11851
override grantConnect(grantee: IGrantable): Grant {
if (this.enableIamAuthentication === false) {
throw new Error(
'Cannot grant connect when IAM authentication is disabled',
);
}
const { instanceResourceId } = this;
const { masterUsername } = this.cfnResource;
return Grant.addToPrincipal({
grantee,
actions: ['rds-db:connect'],
resourceArns: [
Stack.of(this).formatArn({
arnFormat: ArnFormat.COLON_RESOURCE_NAME,
service: 'rds-db',
resource: 'dbuser',
resourceName: masterUsername
? [instanceResourceId, masterUsername].join('/')
: instanceResourceId,
}),
],
});
}
}