Skip to content

Commit abc3502

Browse files
feat(rds): add secret rotation to DatabaseClusterFromSnapshot (#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 #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*
1 parent 572b52c commit abc3502

File tree

2 files changed

+183
-58
lines changed

2 files changed

+183
-58
lines changed

packages/@aws-cdk/aws-rds/lib/cluster.ts

Lines changed: 96 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -285,6 +285,7 @@ export abstract class DatabaseClusterBase extends Resource implements IDatabaseC
285285
* Identifier of the cluster
286286
*/
287287
public abstract readonly clusterIdentifier: string;
288+
288289
/**
289290
* Identifiers of the replicas
290291
*/
@@ -345,9 +346,40 @@ abstract class DatabaseClusterNew extends DatabaseClusterBase {
345346
protected readonly securityGroups: ec2.ISecurityGroup[];
346347
protected readonly subnetGroup: ISubnetGroup;
347348

349+
/**
350+
* Secret in SecretsManager to store the database cluster user credentials.
351+
*/
352+
public abstract readonly secret?: secretsmanager.ISecret;
353+
354+
/**
355+
* The VPC network to place the cluster in.
356+
*/
357+
public readonly vpc: ec2.IVpc;
358+
359+
/**
360+
* The cluster's subnets.
361+
*/
362+
public readonly vpcSubnets?: ec2.SubnetSelection;
363+
364+
/**
365+
* Application for single user rotation of the master password to this cluster.
366+
*/
367+
public readonly singleUserRotationApplication: secretsmanager.SecretRotationApplication;
368+
369+
/**
370+
* Application for multi user rotation to this cluster.
371+
*/
372+
public readonly multiUserRotationApplication: secretsmanager.SecretRotationApplication;
373+
348374
constructor(scope: Construct, id: string, props: DatabaseClusterBaseProps) {
349375
super(scope, id);
350376

377+
this.vpc = props.instanceProps.vpc;
378+
this.vpcSubnets = props.instanceProps.vpcSubnets;
379+
380+
this.singleUserRotationApplication = props.engine.singleUserRotationApplication;
381+
this.multiUserRotationApplication = props.engine.multiUserRotationApplication;
382+
351383
const { subnetIds } = props.instanceProps.vpc.selectSubnets(props.instanceProps.vpcSubnets);
352384

353385
// Cannot test whether the subnets are in different AZs, but at least we can test the amount.
@@ -436,6 +468,47 @@ abstract class DatabaseClusterNew extends DatabaseClusterBase {
436468
copyTagsToSnapshot: props.copyTagsToSnapshot ?? true,
437469
};
438470
}
471+
472+
/**
473+
* Adds the single user rotation of the master password to this cluster.
474+
*/
475+
public addRotationSingleUser(options: RotationSingleUserOptions = {}): secretsmanager.SecretRotation {
476+
if (!this.secret) {
477+
throw new Error('Cannot add a single user rotation for a cluster without a secret.');
478+
}
479+
480+
const id = 'RotationSingleUser';
481+
const existing = this.node.tryFindChild(id);
482+
if (existing) {
483+
throw new Error('A single user rotation was already added to this cluster.');
484+
}
485+
486+
return new secretsmanager.SecretRotation(this, id, {
487+
...applyDefaultRotationOptions(options, this.vpcSubnets),
488+
secret: this.secret,
489+
application: this.singleUserRotationApplication,
490+
vpc: this.vpc,
491+
target: this,
492+
});
493+
}
494+
495+
/**
496+
* Adds the multi user rotation to this cluster.
497+
*/
498+
public addRotationMultiUser(id: string, options: RotationMultiUserOptions): secretsmanager.SecretRotation {
499+
if (!this.secret) {
500+
throw new Error('Cannot add a multi user rotation for a cluster without a secret.');
501+
}
502+
503+
return new secretsmanager.SecretRotation(this, id, {
504+
...applyDefaultRotationOptions(options, this.vpcSubnets),
505+
secret: options.secret,
506+
masterSecret: this.secret,
507+
application: this.multiUserRotationApplication,
508+
vpc: this.vpc,
509+
target: this,
510+
});
511+
}
439512
}
440513

441514
/**
@@ -537,21 +610,9 @@ export class DatabaseCluster extends DatabaseClusterNew {
537610
*/
538611
public readonly secret?: secretsmanager.ISecret;
539612

540-
private readonly vpc: ec2.IVpc;
541-
private readonly vpcSubnets?: ec2.SubnetSelection;
542-
543-
private readonly singleUserRotationApplication: secretsmanager.SecretRotationApplication;
544-
private readonly multiUserRotationApplication: secretsmanager.SecretRotationApplication;
545-
546613
constructor(scope: Construct, id: string, props: DatabaseClusterProps) {
547614
super(scope, id, props);
548615

549-
this.vpc = props.instanceProps.vpc;
550-
this.vpcSubnets = props.instanceProps.vpcSubnets;
551-
552-
this.singleUserRotationApplication = props.engine.singleUserRotationApplication;
553-
this.multiUserRotationApplication = props.engine.multiUserRotationApplication;
554-
555616
const credentials = renderCredentials(this, props.engine, props.credentials);
556617
const secret = credentials.secret;
557618

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

565626
this.clusterIdentifier = cluster.ref;
566627

628+
if (secret) {
629+
this.secret = secret.attach(this);
630+
}
631+
567632
// create a number token that represents the port of the cluster
568633
const portAttribute = Token.asNumber(cluster.attrEndpointPort);
569634
this.clusterEndpoint = new Endpoint(cluster.attrEndpointAddress, portAttribute);
@@ -575,56 +640,11 @@ export class DatabaseCluster extends DatabaseClusterNew {
575640

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

578-
if (secret) {
579-
this.secret = secret.attach(this);
580-
}
581-
582643
setLogRetention(this, props);
583644
const createdInstances = createInstances(this, props, this.subnetGroup);
584645
this.instanceIdentifiers = createdInstances.instanceIdentifiers;
585646
this.instanceEndpoints = createdInstances.instanceEndpoints;
586647
}
587-
588-
/**
589-
* Adds the single user rotation of the master password to this cluster.
590-
*/
591-
public addRotationSingleUser(options: RotationSingleUserOptions = {}): secretsmanager.SecretRotation {
592-
if (!this.secret) {
593-
throw new Error('Cannot add single user rotation for a cluster without secret.');
594-
}
595-
596-
const id = 'RotationSingleUser';
597-
const existing = this.node.tryFindChild(id);
598-
if (existing) {
599-
throw new Error('A single user rotation was already added to this cluster.');
600-
}
601-
602-
return new secretsmanager.SecretRotation(this, id, {
603-
...applyDefaultRotationOptions(options, this.vpcSubnets),
604-
secret: this.secret,
605-
application: this.singleUserRotationApplication,
606-
vpc: this.vpc,
607-
target: this,
608-
});
609-
}
610-
611-
/**
612-
* Adds the multi user rotation to this cluster.
613-
*/
614-
public addRotationMultiUser(id: string, options: RotationMultiUserOptions): secretsmanager.SecretRotation {
615-
if (!this.secret) {
616-
throw new Error('Cannot add multi user rotation for a cluster without secret.');
617-
}
618-
619-
return new secretsmanager.SecretRotation(this, id, {
620-
...applyDefaultRotationOptions(options, this.vpcSubnets),
621-
secret: options.secret,
622-
masterSecret: this.secret,
623-
application: this.multiUserRotationApplication,
624-
vpc: this.vpc,
625-
target: this,
626-
});
627-
}
628648
}
629649

630650
/**
@@ -637,6 +657,13 @@ export interface DatabaseClusterFromSnapshotProps extends DatabaseClusterBasePro
637657
* However, you can use only the ARN to specify a DB instance snapshot.
638658
*/
639659
readonly snapshotIdentifier: string;
660+
661+
/**
662+
* Credentials for the administrative user
663+
*
664+
* @default - A username of 'admin' (or 'postgres' for PostgreSQL) and SecretsManager-generated password
665+
*/
666+
readonly credentials?: Credentials;
640667
}
641668

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

682+
/**
683+
* The secret attached to this cluster
684+
*/
685+
public readonly secret?: secretsmanager.ISecret;
686+
655687
constructor(scope: Construct, id: string, props: DatabaseClusterFromSnapshotProps) {
656688
super(scope, id, props);
657689

690+
const credentials = renderCredentials(this, props.engine, props.credentials);
691+
const secret = credentials.secret;
692+
658693
const cluster = new CfnDBCluster(this, 'Resource', {
659694
...this.newCfnProps,
660695
snapshotIdentifier: props.snapshotIdentifier,
661696
});
662697

663698
this.clusterIdentifier = cluster.ref;
664699

700+
if (secret) {
701+
this.secret = secret.attach(this);
702+
}
703+
665704
// create a number token that represents the port of the cluster
666705
const portAttribute = Token.asNumber(cluster.attrEndpointPort);
667706
this.clusterEndpoint = new Endpoint(cluster.attrEndpointAddress, portAttribute);

packages/@aws-cdk/aws-rds/test/cluster.test.ts

Lines changed: 87 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1074,7 +1074,7 @@ describe('cluster', () => {
10741074
});
10751075

10761076
// THEN
1077-
expect(() => cluster.addRotationSingleUser()).toThrow(/without secret/);
1077+
expect(() => cluster.addRotationSingleUser()).toThrow(/without a secret/);
10781078
});
10791079

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

2052+
test('create a cluster from a snapshot with single user secret rotation', () => {
2053+
// GIVEN
2054+
const stack = testStack();
2055+
const vpc = new ec2.Vpc(stack, 'VPC');
2056+
2057+
const cluster = new DatabaseClusterFromSnapshot(stack, 'Database', {
2058+
engine: DatabaseClusterEngine.aurora({ version: AuroraEngineVersion.VER_1_22_2 }),
2059+
instanceProps: {
2060+
vpc,
2061+
},
2062+
snapshotIdentifier: 'mySnapshot',
2063+
});
2064+
2065+
// WHEN
2066+
cluster.addRotationSingleUser();
2067+
2068+
// THEN
2069+
Template.fromStack(stack).hasResourceProperties('AWS::SecretsManager::RotationSchedule', {
2070+
RotationRules: {
2071+
AutomaticallyAfterDays: 30,
2072+
},
2073+
});
2074+
});
2075+
2076+
test('throws when trying to add single user rotation multiple times on cluster from snapshot', () => {
2077+
// GIVEN
2078+
const stack = testStack();
2079+
const vpc = new ec2.Vpc(stack, 'VPC');
2080+
2081+
const cluster = new DatabaseClusterFromSnapshot(stack, 'Database', {
2082+
engine: DatabaseClusterEngine.aurora({ version: AuroraEngineVersion.VER_1_22_2 }),
2083+
instanceProps: {
2084+
vpc,
2085+
},
2086+
snapshotIdentifier: 'mySnapshot',
2087+
});
2088+
2089+
// WHEN
2090+
cluster.addRotationSingleUser();
2091+
2092+
// THEN
2093+
expect(() => cluster.addRotationSingleUser()).toThrow(/A single user rotation was already added to this cluster/);
2094+
});
2095+
2096+
test('create a cluster from a snapshot with multi user secret rotation', () => {
2097+
// GIVEN
2098+
const stack = testStack();
2099+
const vpc = new ec2.Vpc(stack, 'VPC');
2100+
2101+
const cluster = new DatabaseClusterFromSnapshot(stack, 'Database', {
2102+
engine: DatabaseClusterEngine.aurora({ version: AuroraEngineVersion.VER_1_22_2 }),
2103+
instanceProps: {
2104+
vpc,
2105+
},
2106+
snapshotIdentifier: 'mySnapshot',
2107+
});
2108+
2109+
// WHEN
2110+
const userSecret = new DatabaseSecret(stack, 'UserSecret', { username: 'user' });
2111+
cluster.addRotationMultiUser('user', { secret: userSecret.attach(cluster) });
2112+
2113+
// THEN
2114+
Template.fromStack(stack).hasResourceProperties('AWS::SecretsManager::RotationSchedule', {
2115+
SecretId: {
2116+
Ref: 'UserSecretAttachment16ACBE6D',
2117+
},
2118+
RotationLambdaARN: {
2119+
'Fn::GetAtt': [
2120+
'DatabaseuserECD1FB0C',
2121+
'Outputs.RotationLambdaARN',
2122+
],
2123+
},
2124+
RotationRules: {
2125+
AutomaticallyAfterDays: 30,
2126+
},
2127+
});
2128+
2129+
Template.fromStack(stack).hasResourceProperties('AWS::Serverless::Application', {
2130+
Parameters: {
2131+
masterSecretArn: {
2132+
Ref: 'DatabaseSecretAttachmentE5D1B020',
2133+
},
2134+
},
2135+
});
2136+
});
2137+
20522138
test('reuse an existing subnet group', () => {
20532139
// GIVEN
20542140
const stack = testStack();

0 commit comments

Comments
 (0)