From 2cb8e22221b266b90b3a0b6c198a0da6ff4e3b8a Mon Sep 17 00:00:00 2001 From: Jonathan Goldwasser Date: Tue, 13 Oct 2020 15:30:54 +0200 Subject: [PATCH] feat(secretsmanager): hosted rotation (#10790) Add support for secret rotation using a hosted rotation Lambda function. This should eventually replace the `SecretRotation` class which uses a serverless application (old way of doing this, currently used in `aws-rds`). Note: the `HostedRotationLambda` CF property doesn't support excluding characters yet so we'll have to wait until we can use it in `aws-rds`. See https://docs.aws.amazon.com/secretsmanager/latest/userguide/integrating_cloudformation.html See https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-secretsmanager-rotationschedule-hostedrotationlambda.html ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- .../@aws-cdk/aws-secretsmanager/README.md | 29 +- .../lib/rotation-schedule.ts | 277 +++++++++++++++- .../aws-secretsmanager/lib/secret-rotation.ts | 8 +- .../test/integ.hosted-rotation.expected.json | 61 ++++ .../test/integ.hosted-rotation.ts | 18 + .../test/rotation-schedule.test.ts | 308 +++++++++++++++++- 6 files changed, 691 insertions(+), 10 deletions(-) create mode 100644 packages/@aws-cdk/aws-secretsmanager/test/integ.hosted-rotation.expected.json create mode 100644 packages/@aws-cdk/aws-secretsmanager/test/integ.hosted-rotation.ts diff --git a/packages/@aws-cdk/aws-secretsmanager/README.md b/packages/@aws-cdk/aws-secretsmanager/README.md index 4904814a1c4e9..286e83ea37b6b 100644 --- a/packages/@aws-cdk/aws-secretsmanager/README.md +++ b/packages/@aws-cdk/aws-secretsmanager/README.md @@ -69,7 +69,9 @@ then `Secret.grantRead` and `Secret.grantWrite` will also grant the role the relevant encrypt and decrypt permissions to the KMS key through the SecretsManager service principal. -### Rotating a Secret with a custom Lambda function +### Rotating a Secret + +#### Using a Custom Lambda Function A rotation schedule can be added to a Secret using a custom Lambda function: @@ -85,6 +87,31 @@ secret.addRotationSchedule('RotationSchedule', { See [Overview of the Lambda Rotation Function](https://docs.aws.amazon.com/secretsmanager/latest/userguide/rotating-secrets-lambda-function-overview.html) on how to implement a Lambda Rotation Function. +#### Using a Hosted Lambda Function + +Use the `hostedRotation` prop to rotate a secret with a hosted Lambda function: + +```ts +const secret = new secretsmanager.Secret(this, 'Secret'); + +secret.addRotationSchedule('RotationSchedule', { + hostedRotation: secretsmanager.HostedRotation.mysqlSingleUser(), +}); +``` + +Hosted rotation is available for secrets representing credentials for MySQL, PostgreSQL, Oracle, +MariaDB, SQLServer, Redshift and MongoDB (both for the single and multi user schemes). + +When deployed in a VPC, the hosted rotation implements `ec2.IConnectable`: + +```ts +const myHostedRotation = secretsmanager.HostedRotation.mysqlSingleUser({ vpc: myVpc }); +secret.addRotationSchedule('RotationSchedule', { hostedRotation: myHostedRotation }); +dbConnections.allowDefaultPortFrom(hostedRotation); +``` + +See also [Automating secret creation in AWS CloudFormation](https://docs.aws.amazon.com/secretsmanager/latest/userguide/integrating_cloudformation.html). + ### Rotating database credentials Define a `SecretRotation` to rotate database credentials: diff --git a/packages/@aws-cdk/aws-secretsmanager/lib/rotation-schedule.ts b/packages/@aws-cdk/aws-secretsmanager/lib/rotation-schedule.ts index 49d6170004e71..1243976963386 100644 --- a/packages/@aws-cdk/aws-secretsmanager/lib/rotation-schedule.ts +++ b/packages/@aws-cdk/aws-secretsmanager/lib/rotation-schedule.ts @@ -1,5 +1,6 @@ +import * as ec2 from '@aws-cdk/aws-ec2'; import * as lambda from '@aws-cdk/aws-lambda'; -import { Duration, Resource } from '@aws-cdk/core'; +import { Duration, Resource, Stack } from '@aws-cdk/core'; import { Construct } from 'constructs'; import { ISecret } from './secret'; import { CfnRotationSchedule } from './secretsmanager.generated'; @@ -9,9 +10,18 @@ import { CfnRotationSchedule } from './secretsmanager.generated'; */ export interface RotationScheduleOptions { /** - * The Lambda function that can rotate the secret. + * A Lambda function that can rotate the secret. + * + * @default - either `rotationLambda` or `hostedRotation` must be specified */ - readonly rotationLambda: lambda.IFunction; + readonly rotationLambda?: lambda.IFunction; + + /** + * Hosted rotation + * + * @default - either `rotationLambda` or `hostedRotation` must be specified + */ + readonly hostedRotation?: HostedRotation; /** * Specifies the number of days after the previous rotation before @@ -28,6 +38,23 @@ export interface RotationScheduleOptions { export interface RotationScheduleProps extends RotationScheduleOptions { /** * The secret to rotate. + * + * If hosted rotation is used, this must be a JSON string with the following format: + * + * ``` + * { + * "engine": , + * "host": , + * "username": , + * "password": , + * "dbname": , + * "port": , + * "masterarn": + * } + * ``` + * + * This is typically the case for a secret referenced from an `AWS::SecretsManager::SecretTargetAttachment` + * or an `ISecret` returned by the `attach()` method of `Secret`. */ readonly secret: ISecret; } @@ -39,12 +66,254 @@ export class RotationSchedule extends Resource { constructor(scope: Construct, id: string, props: RotationScheduleProps) { super(scope, id); + if ((!props.rotationLambda && !props.hostedRotation) || (props.rotationLambda && props.hostedRotation)) { + throw new Error('One of `rotationLambda` or `hostedRotation` must be specified.'); + } + new CfnRotationSchedule(this, 'Resource', { secretId: props.secret.secretArn, - rotationLambdaArn: props.rotationLambda.functionArn, + rotationLambdaArn: props.rotationLambda?.functionArn, + hostedRotationLambda: props.hostedRotation?.bind(props.secret, this), rotationRules: { automaticallyAfterDays: props.automaticallyAfter && props.automaticallyAfter.toDays() || 30, }, }); + + // Prevent secrets deletions when rotation is in place + props.secret.denyAccountRootDelete(); + } +} + +/** + * Single user hosted rotation options + */ +export interface SingleUserHostedRotationOptions { + /** + * A name for the Lambda created to rotate the secret + * + * @default - a CloudFormation generated name + */ + readonly functionName?: string; + + /** + * A list of security groups for the Lambda created to rotate the secret + * + * @default - a new security group is created + */ + readonly securityGroups?: ec2.ISecurityGroup[]; + + /** + * The VPC where the Lambda rotation function will run. + * + * @default - the Lambda is not deployed in a VPC + */ + readonly vpc?: ec2.IVpc; + + /** + * The type of subnets in the VPC where the Lambda rotation function will run. + * + * @default - the Vpc default strategy if not specified. + */ + readonly vpcSubnets?: ec2.SubnetSelection; +} + +/** + * Multi user hosted rotation options + */ +export interface MultiUserHostedRotationOptions extends SingleUserHostedRotationOptions { + /** + * The master secret for a multi user rotation scheme + */ + readonly masterSecret: ISecret; +} + +/** + * A hosted rotation + */ +export class HostedRotation implements ec2.IConnectable { + /** MySQL Single User */ + public static mysqlSingleUser(options: SingleUserHostedRotationOptions = {}) { + return new HostedRotation(HostedRotationType.MYSQL_SINGLE_USER, options); + } + + /** MySQL Multi User */ + public static mysqlMultiUser(options: MultiUserHostedRotationOptions) { + return new HostedRotation(HostedRotationType.MYSQL_MULTI_USER, options, options.masterSecret); + } + + /** PostgreSQL Single User */ + public static postgreSqlSingleUser(options: SingleUserHostedRotationOptions = {}) { + return new HostedRotation(HostedRotationType.POSTGRESQL_SINGLE_USER, options); + } + + /** PostgreSQL Multi User */ + public static postgreSqlMultiUser(options: MultiUserHostedRotationOptions) { + return new HostedRotation(HostedRotationType.POSTGRESQL_MULTI_USER, options, options.masterSecret); + } + + /** Oracle Single User */ + public static oracleSingleUser(options: SingleUserHostedRotationOptions = {}) { + return new HostedRotation(HostedRotationType.ORACLE_SINGLE_USER, options); + } + + /** Oracle Multi User */ + public static oracleMultiUser(options: MultiUserHostedRotationOptions) { + return new HostedRotation(HostedRotationType.ORACLE_MULTI_USER, options, options.masterSecret); + } + + /** MariaDB Single User */ + public static mariaDbSingleUser(options: SingleUserHostedRotationOptions = {}) { + return new HostedRotation(HostedRotationType.MARIADB_SINGLE_USER, options); } + + /** MariaDB Multi User */ + public static mariaDbMultiUser(options: MultiUserHostedRotationOptions) { + return new HostedRotation(HostedRotationType.MARIADB_MULTI_USER, options, options.masterSecret); + } + + /** SQL Server Single User */ + public static sqlServerSingleUser(options: SingleUserHostedRotationOptions = {}) { + return new HostedRotation(HostedRotationType.SQLSERVER_SINGLE_USER, options); + } + + /** SQL Server Multi User */ + public static sqlServerMultiUser(options: MultiUserHostedRotationOptions) { + return new HostedRotation(HostedRotationType.SQLSERVER_MULTI_USER, options, options.masterSecret); + } + + /** Redshift Single User */ + public static redshiftSingleUser(options: SingleUserHostedRotationOptions = {}) { + return new HostedRotation(HostedRotationType.REDSHIFT_SINGLE_USER, options); + } + + /** Redshift Multi User */ + public static redshiftMultiUser(options: MultiUserHostedRotationOptions) { + return new HostedRotation(HostedRotationType.REDSHIFT_MULTI_USER, options, options.masterSecret); + } + + /** MongoDB Single User */ + public static mongoDbSingleUser(options: SingleUserHostedRotationOptions = {}) { + return new HostedRotation(HostedRotationType.MONGODB_SINGLE_USER, options); + } + + /** MongoDB Multi User */ + public static mongoDbMultiUser(options: MultiUserHostedRotationOptions) { + return new HostedRotation(HostedRotationType.MONGODB_MULTI_USER, options, options.masterSecret); + } + + private _connections?: ec2.Connections; + + private constructor( + private readonly type: HostedRotationType, + private readonly props: SingleUserHostedRotationOptions | MultiUserHostedRotationOptions, + private readonly masterSecret?: ISecret, + ) { + if (type.isMultiUser && !masterSecret) { + throw new Error('The `masterSecret` must be specified when using the multi user scheme.'); + } + } + + /** + * Binds this hosted rotation to a secret + */ + public bind(secret: ISecret, scope: Construct): CfnRotationSchedule.HostedRotationLambdaProperty { + // https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-properties-secretsmanager-rotationschedule-hostedrotationlambda.html + Stack.of(scope).addTransform('AWS::SecretsManager-2020-07-23'); + + if (!this.props.vpc && this.props.securityGroups) { + throw new Error('`vpc` must be specified when specifying `securityGroups`.'); + } + + if (this.props.vpc) { + this._connections = new ec2.Connections({ + securityGroups: this.props.securityGroups || [new ec2.SecurityGroup(scope, 'SecurityGroup', { + vpc: this.props.vpc, + })], + }); + } + + // Prevent master secret deletion when rotation is in place + if (this.masterSecret) { + this.masterSecret.denyAccountRootDelete(); + } + + return { + rotationType: this.type.name, + kmsKeyArn: secret.encryptionKey?.keyArn, + masterSecretArn: this.masterSecret?.secretArn, + masterSecretKmsKeyArn: this.masterSecret?.encryptionKey?.keyArn, + rotationLambdaName: this.props.functionName, + vpcSecurityGroupIds: this._connections?.securityGroups?.map(s => s.securityGroupId).join(','), + vpcSubnetIds: this.props.vpc?.selectSubnets(this.props.vpcSubnets).subnetIds.join(','), + }; + } + + /** + * Security group connections for this hosted rotation + */ + public get connections() { + if (!this.props.vpc) { + throw new Error('Cannot use connections for a hosted rotation that is not deployed in a VPC'); + } + + // If we are in a vpc and bind() has been called _connections should be defined + if (!this._connections) { + throw new Error('Cannot use connections for a hosted rotation that has not been bound to a secret'); + } + + return this._connections; + } +} + +/** + * Hosted rotation type + */ +export class HostedRotationType { + /** MySQL Single User */ + public static readonly MYSQL_SINGLE_USER = new HostedRotationType('MySQLSingleUser'); + + /** MySQL Multi User */ + public static readonly MYSQL_MULTI_USER = new HostedRotationType('MySQLMultiUser', true); + + /** PostgreSQL Single User */ + public static readonly POSTGRESQL_SINGLE_USER = new HostedRotationType('PostgreSQLSingleUser'); + + /** PostgreSQL Multi User */ + public static readonly POSTGRESQL_MULTI_USER = new HostedRotationType('PostgreSQLMultiUser', true); + + /** Oracle Single User */ + public static readonly ORACLE_SINGLE_USER = new HostedRotationType('OracleSingleUser'); + + /** Oracle Multi User */ + public static readonly ORACLE_MULTI_USER = new HostedRotationType('OracleMultiUser', true); + + /** MariaDB Single User */ + public static readonly MARIADB_SINGLE_USER = new HostedRotationType('MariaDBSingleUser'); + + /** MariaDB Multi User */ + public static readonly MARIADB_MULTI_USER = new HostedRotationType('MariaDBMultiUser', true); + + /** SQL Server Single User */ + public static readonly SQLSERVER_SINGLE_USER = new HostedRotationType('SQLServerSingleUser') + + /** SQL Server Multi User */ + public static readonly SQLSERVER_MULTI_USER = new HostedRotationType('SQLServerMultiUser', true); + + /** Redshift Single User */ + public static readonly REDSHIFT_SINGLE_USER = new HostedRotationType('RedshiftSingleUser') + + /** Redshift Multi User */ + public static readonly REDSHIFT_MULTI_USER = new HostedRotationType('RedshiftMultiUser', true); + + /** MongoDB Single User */ + public static readonly MONGODB_SINGLE_USER = new HostedRotationType('MongoDBSingleUser'); + + /** MongoDB Multi User */ + public static readonly MONGODB_MULTI_USER = new HostedRotationType('MongoDBMultiUser', true); + + /** + * @param name The type of rotation + * @param isMultiUser Whether the rotation uses the mutli user scheme + */ + private constructor(public readonly name: string, public readonly isMultiUser?: boolean) {} } diff --git a/packages/@aws-cdk/aws-secretsmanager/lib/secret-rotation.ts b/packages/@aws-cdk/aws-secretsmanager/lib/secret-rotation.ts index cdd51ff5cedbb..388933895af51 100644 --- a/packages/@aws-cdk/aws-secretsmanager/lib/secret-rotation.ts +++ b/packages/@aws-cdk/aws-secretsmanager/lib/secret-rotation.ts @@ -136,6 +136,7 @@ export class SecretRotationApplication { export interface SecretRotationProps { /** * The secret to rotate. It must be a JSON string with the following format: + * * ``` * { * "engine": , @@ -148,8 +149,8 @@ export interface SecretRotationProps { * } * ``` * - * This is typically the case for a secret referenced from an - * AWS::SecretsManager::SecretTargetAttachment or an `ISecret` returned by the `attach()` method of `Secret`. + * This is typically the case for a secret referenced from an `AWS::SecretsManager::SecretTargetAttachment` + * or an `ISecret` returned by the `attach()` method of `Secret`. * * @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-secretsmanager-secrettargetattachment.html */ @@ -270,8 +271,7 @@ export class SecretRotation extends CoreConstruct { automaticallyAfter: props.automaticallyAfter, }); - // Prevent secrets deletions when rotation is in place - props.secret.denyAccountRootDelete(); + // Prevent master secret deletion when rotation is in place if (props.masterSecret) { props.masterSecret.denyAccountRootDelete(); } diff --git a/packages/@aws-cdk/aws-secretsmanager/test/integ.hosted-rotation.expected.json b/packages/@aws-cdk/aws-secretsmanager/test/integ.hosted-rotation.expected.json new file mode 100644 index 0000000000000..be2f63be0aa79 --- /dev/null +++ b/packages/@aws-cdk/aws-secretsmanager/test/integ.hosted-rotation.expected.json @@ -0,0 +1,61 @@ +{ + "Transform": "AWS::SecretsManager-2020-07-23", + "Resources": { + "SecretA720EF05": { + "Type": "AWS::SecretsManager::Secret", + "Properties": { + "GenerateSecretString": {} + } + }, + "SecretSchedule18F2CB66": { + "Type": "AWS::SecretsManager::RotationSchedule", + "Properties": { + "SecretId": { + "Ref": "SecretA720EF05" + }, + "HostedRotationLambda": { + "RotationType": "MySQLSingleUser" + }, + "RotationRules": { + "AutomaticallyAfterDays": 30 + } + } + }, + "SecretPolicy06C9821C": { + "Type": "AWS::SecretsManager::ResourcePolicy", + "Properties": { + "ResourcePolicy": { + "Statement": [ + { + "Action": "secretsmanager:DeleteSecret", + "Effect": "Deny", + "Principal": { + "AWS": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::", + { + "Ref": "AWS::AccountId" + }, + ":root" + ] + ] + } + }, + "Resource": "*" + } + ], + "Version": "2012-10-17" + }, + "SecretId": { + "Ref": "SecretA720EF05" + } + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-secretsmanager/test/integ.hosted-rotation.ts b/packages/@aws-cdk/aws-secretsmanager/test/integ.hosted-rotation.ts new file mode 100644 index 0000000000000..10109f91496cd --- /dev/null +++ b/packages/@aws-cdk/aws-secretsmanager/test/integ.hosted-rotation.ts @@ -0,0 +1,18 @@ +import * as cdk from '@aws-cdk/core'; +import * as secretsmanager from '../lib'; + +class TestStack extends cdk.Stack { + constructor(scope: cdk.App, id: string) { + super(scope, id); + + const secret = new secretsmanager.Secret(this, 'Secret'); + + secret.addRotationSchedule('Schedule', { + hostedRotation: secretsmanager.HostedRotation.mysqlSingleUser(), + }); + } +} + +const app = new cdk.App(); +new TestStack(app, 'cdk-integ-secret-hosted-rotation'); +app.synth(); diff --git a/packages/@aws-cdk/aws-secretsmanager/test/rotation-schedule.test.ts b/packages/@aws-cdk/aws-secretsmanager/test/rotation-schedule.test.ts index d63859ce02f5f..56eff9534a776 100644 --- a/packages/@aws-cdk/aws-secretsmanager/test/rotation-schedule.test.ts +++ b/packages/@aws-cdk/aws-secretsmanager/test/rotation-schedule.test.ts @@ -1,4 +1,5 @@ import '@aws-cdk/assert/jest'; +import * as ec2 from '@aws-cdk/aws-ec2'; import * as lambda from '@aws-cdk/aws-lambda'; import * as cdk from '@aws-cdk/core'; import * as secretsmanager from '../lib'; @@ -8,7 +9,7 @@ beforeEach(() => { stack = new cdk.Stack(); }); -test('create a rotation schedule', () => { +test('create a rotation schedule with a rotation Lambda', () => { // GIVEN const secret = new secretsmanager.Secret(stack, 'Secret'); const rotationLambda = new lambda.Function(stack, 'Lambda', { @@ -39,3 +40,308 @@ test('create a rotation schedule', () => { }, }); }); + +describe('hosted rotation', () => { + test('single user not in a vpc', () => { + // GIVEN + const app = new cdk.App(); + stack = new cdk.Stack(app, 'TestStack'); + const secret = new secretsmanager.Secret(stack, 'Secret'); + + // WHEN + secret.addRotationSchedule('RotationSchedule', { + hostedRotation: secretsmanager.HostedRotation.mysqlSingleUser(), + }); + + // THEN + expect(stack).toHaveResource('AWS::SecretsManager::RotationSchedule', { + SecretId: { + Ref: 'SecretA720EF05', + }, + HostedRotationLambda: { + RotationType: 'MySQLSingleUser', + }, + RotationRules: { + AutomaticallyAfterDays: 30, + }, + }); + + expect(app.synth().getStackByName(stack.stackName).template).toEqual(expect.objectContaining({ + Transform: 'AWS::SecretsManager-2020-07-23', + })); + + expect(stack).toHaveResource('AWS::SecretsManager::ResourcePolicy', { + ResourcePolicy: { + Statement: [ + { + Action: 'secretsmanager:DeleteSecret', + Effect: 'Deny', + Principal: { + AWS: { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':iam::', + { + Ref: 'AWS::AccountId', + }, + ':root', + ], + ], + }, + }, + Resource: '*', + }, + ], + Version: '2012-10-17', + }, + SecretId: { + Ref: 'SecretA720EF05', + }, + }); + }); + + test('multi user not in a vpc', () => { + // GIVEN + const secret = new secretsmanager.Secret(stack, 'Secret'); + const masterSecret = new secretsmanager.Secret(stack, 'MasterSecret'); + + // WHEN + secret.addRotationSchedule('RotationSchedule', { + hostedRotation: secretsmanager.HostedRotation.postgreSqlMultiUser({ + masterSecret, + }), + }); + + // THEN + expect(stack).toHaveResource('AWS::SecretsManager::RotationSchedule', { + SecretId: { + Ref: 'SecretA720EF05', + }, + HostedRotationLambda: { + MasterSecretArn: { + Ref: 'MasterSecretA11BF785', + }, + RotationType: 'PostgreSQLMultiUser', + }, + RotationRules: { + AutomaticallyAfterDays: 30, + }, + }); + + expect(stack).toHaveResource('AWS::SecretsManager::ResourcePolicy', { + ResourcePolicy: { + Statement: [ + { + Action: 'secretsmanager:DeleteSecret', + Effect: 'Deny', + Principal: { + AWS: { + 'Fn::Join': [ + '', + [ + 'arn:', + { + Ref: 'AWS::Partition', + }, + ':iam::', + { + Ref: 'AWS::AccountId', + }, + ':root', + ], + ], + }, + }, + Resource: '*', + }, + ], + Version: '2012-10-17', + }, + SecretId: { + Ref: 'MasterSecretA11BF785', + }, + }); + }); + + test('single user in a vpc', () => { + // GIVEN + const vpc = new ec2.Vpc(stack, 'Vpc'); + const secret = new secretsmanager.Secret(stack, 'Secret'); + const dbSecurityGroup = new ec2.SecurityGroup(stack, 'SecurityGroup', { vpc }); + const dbConnections = new ec2.Connections({ + defaultPort: ec2.Port.tcp(3306), + securityGroups: [dbSecurityGroup], + }); + + // WHEN + const hostedRotation = secretsmanager.HostedRotation.mysqlSingleUser({ vpc }); + secret.addRotationSchedule('RotationSchedule', { hostedRotation }); + dbConnections.allowDefaultPortFrom(hostedRotation); + + // THEN + expect(stack).toHaveResource('AWS::SecretsManager::RotationSchedule', { + SecretId: { + Ref: 'SecretA720EF05', + }, + HostedRotationLambda: { + RotationType: 'MySQLSingleUser', + VpcSecurityGroupIds: { + 'Fn::GetAtt': [ + 'SecretRotationScheduleSecurityGroup3F1F76EA', + 'GroupId', + ], + }, + VpcSubnetIds: { + 'Fn::Join': [ + '', + [ + { + Ref: 'VpcPrivateSubnet1Subnet536B997A', + }, + ',', + { + Ref: 'VpcPrivateSubnet2Subnet3788AAA1', + }, + ], + ], + }, + }, + RotationRules: { + AutomaticallyAfterDays: 30, + }, + }); + + expect(stack).toHaveResource('AWS::EC2::SecurityGroupIngress', { + FromPort: 3306, + GroupId: { + 'Fn::GetAtt': [ + 'SecurityGroupDD263621', + 'GroupId', + ], + }, + SourceSecurityGroupId: { + 'Fn::GetAtt': [ + 'SecretRotationScheduleSecurityGroup3F1F76EA', + 'GroupId', + ], + }, + ToPort: 3306, + }); + }); + + test('single user in a vpc with security groups', () => { + // GIVEN + const vpc = new ec2.Vpc(stack, 'Vpc'); + const secret = new secretsmanager.Secret(stack, 'Secret'); + const dbSecurityGroup = new ec2.SecurityGroup(stack, 'SecurityGroup', { vpc }); + const dbConnections = new ec2.Connections({ + defaultPort: ec2.Port.tcp(3306), + securityGroups: [dbSecurityGroup], + }); + + // WHEN + const hostedRotation = secretsmanager.HostedRotation.mysqlSingleUser({ + vpc, + securityGroups: [ + new ec2.SecurityGroup(stack, 'SG1', { vpc }), + new ec2.SecurityGroup(stack, 'SG2', { vpc }), + ], + }); + secret.addRotationSchedule('RotationSchedule', { hostedRotation }); + dbConnections.allowDefaultPortFrom(hostedRotation); + + // THEN + expect(stack).toHaveResource('AWS::SecretsManager::RotationSchedule', { + SecretId: { + Ref: 'SecretA720EF05', + }, + HostedRotationLambda: { + RotationType: 'MySQLSingleUser', + VpcSecurityGroupIds: { + 'Fn::Join': [ + '', + [ + { + 'Fn::GetAtt': [ + 'SG1BA065B6E', + 'GroupId', + ], + }, + ',', + { + 'Fn::GetAtt': [ + 'SG20CE3219C', + 'GroupId', + ], + }, + ], + ], + }, + VpcSubnetIds: { + 'Fn::Join': [ + '', + [ + { + Ref: 'VpcPrivateSubnet1Subnet536B997A', + }, + ',', + { + Ref: 'VpcPrivateSubnet2Subnet3788AAA1', + }, + ], + ], + }, + }, + RotationRules: { + AutomaticallyAfterDays: 30, + }, + }); + + expect(stack).toHaveResource('AWS::EC2::SecurityGroupIngress', { + FromPort: 3306, + GroupId: { + 'Fn::GetAtt': [ + 'SecurityGroupDD263621', + 'GroupId', + ], + }, + SourceSecurityGroupId: { + 'Fn::GetAtt': [ + 'SG20CE3219C', + 'GroupId', + ], + }, + ToPort: 3306, + }); + }); + + test('throws with security groups and no vpc', () => { + // GIVEN + const secret = new secretsmanager.Secret(stack, 'Secret'); + + // THEN + expect(() => secret.addRotationSchedule('RotationSchedule', { + hostedRotation: secretsmanager.HostedRotation.oracleSingleUser({ + securityGroups: [ec2.SecurityGroup.fromSecurityGroupId(secret, 'SG', 'sg-12345678')], + }), + })).toThrow(/`vpc` must be specified when specifying `securityGroups`/); + }); + + test('throws when accessing the connections object when not in a vpc', () => { + // GIVEN + const secret = new secretsmanager.Secret(stack, 'Secret'); + + // WHEN + const hostedRotation = secretsmanager.HostedRotation.sqlServerSingleUser(); + secret.addRotationSchedule('RotationSchedule', { hostedRotation }); + + // THEN + expect(() => hostedRotation.connections.allowToAnyIpv4(ec2.Port.allTraffic())) + .toThrow(/Cannot use connections for a hosted rotation that is not deployed in a VPC/); + }); +});