Create a secure AWS RDS instance with CDK

rodrigo carvajal - Sep 15 - - Dev Community

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

Image description

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
  • 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,
        },
    },
});
Enter fullscreen mode Exit fullscreen mode

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,
    },
});
Enter fullscreen mode Exit fullscreen mode

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',
    },
});
Enter fullscreen mode Exit fullscreen mode

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: Since SubnetType.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 and performanceInsightEncryptionKey: 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 and parameters: 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',
    }),
});
Enter fullscreen mode Exit fullscreen mode

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,
                }),
            }),
        },
    ],
});
Enter fullscreen mode Exit fullscreen mode

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',
        }),
    ],
});
Enter fullscreen mode Exit fullscreen mode

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. 🖖🏽

.
Terabox Video Player