diff --git a/packages/@aws-cdk/aws-rds/lib/cluster.ts b/packages/@aws-cdk/aws-rds/lib/cluster.ts index d8423cfdfdd6d..be0034586fdb9 100644 --- a/packages/@aws-cdk/aws-rds/lib/cluster.ts +++ b/packages/@aws-cdk/aws-rds/lib/cluster.ts @@ -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 */ @@ -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. @@ -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, + }); + } } /** @@ -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; @@ -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); @@ -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, - }); - } } /** @@ -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; } /** @@ -652,9 +679,17 @@ 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, @@ -662,6 +697,10 @@ export class DatabaseClusterFromSnapshot 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); diff --git a/packages/@aws-cdk/aws-rds/test/cluster.test.ts b/packages/@aws-cdk/aws-rds/test/cluster.test.ts index dddc3edc9e97a..c89a31ba45b98 100644 --- a/packages/@aws-cdk/aws-rds/test/cluster.test.ts +++ b/packages/@aws-cdk/aws-rds/test/cluster.test.ts @@ -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', () => { @@ -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();