Skip to content

Commit

Permalink
feat(rds,secretsmanager): subnets and endpoint configuration for secr…
Browse files Browse the repository at this point in the history
…et rotation (aws#17363)

Add options to configure vpc subnet placement and Secrets Manager API
endpoint for the rotation Lambda function.

This is required in some VPC configurations where the database is placed
in subnets without internet connectivity.

Closes aws#17265


----

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
jogold authored and TikiTDO committed Feb 21, 2022
1 parent aca6ded commit 9850be8
Show file tree
Hide file tree
Showing 6 changed files with 450 additions and 1 deletion.
15 changes: 15 additions & 0 deletions packages/@aws-cdk/aws-rds/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,21 @@ instance.addRotationMultiUser('MyUser', { // Add rotation using the multi user s
**Note**: This user must be created manually in the database using the master credentials.
The rotation will start as soon as this user exists.

Access to the Secrets Manager API is required for the secret rotation. This can be achieved either with
internet connectivity (through NAT) or with a VPC interface endpoint. By default, the rotation Lambda function
is deployed in the same subnets as the instance/cluster. If access to the Secrets Manager API is not possible from
those subnets or using the default API endpoint, use the `vpcSubnets` and/or `endpoint` options:

```ts
declare const instance: rds.DatabaseInstance;
declare const myEndpoint: ec2.InterfaceVpcEndpoint;

instance.addRotationSingleUser({
vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_NAT }, // Place rotation Lambda in private subnets
endpoint: myEndpoint, // Use VPC interface endpoint
});
```

See also [@aws-cdk/aws-secretsmanager](https://github.com/aws/aws-cdk/blob/master/packages/%40aws-cdk/aws-secretsmanager/README.md) for credentials rotation of existing clusters/instances.

## IAM Authentication
Expand Down
19 changes: 19 additions & 0 deletions packages/@aws-cdk/aws-rds/lib/props.ts
Original file line number Diff line number Diff line change
Expand Up @@ -468,6 +468,25 @@ interface CommonRotationUserOptions {
* @default " %+~`#$&*()|[]{}:;<>?!'/@\"\\"
*/
readonly excludeCharacters?: string;

/**
* Where to place the rotation Lambda function
*
* @default - same placement as instance or cluster
*/
readonly vpcSubnets?: ec2.SubnetSelection;

/**
* The VPC interface endpoint to use for the Secrets Manager API
*
* If you enable private DNS hostnames for your VPC private endpoint (the default), you don't
* need to specify an endpoint. The standard Secrets Manager DNS hostname the Secrets Manager
* CLI and SDKs use by default (https://secretsmanager.<region>.amazonaws.com) automatically
* resolves to your VPC endpoint.
*
* @default https://secretsmanager.<region>.amazonaws.com
*/
readonly endpoint?: ec2.IInterfaceVpcEndpoint;
}

/**
Expand Down
191 changes: 191 additions & 0 deletions packages/@aws-cdk/aws-rds/test/cluster.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -722,6 +722,197 @@ describe('cluster', () => {

});

test('addRotationSingleUser()', () => {
// GIVEN
const stack = new cdk.Stack();
const vpc = new ec2.Vpc(stack, 'VPC');
const cluster = new DatabaseCluster(stack, 'Database', {
engine: DatabaseClusterEngine.AURORA_MYSQL,
instanceProps: {
instanceType: ec2.InstanceType.of(ec2.InstanceClass.BURSTABLE2, ec2.InstanceSize.SMALL),
vpc,
},
});

// WHEN
cluster.addRotationSingleUser();

expect(stack).toHaveResource('AWS::SecretsManager::RotationSchedule', {
SecretId: {
Ref: 'DatabaseSecretAttachmentE5D1B020',
},
RotationLambdaARN: {
'Fn::GetAtt': [
'DatabaseRotationSingleUser65F55654',
'Outputs.RotationLambdaARN',
],
},
RotationRules: {
AutomaticallyAfterDays: 30,
},
});
});

test('addRotationMultiUser()', () => {
// GIVEN
const stack = new cdk.Stack();
const vpc = new ec2.Vpc(stack, 'VPC');
const cluster = new DatabaseCluster(stack, 'Database', {
engine: DatabaseClusterEngine.AURORA_MYSQL,
instanceProps: {
instanceType: ec2.InstanceType.of(ec2.InstanceClass.BURSTABLE2, ec2.InstanceSize.SMALL),
vpc,
},
});

const userSecret = new DatabaseSecret(stack, 'UserSecret', { username: 'user' });
cluster.addRotationMultiUser('user', { secret: userSecret.attach(cluster) });

expect(stack).toHaveResource('AWS::SecretsManager::RotationSchedule', {
SecretId: {
Ref: 'UserSecretAttachment16ACBE6D',
},
RotationLambdaARN: {
'Fn::GetAtt': [
'DatabaseuserECD1FB0C',
'Outputs.RotationLambdaARN',
],
},
RotationRules: {
AutomaticallyAfterDays: 30,
},
});

expect(stack).toHaveResourceLike('AWS::Serverless::Application', {
Parameters: {
masterSecretArn: {
Ref: 'DatabaseSecretAttachmentE5D1B020',
},
},
});
});

test('addRotationSingleUser() with options', () => {
// GIVEN
const stack = new cdk.Stack();
const vpcWithIsolated = new ec2.Vpc(stack, 'Vpc', {
subnetConfiguration: [
{ name: 'public', subnetType: ec2.SubnetType.PUBLIC },
{ name: 'private', subnetType: ec2.SubnetType.PRIVATE_WITH_NAT },
{ name: 'isolated', subnetType: ec2.SubnetType.PRIVATE_ISOLATED },
],
});

// WHEN
// DB in isolated subnet (no internet connectivity)
const cluster = new DatabaseCluster(stack, 'Database', {
engine: DatabaseClusterEngine.AURORA_MYSQL,
instanceProps: {
instanceType: ec2.InstanceType.of(ec2.InstanceClass.BURSTABLE2, ec2.InstanceSize.SMALL),
vpc: vpcWithIsolated,
vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_ISOLATED },
},
});

// Rotation in private subnet (internet via NAT)
cluster.addRotationSingleUser({
automaticallyAfter: cdk.Duration.days(15),
excludeCharacters: '°_@',
vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_WITH_NAT },
});

// THEN
expect(stack).toHaveResource('AWS::SecretsManager::RotationSchedule', {
RotationRules: {
AutomaticallyAfterDays: 15,
},
});

expect(stack).toHaveResource('AWS::Serverless::Application', {
Parameters: {
endpoint: {
'Fn::Join': ['', [
'https://secretsmanager.',
{ Ref: 'AWS::Region' },
'.',
{ Ref: 'AWS::URLSuffix' },
]],
},
functionName: 'DatabaseRotationSingleUser458A45BE',
vpcSubnetIds: {
'Fn::Join': ['', [
{ Ref: 'VpcprivateSubnet1SubnetCEAD3716' },
',',
{ Ref: 'VpcprivateSubnet2Subnet2DE7549C' },
]],
},
vpcSecurityGroupIds: {
'Fn::GetAtt': [
'DatabaseRotationSingleUserSecurityGroupAC6E0E73',
'GroupId',
],
},
excludeCharacters: '°_@',
},
});
});


test('addRotationSingleUser() with VPC interface endpoint', () => {
// GIVEN
const stack = new cdk.Stack();
const vpcIsolatedOnly = new ec2.Vpc(stack, 'Vpc', { natGateways: 0 });

const endpoint = new ec2.InterfaceVpcEndpoint(stack, 'Endpoint', {
service: ec2.InterfaceVpcEndpointAwsService.SECRETS_MANAGER,
vpc: vpcIsolatedOnly,
subnets: { subnetType: ec2.SubnetType.PRIVATE_ISOLATED },
});

// DB in isolated subnet (no internet connectivity)
const cluster = new DatabaseCluster(stack, 'Database', {
engine: DatabaseClusterEngine.AURORA_MYSQL,
instanceProps: {
instanceType: ec2.InstanceType.of(ec2.InstanceClass.BURSTABLE2, ec2.InstanceSize.SMALL),
vpc: vpcIsolatedOnly,
vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE_ISOLATED },
},
});

// Rotation in isolated subnet with access to Secrets Manager API via endpoint
cluster.addRotationSingleUser({ endpoint });

expect(stack).toHaveResource('AWS::Serverless::Application', {
Parameters: {
endpoint: {
'Fn::Join': ['', [
'https://',
{ Ref: 'EndpointEEF1FD8F' },
'.secretsmanager.',
{ Ref: 'AWS::Region' },
'.',
{ Ref: 'AWS::URLSuffix' },
]],
},
functionName: 'DatabaseRotationSingleUser458A45BE',
vpcSubnetIds: {
'Fn::Join': ['', [
{ Ref: 'VpcIsolatedSubnet1SubnetE48C5737' },
',',
{ Ref: 'VpcIsolatedSubnet2Subnet16364B91' },
]],
},
vpcSecurityGroupIds: {
'Fn::GetAtt': [
'DatabaseRotationSingleUserSecurityGroupAC6E0E73',
'GroupId',
],
},
excludeCharacters: " %+~`#$&*()|[]{}:;<>?!'/@\"\\",
},
});
});

test('throws when trying to add rotation to a cluster without secret', () => {
// GIVEN
const stack = new cdk.Stack();
Expand Down
Loading

0 comments on commit 9850be8

Please sign in to comment.