Create a secure AWS RDS instance with CDK
rodrigo carvajal
Posted on September 15, 2024
TLDR; This is the GitHub package with the code. This is the class that has everything. You can consume it as an NPM module too. This is not comprehensive, and many things can be added to make it better.
Background
Building infrastructure is complicated. Building secure infrastructure is even more complicated. With the vast amount of features provided by cloud providers and all the possible combinations of requirements, we can’t possibly expect to have a “how-to” guide for everything. This is the first in a series of posts going through examples of creating secure infrastructure and all the possible security features I can think of adding to it based on 10 years of experience using AWS. This specific post is for getting started on PostgreSQL on AWS RDS.
Overall picture
This creates an RDS instance with two points of access (a bastion host and a network load balancer). All the data is encrypted at rest, the root credentials are rotated daily, and all queries are logged to CloudWatch. To be able to actually run a query, there are two paths:
-
Through the bastion. Requires:
- Having key pair
.pem
file - Making the query through a specific IP
- Have access to the console or the EC2 API to get the bastion address
- Have access to the console or the SM API to get the credentials
- Having key pair
-
Through the NLB. Requires:
- Making the query through a specific IP
- Have access to the console or the EC2 API to get the NLB address
- Have access to the console or the SM API to get the credentials.
Networking
The core of the networking section is the VPC.
this.vpc = new Vpc(scope, 'MainVPC', {
ipAddresses: IpAddresses.cidr('10.0.0.0/16'),
vpcName: 'MainVPC',
subnetConfiguration: [
{
name: 'private',
subnetType: SubnetType.PRIVATE_WITH_EGRESS,
},
{
name: 'public',
subnetType: SubnetType.PUBLIC,
},
],
maxAzs: 2,
natGatewayProvider: NatProvider.instanceV2({
instanceType: InstanceType.of(InstanceClass.T3, InstanceSize.MICRO),
}),
natGateways: 1,
flowLogs: {
cw: {
destination: FlowLogDestination.toCloudWatchLogs(new LogGroup(scope, 'VPCFlowLogs', {
encryptionKey: flowLogsKmsKey,
removalPolicy,
logGroupName: 'vpcflowlogs',
retention: RetentionDays.ONE_YEAR,
})),
trafficType: FlowLogTrafficType.ALL,
},
},
});
This creates a VPC with two public subnets and two private subnets. For the purpose of cost savings, it uses a NAT instance EC2 rather than regular NAT gateways. Use NAT gateways for real production environments. Most importantly, it sends the VPC flow logs to CloudWatch, which can be used to monitor traffic in the VPC.
Another important aspect is the VPC endpoint for Secrets Manager:
this.vpc.addInterfaceEndpoint('SMEndpoint', {
service: InterfaceVpcEndpointAwsService.SECRETS_MANAGER,
subnets: {
subnetType: SubnetType.PRIVATE_WITH_EGRESS,
},
});
This ensures that if any resource inside the private subnets calls Secrets Manager, the calls are directed through the endpoint, avoiding the public internet.
You can see the security groups and their rules in the source code, but at a high level, the bastion and NLB can connect to RDS, and only specific public IP addresses can connect to the NLB and bastion.
RDS
The RDS instance has a large number of possible settings; I’ll be highlighting the important ones related to security here.
this.rds = new DatabaseInstance(scope, 'RDSDB', {
engine: DatabaseInstanceEngine.postgres({
version: PostgresEngineVersion.VER_16,
}),
vpc: this.vpc,
allowMajorVersionUpgrade: true,
credentials: Credentials.fromGeneratedSecret(props.appName, {
secretName: props.appName,
encryptionKey: new Key(scope, 'RDSDBSecretKMSKey', {
enableKeyRotation: true,
alias: 'RDSDBSecretKMSKey',
removalPolicy,
}),
}),
databaseName: props.appName,
iamAuthentication: true,
instanceIdentifier: props.appName,
instanceType: dbInstanceType,
removalPolicy,
storageEncryptionKey: new Key(scope, 'RDSDBKMSKey', {
enableKeyRotation: true,
alias: 'RDSDBDBKMSKey',
removalPolicy,
}),
vpcSubnets: {
subnetType: SubnetType.PRIVATE_WITH_EGRESS,
},
securityGroups: [
this.rdsSecurityGroup,
],
port: rdsPort,
allocatedStorage: dbStorageGib,
storageType: StorageType.GP3,
monitoringInterval: Duration.minutes(1),
enablePerformanceInsights: true,
performanceInsightRetention: PerformanceInsightRetention.MONTHS_3,
performanceInsightEncryptionKey: new Key(scope, 'RDSDBInsightsKMSKey', {
enableKeyRotation: true,
alias: 'RDSDBInsightsKMSKey',
removalPolicy: RemovalPolicy.DESTROY,
}),
cloudwatchLogsRetention: RetentionDays.ONE_YEAR,
cloudwatchLogsExports: [
'postgresql',
],
parameters: {
log_min_duration_statement: '0',
},
});
Here they are:
-
credentials
: This creates a random password stored in a secret in SM. The secret also has a KMS key, which means that accessing it requires multiple permissions and it is encrypted at rest. -
storageEncryptionKey
: Adds encryption to the storage at rest. -
vpcSubnets
: SinceSubnetType.PRIVATE_WITH_EGRESS
is specified, it means the RDS endpoint is not accessible from the public internet. This is crucial as it means that only resources from inside the VPC can actually make a connection to it. -
enablePerformanceInsights
,performanceInsightRetention
andperformanceInsightEncryptionKey
: This enables Performance Insights, while technically not security related, it is great to find bad queries via this feature that could be anomalous in nature and origin. Also, makes these logs encrypted at rest. -
cloudwatchLogsRetention
,cloudwatchLogsExports
andparameters
: These make it so that every single query is logged into CloudWatch, where monitoring and alarming can be set up.
Additionally, we make the root user password be rotated every day:
this.rds.secret!.addRotationSchedule('RDSDBSecretRotation', {
automaticallyAfter: passwordRotationInterval,
hostedRotation: HostedRotation.postgreSqlSingleUser({
vpc: this.vpc,
vpcSubnets: {
subnetType: SubnetType.PRIVATE_WITH_EGRESS,
},
securityGroups: [
rdsRotationSecurityGroup,
],
functionName: 'RDSDBSecretRotation',
}),
});
Bastion host
The bastion host, which acts like a tunnel so people can run queries, has nothing special aside from not having any permissions to do anything and having the boot drive encrypted at rest.
this.bastion = new Instance(scope, 'BastionHost', {
instanceType: InstanceType.of(InstanceClass.T3, InstanceSize.MICRO),
machineImage: MachineImage.latestAmazonLinux2023(),
vpc: this.vpc,
vpcSubnets: {
subnetType: SubnetType.PUBLIC,
},
keyPair: KeyPair.fromKeyPairName(scope, 'BastionKeyPair', props.bastionKeyPairName),
securityGroup: this.bastionSecurityGroup,
blockDevices: [
{
deviceName: '/dev/xvda',
mappingEnabled: true,
volume: BlockDeviceVolume.ebs(8, {
deleteOnTermination: true,
volumeType: EbsDeviceVolumeType.GP3,
encrypted: true,
kmsKey: new Key(scope, 'BastionKMSKey', {
enableKeyRotation: true,
alias: 'BastionKMSKey',
removalPolicy,
}),
}),
},
],
});
Network load balancer
This is a tricky part. You can’t directly make an RDS instance a target of an NLB; additionally, the CDK constructs don’t expose the private IP address of the RDS instance. Because of this, I created a custom resource that makes an API call to get the address and pass it as a target for the NLB. You can see the source code for more details on the custom resource.
this.nlb.addListener('RDSListener', {
port: rdsPort,
defaultTargetGroups: [
new NetworkTargetGroup(scope, 'RDSTargetGroup', {
port: rdsPort,
targetType: TargetType.IP,
targets: [
new IpTarget(getPrivateIp.getResponseField('NetworkInterfaces.0.PrivateIpAddress')),
],
vpc: this.vpc,
targetGroupName: 'RDSTargetGroup',
}),
],
});
Conclusion
There you have it, a (mostly) straightforward way to create a secured RDS instance. However, this is not comprehensive. Many things, like CloudTrail and S3 IAM roles, can be added. And I will cover these in future posts. Feel free to reach out with questions or comments.
Live long and prosper. 🖖🏽
Posted on September 15, 2024
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.