Skip to content

Commit

Permalink
feat(rds): add secret rotation to DatabaseClusterFromSnapshot (aws#…
Browse files Browse the repository at this point in the history
…20020)

Bring RDS `DatabaseClusterFromSnapshot` API to parity with `DatabaseCluster` in being able to add Secrets Manager credential rotation with `addRotationSingleUser` or `addRotationMultiUser`.

My first PR here! There may be some potential to DRY up this approach by moving up the method to the parent `DatabaseClusterNew` class as for now the code is duplicative between the classes, but I am frankly not comfortable doing it myself. Any input and suggestions very welcome -- thanks in advance!

closes aws#12877

----

### All Submissions:

* [x] Have you followed the guidelines in our [Contributing guide?](https://github.com/aws/aws-cdk/blob/master/CONTRIBUTING.md)

### Adding new Unconventional Dependencies:
N/A

### New Features
N/A

*By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
  • Loading branch information
davenix-palmetto authored and wphilipw committed May 23, 2022
1 parent ca74868 commit b9870de
Show file tree
Hide file tree
Showing 2 changed files with 183 additions and 58 deletions.
153 changes: 96 additions & 57 deletions packages/@aws-cdk/aws-rds/lib/cluster.ts
Original file line number Diff line number Diff line change
Expand Up @@ -285,6 +285,7 @@ export abstract class DatabaseClusterBase extends Resource implements IDatabaseC
* Identifier of the cluster
*/
public abstract readonly clusterIdentifier: string;

/**
* Identifiers of the replicas
*/
Expand Down Expand Up @@ -345,9 +346,40 @@ abstract class DatabaseClusterNew extends DatabaseClusterBase {
protected readonly securityGroups: ec2.ISecurityGroup[];
protected readonly subnetGroup: ISubnetGroup;

/**
* Secret in SecretsManager to store the database cluster user credentials.
*/
public abstract readonly secret?: secretsmanager.ISecret;

/**
* The VPC network to place the cluster in.
*/
public readonly vpc: ec2.IVpc;

/**
* The cluster's subnets.
*/
public readonly vpcSubnets?: ec2.SubnetSelection;

/**
* Application for single user rotation of the master password to this cluster.
*/
public readonly singleUserRotationApplication: secretsmanager.SecretRotationApplication;

/**
* Application for multi user rotation to this cluster.
*/
public readonly multiUserRotationApplication: secretsmanager.SecretRotationApplication;

constructor(scope: Construct, id: string, props: DatabaseClusterBaseProps) {
super(scope, id);

this.vpc = props.instanceProps.vpc;
this.vpcSubnets = props.instanceProps.vpcSubnets;

this.singleUserRotationApplication = props.engine.singleUserRotationApplication;
this.multiUserRotationApplication = props.engine.multiUserRotationApplication;

const { subnetIds } = props.instanceProps.vpc.selectSubnets(props.instanceProps.vpcSubnets);

// Cannot test whether the subnets are in different AZs, but at least we can test the amount.
Expand Down Expand Up @@ -436,6 +468,47 @@ abstract class DatabaseClusterNew extends DatabaseClusterBase {
copyTagsToSnapshot: props.copyTagsToSnapshot ?? true,
};
}

/**
* Adds the single user rotation of the master password to this cluster.
*/
public addRotationSingleUser(options: RotationSingleUserOptions = {}): secretsmanager.SecretRotation {
if (!this.secret) {
throw new Error('Cannot add a single user rotation for a cluster without a secret.');
}

const id = 'RotationSingleUser';
const existing = this.node.tryFindChild(id);
if (existing) {
throw new Error('A single user rotation was already added to this cluster.');
}

return new secretsmanager.SecretRotation(this, id, {
...applyDefaultRotationOptions(options, this.vpcSubnets),
secret: this.secret,
application: this.singleUserRotationApplication,
vpc: this.vpc,
target: this,
});
}

/**
* Adds the multi user rotation to this cluster.
*/
public addRotationMultiUser(id: string, options: RotationMultiUserOptions): secretsmanager.SecretRotation {
if (!this.secret) {
throw new Error('Cannot add a multi user rotation for a cluster without a secret.');
}

return new secretsmanager.SecretRotation(this, id, {
...applyDefaultRotationOptions(options, this.vpcSubnets),
secret: options.secret,
masterSecret: this.secret,
application: this.multiUserRotationApplication,
vpc: this.vpc,
target: this,
});
}
}

/**
Expand Down Expand Up @@ -537,21 +610,9 @@ export class DatabaseCluster extends DatabaseClusterNew {
*/
public readonly secret?: secretsmanager.ISecret;

private readonly vpc: ec2.IVpc;
private readonly vpcSubnets?: ec2.SubnetSelection;

private readonly singleUserRotationApplication: secretsmanager.SecretRotationApplication;
private readonly multiUserRotationApplication: secretsmanager.SecretRotationApplication;

constructor(scope: Construct, id: string, props: DatabaseClusterProps) {
super(scope, id, props);

this.vpc = props.instanceProps.vpc;
this.vpcSubnets = props.instanceProps.vpcSubnets;

this.singleUserRotationApplication = props.engine.singleUserRotationApplication;
this.multiUserRotationApplication = props.engine.multiUserRotationApplication;

const credentials = renderCredentials(this, props.engine, props.credentials);
const secret = credentials.secret;

Expand All @@ -564,6 +625,10 @@ export class DatabaseCluster extends DatabaseClusterNew {

this.clusterIdentifier = cluster.ref;

if (secret) {
this.secret = secret.attach(this);
}

// create a number token that represents the port of the cluster
const portAttribute = Token.asNumber(cluster.attrEndpointPort);
this.clusterEndpoint = new Endpoint(cluster.attrEndpointAddress, portAttribute);
Expand All @@ -575,56 +640,11 @@ export class DatabaseCluster extends DatabaseClusterNew {

cluster.applyRemovalPolicy(props.removalPolicy ?? RemovalPolicy.SNAPSHOT);

if (secret) {
this.secret = secret.attach(this);
}

setLogRetention(this, props);
const createdInstances = createInstances(this, props, this.subnetGroup);
this.instanceIdentifiers = createdInstances.instanceIdentifiers;
this.instanceEndpoints = createdInstances.instanceEndpoints;
}

/**
* Adds the single user rotation of the master password to this cluster.
*/
public addRotationSingleUser(options: RotationSingleUserOptions = {}): secretsmanager.SecretRotation {
if (!this.secret) {
throw new Error('Cannot add single user rotation for a cluster without secret.');
}

const id = 'RotationSingleUser';
const existing = this.node.tryFindChild(id);
if (existing) {
throw new Error('A single user rotation was already added to this cluster.');
}

return new secretsmanager.SecretRotation(this, id, {
...applyDefaultRotationOptions(options, this.vpcSubnets),
secret: this.secret,
application: this.singleUserRotationApplication,
vpc: this.vpc,
target: this,
});
}

/**
* Adds the multi user rotation to this cluster.
*/
public addRotationMultiUser(id: string, options: RotationMultiUserOptions): secretsmanager.SecretRotation {
if (!this.secret) {
throw new Error('Cannot add multi user rotation for a cluster without secret.');
}

return new secretsmanager.SecretRotation(this, id, {
...applyDefaultRotationOptions(options, this.vpcSubnets),
secret: options.secret,
masterSecret: this.secret,
application: this.multiUserRotationApplication,
vpc: this.vpc,
target: this,
});
}
}

/**
Expand All @@ -637,6 +657,13 @@ export interface DatabaseClusterFromSnapshotProps extends DatabaseClusterBasePro
* However, you can use only the ARN to specify a DB instance snapshot.
*/
readonly snapshotIdentifier: string;

/**
* Credentials for the administrative user
*
* @default - A username of 'admin' (or 'postgres' for PostgreSQL) and SecretsManager-generated password
*/
readonly credentials?: Credentials;
}

/**
Expand All @@ -652,16 +679,28 @@ export class DatabaseClusterFromSnapshot extends DatabaseClusterNew {
public readonly instanceIdentifiers: string[];
public readonly instanceEndpoints: Endpoint[];

/**
* The secret attached to this cluster
*/
public readonly secret?: secretsmanager.ISecret;

constructor(scope: Construct, id: string, props: DatabaseClusterFromSnapshotProps) {
super(scope, id, props);

const credentials = renderCredentials(this, props.engine, props.credentials);
const secret = credentials.secret;

const cluster = new CfnDBCluster(this, 'Resource', {
...this.newCfnProps,
snapshotIdentifier: props.snapshotIdentifier,
});

this.clusterIdentifier = cluster.ref;

if (secret) {
this.secret = secret.attach(this);
}

// create a number token that represents the port of the cluster
const portAttribute = Token.asNumber(cluster.attrEndpointPort);
this.clusterEndpoint = new Endpoint(cluster.attrEndpointAddress, portAttribute);
Expand Down
88 changes: 87 additions & 1 deletion packages/@aws-cdk/aws-rds/test/cluster.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1074,7 +1074,7 @@ describe('cluster', () => {
});

// THEN
expect(() => cluster.addRotationSingleUser()).toThrow(/without secret/);
expect(() => cluster.addRotationSingleUser()).toThrow(/without a secret/);
});

test('throws when trying to add single user rotation multiple times', () => {
Expand Down Expand Up @@ -2049,6 +2049,92 @@ describe('cluster', () => {
});
});

test('create a cluster from a snapshot with single user secret rotation', () => {
// GIVEN
const stack = testStack();
const vpc = new ec2.Vpc(stack, 'VPC');

const cluster = new DatabaseClusterFromSnapshot(stack, 'Database', {
engine: DatabaseClusterEngine.aurora({ version: AuroraEngineVersion.VER_1_22_2 }),
instanceProps: {
vpc,
},
snapshotIdentifier: 'mySnapshot',
});

// WHEN
cluster.addRotationSingleUser();

// THEN
Template.fromStack(stack).hasResourceProperties('AWS::SecretsManager::RotationSchedule', {
RotationRules: {
AutomaticallyAfterDays: 30,
},
});
});

test('throws when trying to add single user rotation multiple times on cluster from snapshot', () => {
// GIVEN
const stack = testStack();
const vpc = new ec2.Vpc(stack, 'VPC');

const cluster = new DatabaseClusterFromSnapshot(stack, 'Database', {
engine: DatabaseClusterEngine.aurora({ version: AuroraEngineVersion.VER_1_22_2 }),
instanceProps: {
vpc,
},
snapshotIdentifier: 'mySnapshot',
});

// WHEN
cluster.addRotationSingleUser();

// THEN
expect(() => cluster.addRotationSingleUser()).toThrow(/A single user rotation was already added to this cluster/);
});

test('create a cluster from a snapshot with multi user secret rotation', () => {
// GIVEN
const stack = testStack();
const vpc = new ec2.Vpc(stack, 'VPC');

const cluster = new DatabaseClusterFromSnapshot(stack, 'Database', {
engine: DatabaseClusterEngine.aurora({ version: AuroraEngineVersion.VER_1_22_2 }),
instanceProps: {
vpc,
},
snapshotIdentifier: 'mySnapshot',
});

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

// THEN
Template.fromStack(stack).hasResourceProperties('AWS::SecretsManager::RotationSchedule', {
SecretId: {
Ref: 'UserSecretAttachment16ACBE6D',
},
RotationLambdaARN: {
'Fn::GetAtt': [
'DatabaseuserECD1FB0C',
'Outputs.RotationLambdaARN',
],
},
RotationRules: {
AutomaticallyAfterDays: 30,
},
});

Template.fromStack(stack).hasResourceProperties('AWS::Serverless::Application', {
Parameters: {
masterSecretArn: {
Ref: 'DatabaseSecretAttachmentE5D1B020',
},
},
});
});

test('reuse an existing subnet group', () => {
// GIVEN
const stack = testStack();
Expand Down

0 comments on commit b9870de

Please sign in to comment.