diff --git a/packages/@aws-cdk/aws-rds/README.md b/packages/@aws-cdk/aws-rds/README.md index 89fc1011c464b..6c9f86df82f7f 100644 --- a/packages/@aws-cdk/aws-rds/README.md +++ b/packages/@aws-cdk/aws-rds/README.md @@ -108,32 +108,36 @@ For an instance database: const address = instance.instanceEndpoint.socketAddress; // "HOSTNAME:PORT" ``` -### Rotating master password +### Rotating credentials When the master password is generated and stored in AWS Secrets Manager, it can be rotated automatically: +```ts +instance.addRotationSingleUser(); // Will rotate automatically after 30 days +``` [example of setting up master password rotation for a cluster](test/integ.cluster-rotation.lit.ts) -Rotation of the master password is also supported for an existing cluster: +The multi user rotation scheme is also available: ```ts -new SecretRotation(stack, 'Rotation', { - secret: importedSecret, - application: SecretRotationApplication.ORACLE_ROTATION_SINGLE_USER - target: importedCluster, // or importedInstance - vpc: importedVpc, -}) +instance.addRotationMultiUser('MyUser', { + secret: myImportedSecret +}); ``` -The `importedSecret` must be a JSON string with the following format: -```json -{ - "engine": "", - "host": "", - "username": "", - "password": "", - "dbname": "", - "port": "" -} +It's also possible to create user credentials together with the instance/cluster and add rotation: +```ts +const myUserSecret = new rds.DatabaseSecret(this, 'MyUserSecret', { + username: 'myuser' +}); +const myUserSecretAttached = myUserSecret.attach(instance); // Adds DB connections information in the secret + +instance.addRotationMultiUser('MyUser', { // Add rotation using the multi user scheme + secret: myUserSecretAttached +}); ``` +**Note**: This user must be created manually in the database using the master credentials. +The rotation will start as soon as this user exists. + +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. ### Metrics Database instances expose metrics (`cloudwatch.Metric`): diff --git a/packages/@aws-cdk/aws-rds/lib/cluster.ts b/packages/@aws-cdk/aws-rds/lib/cluster.ts index 5ac51111e0976..bd8ea3330ce8f 100644 --- a/packages/@aws-cdk/aws-rds/lib/cluster.ts +++ b/packages/@aws-cdk/aws-rds/lib/cluster.ts @@ -7,9 +7,8 @@ import { DatabaseClusterAttributes, IDatabaseCluster } from './cluster-ref'; import { DatabaseSecret } from './database-secret'; import { Endpoint } from './endpoint'; import { IParameterGroup } from './parameter-group'; -import { BackupProps, DatabaseClusterEngine, InstanceProps, Login } from './props'; +import { BackupProps, DatabaseClusterEngine, InstanceProps, Login, RotationMultiUserOptions } from './props'; import { CfnDBCluster, CfnDBInstance, CfnDBSubnetGroup } from './rds.generated'; -import { SecretRotation, SecretRotationApplication, SecretRotationOptions } from './secret-rotation'; /** * Properties for a new database cluster @@ -190,7 +189,7 @@ abstract class DatabaseClusterBase extends Resource implements IDatabaseCluster public asSecretAttachmentTarget(): secretsmanager.SecretAttachmentTargetProps { return { targetId: this.clusterIdentifier, - targetType: secretsmanager.AttachmentTargetType.CLUSTER + targetType: secretsmanager.AttachmentTargetType.RDS_DB_CLUSTER }; } } @@ -262,10 +261,8 @@ export class DatabaseCluster extends DatabaseClusterBase { */ public readonly secret?: secretsmanager.ISecret; - /** - * The database engine of this cluster - */ - private readonly secretRotationApplication: SecretRotationApplication; + private readonly singleUserRotationApplication: secretsmanager.SecretRotationApplication; + private readonly multiUserRotationApplication: secretsmanager.SecretRotationApplication; /** * The VPC where the DB subnet group is created. @@ -302,7 +299,7 @@ export class DatabaseCluster extends DatabaseClusterBase { }); this.securityGroupId = securityGroup.securityGroupId; - let secret; + let secret: DatabaseSecret | undefined; if (!props.masterUser.password) { secret = new DatabaseSecret(this, 'Secret', { username: props.masterUser.username, @@ -310,7 +307,8 @@ export class DatabaseCluster extends DatabaseClusterBase { }); } - this.secretRotationApplication = props.engine.secretRotationApplication; + this.singleUserRotationApplication = props.engine.singleUserRotationApplication; + this.multiUserRotationApplication = props.engine.multiUserRotationApplication; const cluster = new CfnDBCluster(this, 'Resource', { // Basic @@ -349,9 +347,7 @@ export class DatabaseCluster extends DatabaseClusterBase { this.clusterReadEndpoint = new Endpoint(cluster.attrReadEndpointAddress, portAttribute); if (secret) { - this.secret = secret.addTargetAttachment('AttachedSecret', { - target: this - }); + this.secret = secret.attach(this); } const instanceCount = props.instances != null ? props.instances : 2; @@ -415,18 +411,46 @@ export class DatabaseCluster extends DatabaseClusterBase { /** * Adds the single user rotation of the master password to this cluster. + * + * @param [automaticallyAfter=Duration.days(30)] Specifies the number of days after the previous rotation + * before Secrets Manager triggers the next automatic rotation. */ - public addRotationSingleUser(id: string, options: SecretRotationOptions = {}): SecretRotation { + public addRotationSingleUser(automaticallyAfter?: Duration): secretsmanager.SecretRotation { if (!this.secret) { throw new Error('Cannot add single user rotation for a cluster without secret.'); } - return new SecretRotation(this, id, { + + 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, { secret: this.secret, - application: this.secretRotationApplication, + automaticallyAfter, + application: this.singleUserRotationApplication, + vpc: this.vpc, + vpcSubnets: this.vpcSubnets, + 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, { + secret: options.secret, + masterSecret: this.secret, + automaticallyAfter: options.automaticallyAfter, + application: this.multiUserRotationApplication, vpc: this.vpc, vpcSubnets: this.vpcSubnets, target: this, - ...options }); } } diff --git a/packages/@aws-cdk/aws-rds/lib/index.ts b/packages/@aws-cdk/aws-rds/lib/index.ts index 116e542651658..83e4a99bc4a07 100644 --- a/packages/@aws-cdk/aws-rds/lib/index.ts +++ b/packages/@aws-cdk/aws-rds/lib/index.ts @@ -2,7 +2,6 @@ export * from './cluster'; export * from './cluster-ref'; export * from './props'; export * from './parameter-group'; -export * from './secret-rotation'; export * from './database-secret'; export * from './endpoint'; export * from './option-group'; diff --git a/packages/@aws-cdk/aws-rds/lib/instance.ts b/packages/@aws-cdk/aws-rds/lib/instance.ts index 1fab29f7e2e9d..0defd9136a583 100644 --- a/packages/@aws-cdk/aws-rds/lib/instance.ts +++ b/packages/@aws-cdk/aws-rds/lib/instance.ts @@ -10,9 +10,8 @@ import { DatabaseSecret } from './database-secret'; import { Endpoint } from './endpoint'; import { IOptionGroup } from './option-group'; import { IParameterGroup } from './parameter-group'; -import { DatabaseClusterEngine } from './props'; +import { DatabaseClusterEngine, RotationMultiUserOptions } from './props'; import { CfnDBInstance, CfnDBInstanceProps, CfnDBSubnetGroup } from './rds.generated'; -import { SecretRotation, SecretRotationApplication, SecretRotationOptions } from './secret-rotation'; /** * A database instance @@ -144,7 +143,7 @@ export abstract class DatabaseInstanceBase extends Resource implements IDatabase public asSecretAttachmentTarget(): secretsmanager.SecretAttachmentTargetProps { return { targetId: this.instanceIdentifier, - targetType: secretsmanager.AttachmentTargetType.INSTANCE + targetType: secretsmanager.AttachmentTargetType.RDS_DB_INSTANCE }; } } @@ -154,17 +153,19 @@ export abstract class DatabaseInstanceBase extends Resource implements IDatabase * secret rotation. */ export class DatabaseInstanceEngine extends DatabaseClusterEngine { - public static readonly MARIADB = new DatabaseInstanceEngine('mariadb', SecretRotationApplication.MARIADB_ROTATION_SINGLE_USER); - public static readonly MYSQL = new DatabaseInstanceEngine('mysql', SecretRotationApplication.MYSQL_ROTATION_SINGLE_USER); - public static readonly ORACLE_EE = new DatabaseInstanceEngine('oracle-ee', SecretRotationApplication.ORACLE_ROTATION_SINGLE_USER); - public static readonly ORACLE_SE2 = new DatabaseInstanceEngine('oracle-se2', SecretRotationApplication.ORACLE_ROTATION_SINGLE_USER); - public static readonly ORACLE_SE1 = new DatabaseInstanceEngine('oracle-se1', SecretRotationApplication.ORACLE_ROTATION_SINGLE_USER); - public static readonly ORACLE_SE = new DatabaseInstanceEngine('oracle-se', SecretRotationApplication.ORACLE_ROTATION_SINGLE_USER); - public static readonly POSTGRES = new DatabaseInstanceEngine('postgres', SecretRotationApplication.POSTGRES_ROTATION_SINGLE_USER); - public static readonly SQL_SERVER_EE = new DatabaseInstanceEngine('sqlserver-ee', SecretRotationApplication.SQLSERVER_ROTATION_SINGLE_USER); - public static readonly SQL_SERVER_SE = new DatabaseInstanceEngine('sqlserver-se', SecretRotationApplication.SQLSERVER_ROTATION_SINGLE_USER); - public static readonly SQL_SERVER_EX = new DatabaseInstanceEngine('sqlserver-ex', SecretRotationApplication.SQLSERVER_ROTATION_SINGLE_USER); - public static readonly SQL_SERVER_WEB = new DatabaseInstanceEngine('sqlserver-web', SecretRotationApplication.SQLSERVER_ROTATION_SINGLE_USER); + /* tslint:disable max-line-length */ + public static readonly MARIADB = new DatabaseInstanceEngine('mariadb', secretsmanager.SecretRotationApplication.MARIADB_ROTATION_SINGLE_USER, secretsmanager.SecretRotationApplication.MARIADB_ROTATION_MULTI_USER); + public static readonly MYSQL = new DatabaseInstanceEngine('mysql', secretsmanager.SecretRotationApplication.MYSQL_ROTATION_SINGLE_USER, secretsmanager.SecretRotationApplication.MYSQL_ROTATION_MULTI_USER); + public static readonly ORACLE_EE = new DatabaseInstanceEngine('oracle-ee', secretsmanager.SecretRotationApplication.ORACLE_ROTATION_SINGLE_USER, secretsmanager.SecretRotationApplication.ORACLE_ROTATION_MULTI_USER); + public static readonly ORACLE_SE2 = new DatabaseInstanceEngine('oracle-se2', secretsmanager.SecretRotationApplication.ORACLE_ROTATION_SINGLE_USER, secretsmanager.SecretRotationApplication.ORACLE_ROTATION_MULTI_USER); + public static readonly ORACLE_SE1 = new DatabaseInstanceEngine('oracle-se1', secretsmanager.SecretRotationApplication.ORACLE_ROTATION_SINGLE_USER, secretsmanager.SecretRotationApplication.ORACLE_ROTATION_MULTI_USER); + public static readonly ORACLE_SE = new DatabaseInstanceEngine('oracle-se', secretsmanager.SecretRotationApplication.ORACLE_ROTATION_SINGLE_USER, secretsmanager.SecretRotationApplication.ORACLE_ROTATION_MULTI_USER); + public static readonly POSTGRES = new DatabaseInstanceEngine('postgres', secretsmanager.SecretRotationApplication.POSTGRES_ROTATION_SINGLE_USER, secretsmanager.SecretRotationApplication.POSTGRES_ROTATION_MULTI_USER); + public static readonly SQL_SERVER_EE = new DatabaseInstanceEngine('sqlserver-ee', secretsmanager.SecretRotationApplication.SQLSERVER_ROTATION_SINGLE_USER, secretsmanager.SecretRotationApplication.SQLSERVER_ROTATION_MULTI_USER); + public static readonly SQL_SERVER_SE = new DatabaseInstanceEngine('sqlserver-se', secretsmanager.SecretRotationApplication.SQLSERVER_ROTATION_SINGLE_USER, secretsmanager.SecretRotationApplication.SQLSERVER_ROTATION_MULTI_USER); + public static readonly SQL_SERVER_EX = new DatabaseInstanceEngine('sqlserver-ex', secretsmanager.SecretRotationApplication.SQLSERVER_ROTATION_SINGLE_USER, secretsmanager.SecretRotationApplication.SQLSERVER_ROTATION_MULTI_USER); + public static readonly SQL_SERVER_WEB = new DatabaseInstanceEngine('sqlserver-web', secretsmanager.SecretRotationApplication.SQLSERVER_ROTATION_SINGLE_USER, secretsmanager.SecretRotationApplication.SQLSERVER_ROTATION_MULTI_USER); + /* tslint:enable max-line-length */ } /** @@ -665,12 +666,14 @@ abstract class DatabaseInstanceSource extends DatabaseInstanceNew implements IDa protected readonly sourceCfnProps: CfnDBInstanceProps; - private readonly secretRotationApplication: SecretRotationApplication; + private readonly singleUserRotationApplication: secretsmanager.SecretRotationApplication; + private readonly multiUserRotationApplication: secretsmanager.SecretRotationApplication; constructor(scope: Construct, id: string, props: DatabaseInstanceSourceProps) { super(scope, id, props); - this.secretRotationApplication = props.engine.secretRotationApplication; + this.singleUserRotationApplication = props.engine.singleUserRotationApplication; + this.multiUserRotationApplication = props.engine.multiUserRotationApplication; this.sourceCfnProps = { ...this.newCfnProps, @@ -687,18 +690,46 @@ abstract class DatabaseInstanceSource extends DatabaseInstanceNew implements IDa /** * Adds the single user rotation of the master password to this instance. + * + * @param [automaticallyAfter=Duration.days(30)] Specifies the number of days after the previous rotation + * before Secrets Manager triggers the next automatic rotation. */ - public addRotationSingleUser(id: string, options: SecretRotationOptions = {}): SecretRotation { + public addRotationSingleUser(automaticallyAfter?: Duration): secretsmanager.SecretRotation { if (!this.secret) { throw new Error('Cannot add single user rotation for an instance without secret.'); } - return new SecretRotation(this, id, { + + const id = 'RotationSingleUser'; + const existing = this.node.tryFindChild(id); + if (existing) { + throw new Error('A single user rotation was already added to this instance.'); + } + + return new secretsmanager.SecretRotation(this, id, { secret: this.secret, - application: this.secretRotationApplication, + automaticallyAfter, + application: this.singleUserRotationApplication, + vpc: this.vpc, + vpcSubnets: this.vpcPlacement, + target: this, + }); + } + + /** + * Adds the multi user rotation to this instance. + */ + public addRotationMultiUser(id: string, options: RotationMultiUserOptions): secretsmanager.SecretRotation { + if (!this.secret) { + throw new Error('Cannot add multi user rotation for an instance without secret.'); + } + return new secretsmanager.SecretRotation(this, id, { + secret: options.secret, + masterSecret: this.secret, + automaticallyAfter: options.automaticallyAfter, + application: this.multiUserRotationApplication, vpc: this.vpc, vpcSubnets: this.vpcPlacement, target: this, - ...options }); } } @@ -750,7 +781,7 @@ export class DatabaseInstance extends DatabaseInstanceSource implements IDatabas constructor(scope: Construct, id: string, props: DatabaseInstanceProps) { super(scope, id, props); - let secret; + let secret: DatabaseSecret | undefined; if (!props.masterUserPassword) { secret = new DatabaseSecret(this, 'Secret', { username: props.masterUsername, @@ -782,9 +813,7 @@ export class DatabaseInstance extends DatabaseInstanceSource implements IDatabas }); if (secret) { - this.secret = secret.addTargetAttachment('AttachedSecret', { - target: this - }); + this.secret = secret.attach(this); } this.setLogRetention(); @@ -882,9 +911,7 @@ export class DatabaseInstanceFromSnapshot extends DatabaseInstanceSource impleme }); if (secret) { - this.secret = secret.addTargetAttachment('AttachedSecret', { - target: this - }); + this.secret = secret.attach(this); } this.setLogRetention(); diff --git a/packages/@aws-cdk/aws-rds/lib/props.ts b/packages/@aws-cdk/aws-rds/lib/props.ts index 263a3b4f6e7ef..da3e4c95929b1 100644 --- a/packages/@aws-cdk/aws-rds/lib/props.ts +++ b/packages/@aws-cdk/aws-rds/lib/props.ts @@ -1,17 +1,19 @@ import ec2 = require('@aws-cdk/aws-ec2'); import kms = require('@aws-cdk/aws-kms'); +import secretsmanager = require('@aws-cdk/aws-secretsmanager'); import { Duration, SecretValue } from '@aws-cdk/core'; import { IParameterGroup } from './parameter-group'; -import { SecretRotationApplication } from './secret-rotation'; /** * A database cluster engine. Provides mapping to the serverless application * used for secret rotation. */ export class DatabaseClusterEngine { - public static readonly AURORA = new DatabaseClusterEngine('aurora', SecretRotationApplication.MYSQL_ROTATION_SINGLE_USER); - public static readonly AURORA_MYSQL = new DatabaseClusterEngine('aurora-mysql', SecretRotationApplication.MYSQL_ROTATION_SINGLE_USER); - public static readonly AURORA_POSTGRESQL = new DatabaseClusterEngine('aurora-postgresql', SecretRotationApplication.POSTGRES_ROTATION_SINGLE_USER); + /* tslint:disable max-line-length */ + public static readonly AURORA = new DatabaseClusterEngine('aurora', secretsmanager.SecretRotationApplication.MYSQL_ROTATION_SINGLE_USER, secretsmanager.SecretRotationApplication.MYSQL_ROTATION_MULTI_USER); + public static readonly AURORA_MYSQL = new DatabaseClusterEngine('aurora-mysql', secretsmanager.SecretRotationApplication.MYSQL_ROTATION_SINGLE_USER, secretsmanager.SecretRotationApplication.MYSQL_ROTATION_MULTI_USER); + public static readonly AURORA_POSTGRESQL = new DatabaseClusterEngine('aurora-postgresql', secretsmanager.SecretRotationApplication.POSTGRES_ROTATION_SINGLE_USER, secretsmanager.SecretRotationApplication.POSTGRES_ROTATION_MULTI_USER); + /* tslint:enable max-line-length */ /** * The engine. @@ -19,13 +21,19 @@ export class DatabaseClusterEngine { public readonly name: string; /** - * The secret rotation application. + * The single user secret rotation application. */ - public readonly secretRotationApplication: SecretRotationApplication; + public readonly singleUserRotationApplication: secretsmanager.SecretRotationApplication; - constructor(name: string, secretRotationApplication: SecretRotationApplication) { + /** + * The multi user secret rotation application. + */ + public readonly multiUserRotationApplication: secretsmanager.SecretRotationApplication; + + constructor(name: string, singleUserRotationApplication: secretsmanager.SecretRotationApplication, multiUserRotationApplication) { this.name = name; - this.secretRotationApplication = secretRotationApplication; + this.singleUserRotationApplication = singleUserRotationApplication; + this.multiUserRotationApplication = multiUserRotationApplication; } } @@ -121,3 +129,30 @@ export interface Login { */ readonly kmsKey?: kms.IKey; } + +/** + * Options to add the multi user rotation + */ +export interface RotationMultiUserOptions { + /** + * The secret to rotate. It must be a JSON string with the following format: + * { + * "engine": , + * "host": , + * "username": , + * "password": , + * "dbname": , + * "port": , + * "masterarn": + * } + */ + readonly secret: secretsmanager.ISecret; + + /** + * Specifies the number of days after the previous rotation before + * Secrets Manager triggers the next automatic rotation. + * + * @default Duration.days(30) + */ + readonly automaticallyAfter?: Duration; +} diff --git a/packages/@aws-cdk/aws-rds/lib/secret-rotation.ts b/packages/@aws-cdk/aws-rds/lib/secret-rotation.ts deleted file mode 100644 index 630521c85d187..0000000000000 --- a/packages/@aws-cdk/aws-rds/lib/secret-rotation.ts +++ /dev/null @@ -1,146 +0,0 @@ -import ec2 = require('@aws-cdk/aws-ec2'); -import lambda = require('@aws-cdk/aws-lambda'); -import serverless = require('@aws-cdk/aws-sam'); -import secretsmanager = require('@aws-cdk/aws-secretsmanager'); -import { Construct, Duration, Stack } from '@aws-cdk/core'; - -/** - * A secret rotation serverless application. - */ -export class SecretRotationApplication { - public static readonly MARIADB_ROTATION_SINGLE_USER = new SecretRotationApplication('SecretsManagerRDSMariaDBRotationSingleUser', '1.0.57'); - public static readonly MARIADB_ROTATION_MULTI_USER = new SecretRotationApplication('SecretsManagerRDSMariaDBRotationMultiUser', '1.0.57'); - - public static readonly MYSQL_ROTATION_SINGLE_USER = new SecretRotationApplication('SecretsManagerRDSMySQLRotationSingleUser', '1.0.85'); - public static readonly MYSQL_ROTATION_MULTI_USER = new SecretRotationApplication('SecretsManagerRDSMySQLRotationMultiUser', '1.0.85'); - - public static readonly ORACLE_ROTATION_SINGLE_USER = new SecretRotationApplication('SecretsManagerRDSOracleRotationSingleUser', '1.0.56'); - public static readonly ORACLE_ROTATION_MULTI_USER = new SecretRotationApplication('SecretsManagerRDSOracleRotationMultiUser', '1.0.56'); - - public static readonly POSTGRES_ROTATION_SINGLE_USER = new SecretRotationApplication('SecretsManagerRDSPostgreSQLRotationSingleUser', '1.0.86'); - public static readonly POSTGRES_ROTATION_MULTI_USER = new SecretRotationApplication('SecretsManagerRDSPostgreSQLRotationMultiUser ', '1.0.86'); - - public static readonly SQLSERVER_ROTATION_SINGLE_USER = new SecretRotationApplication('SecretsManagerRDSSQLServerRotationSingleUser', '1.0.57'); - public static readonly SQLSERVER_ROTATION_MULTI_USER = new SecretRotationApplication('SecretsManagerRDSSQLServerRotationMultiUser', '1.0.57'); - - public readonly applicationId: string; - public readonly semanticVersion: string; - - constructor(applicationId: string, semanticVersion: string) { - this.applicationId = `arn:aws:serverlessrepo:us-east-1:297356227824:applications/${applicationId}`; - this.semanticVersion = semanticVersion; - } -} - -/** - * Options to add secret rotation to a database instance or cluster. - */ -export interface SecretRotationOptions { - /** - * Specifies the number of days after the previous rotation before - * Secrets Manager triggers the next automatic rotation. - * - * @default Duration.days(30) - */ - readonly automaticallyAfter?: Duration; -} - -/** - * Construction properties for a SecretRotation. - */ -export interface SecretRotationProps extends SecretRotationOptions { - /** - * The secret to rotate. It 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 - * https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-secretsmanager-secrettargetattachment.html - */ - readonly secret: secretsmanager.ISecret; - - /** - * The serverless application for the rotation. - */ - readonly application: SecretRotationApplication; - - /** - * The VPC where the Lambda rotation function will run. - */ - readonly vpc: ec2.IVpc; - - /** - * The type of subnets in the VPC where the Lambda rotation function will run. - * - * @default - Private subnets. - */ - readonly vpcSubnets?: ec2.SubnetSelection; - - /** - * The target database cluster or instance - */ - readonly target: ec2.IConnectable; -} - -/** - * Secret rotation for a database instance or cluster. - */ -export class SecretRotation extends Construct { - constructor(scope: Construct, id: string, props: SecretRotationProps) { - super(scope, id); - - if (!props.target.connections.defaultPort) { - throw new Error('The `target` connections must have a default port range.'); - } - - const rotationFunctionName = this.node.uniqueId; - - const securityGroup = new ec2.SecurityGroup(this, 'SecurityGroup', { - vpc: props.vpc - }); - - const { subnetIds } = props.vpc.selectSubnets(props.vpcSubnets); - - props.target.connections.allowDefaultPortFrom(securityGroup); - - const application = new serverless.CfnApplication(this, 'Resource', { - location: props.application, - parameters: { - endpoint: `https://secretsmanager.${Stack.of(this).region}.${Stack.of(this).urlSuffix}`, - functionName: rotationFunctionName, - vpcSecurityGroupIds: securityGroup.securityGroupId, - vpcSubnetIds: subnetIds.join(',') - } - }); - - // Dummy import to reference this function in the rotation schedule - const rotationLambda = lambda.Function.fromFunctionArn(this, 'RotationLambda', Stack.of(this).formatArn({ - service: 'lambda', - resource: 'function', - sep: ':', - resourceName: rotationFunctionName - })); - - // Cannot use rotationLambda.addPermission because it's a no-op on imported - // functions. - const permission = new lambda.CfnPermission(this, 'Permission', { - action: 'lambda:InvokeFunction', - functionName: rotationFunctionName, - principal: `secretsmanager.${Stack.of(this).urlSuffix}` - }); - permission.node.addDependency(application); // Add permission after application is deployed - - const rotationSchedule = props.secret.addRotationSchedule('RotationSchedule', { - rotationLambda, - automaticallyAfter: props.automaticallyAfter - }); - rotationSchedule.node.addDependency(permission); // Cannot rotate without permission - } -} diff --git a/packages/@aws-cdk/aws-rds/package.json b/packages/@aws-cdk/aws-rds/package.json index 89cb010b2edef..fe7e143b15ee9 100644 --- a/packages/@aws-cdk/aws-rds/package.json +++ b/packages/@aws-cdk/aws-rds/package.json @@ -77,7 +77,6 @@ "@aws-cdk/aws-kms": "1.18.0", "@aws-cdk/aws-lambda": "1.18.0", "@aws-cdk/aws-logs": "1.18.0", - "@aws-cdk/aws-sam": "1.18.0", "@aws-cdk/aws-secretsmanager": "1.18.0", "@aws-cdk/core": "1.18.0" }, @@ -90,7 +89,6 @@ "@aws-cdk/aws-kms": "1.18.0", "@aws-cdk/aws-lambda": "1.18.0", "@aws-cdk/aws-logs": "1.18.0", - "@aws-cdk/aws-sam": "1.18.0", "@aws-cdk/aws-secretsmanager": "1.18.0", "@aws-cdk/core": "1.18.0" }, diff --git a/packages/@aws-cdk/aws-rds/test/integ.cluster-rotation.lit.expected.json b/packages/@aws-cdk/aws-rds/test/integ.cluster-rotation.lit.expected.json index 5d1afa3c08a50..aa73530e40228 100644 --- a/packages/@aws-cdk/aws-rds/test/integ.cluster-rotation.lit.expected.json +++ b/packages/@aws-cdk/aws-rds/test/integ.cluster-rotation.lit.expected.json @@ -530,11 +530,11 @@ } } }, - "DatabaseSecurityGroupfromawscdkrdsclusterrotationDatabaseRotationSecurityGroup35913E19IndirectPort12DA2942": { + "DatabaseSecurityGroupfromawscdkrdsclusterrotationDatabaseRotationSingleUserSecurityGroup0FFF34B1IndirectPortE6A88723": { "Type": "AWS::EC2::SecurityGroupIngress", "Properties": { "IpProtocol": "tcp", - "Description": "from awscdkrdsclusterrotationDatabaseRotationSecurityGroup35913E19:{IndirectPort}", + "Description": "from awscdkrdsclusterrotationDatabaseRotationSingleUserSecurityGroup0FFF34B1:{IndirectPort}", "FromPort": { "Fn::GetAtt": [ "DatabaseB269D8BB", @@ -549,7 +549,7 @@ }, "SourceSecurityGroupId": { "Fn::GetAtt": [ - "DatabaseRotationSecurityGroup17736B63", + "DatabaseRotationSingleUserSecurityGroupAC6E0E73", "GroupId" ] }, @@ -572,7 +572,7 @@ } } }, - "DatabaseSecretAttachedSecretE6CAC445": { + "DatabaseSecretAttachmentE5D1B020": { "Type": "AWS::SecretsManager::SecretTargetAttachment", "Properties": { "SecretId": { @@ -584,39 +584,58 @@ "TargetType": "AWS::RDS::DBCluster" } }, - "DatabaseSecretAttachedSecretRotationSchedule93D67FF7": { + "DatabaseSecretAttachmentRotationScheduleA4E9F034": { "Type": "AWS::SecretsManager::RotationSchedule", "Properties": { "SecretId": { - "Ref": "DatabaseSecretAttachedSecretE6CAC445" + "Ref": "DatabaseSecretAttachmentE5D1B020" }, "RotationLambdaARN": { - "Fn::Join": [ - "", - [ - "arn:", - { - "Ref": "AWS::Partition" - }, - ":lambda:", - { - "Ref": "AWS::Region" - }, - ":", - { - "Ref": "AWS::AccountId" - }, - ":function:awscdkrdsclusterrotationDatabaseRotation30042AAE" - ] + "Fn::GetAtt": [ + "DatabaseRotationSingleUser65F55654", + "Outputs.RotationLambdaARN" ] }, "RotationRules": { "AutomaticallyAfterDays": 30 } - }, - "DependsOn": [ - "DatabaseRotationPermission64416CB0" - ] + } + }, + "DatabaseSecretAttachmentPolicy5ACFE6CA": { + "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": "DatabaseSecretAttachmentE5D1B020" + } + } }, "DatabaseB269D8BB": { "Type": "AWS::RDS::DBCluster", @@ -658,13 +677,13 @@ } ] }, - "DeletionPolicy": "Retain", - "UpdateReplacePolicy": "Retain" + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain" }, "DatabaseInstance1844F58FD": { "Type": "AWS::RDS::DBInstance", "Properties": { - "DBInstanceClass": "db.t2.small", + "DBInstanceClass": "db.t3.small", "DBClusterIdentifier": { "Ref": "DatabaseB269D8BB" }, @@ -678,13 +697,13 @@ "VPCPrivateSubnet2DefaultRouteF4F5CFD2", "VPCPrivateSubnet3DefaultRoute27F311AE" ], - "DeletionPolicy": "Retain", - "UpdateReplacePolicy": "Retain" + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain" }, "DatabaseInstance2AA380DEE": { "Type": "AWS::RDS::DBInstance", "Properties": { - "DBInstanceClass": "db.t2.small", + "DBInstanceClass": "db.t3.small", "DBClusterIdentifier": { "Ref": "DatabaseB269D8BB" }, @@ -698,13 +717,13 @@ "VPCPrivateSubnet2DefaultRouteF4F5CFD2", "VPCPrivateSubnet3DefaultRoute27F311AE" ], - "DeletionPolicy": "Retain", - "UpdateReplacePolicy": "Retain" + "UpdateReplacePolicy": "Retain", + "DeletionPolicy": "Retain" }, - "DatabaseRotationSecurityGroup17736B63": { + "DatabaseRotationSingleUserSecurityGroupAC6E0E73": { "Type": "AWS::EC2::SecurityGroup", "Properties": { - "GroupDescription": "aws-cdk-rds-cluster-rotation/Database/Rotation/SecurityGroup", + "GroupDescription": "aws-cdk-rds-cluster-rotation/Database/RotationSingleUser/SecurityGroup", "SecurityGroupEgress": [ { "CidrIp": "0.0.0.0/0", @@ -717,12 +736,12 @@ } } }, - "DatabaseRotation6B6E1D86": { + "DatabaseRotationSingleUser65F55654": { "Type": "AWS::Serverless::Application", "Properties": { "Location": { "ApplicationId": "arn:aws:serverlessrepo:us-east-1:297356227824:applications/SecretsManagerRDSMySQLRotationSingleUser", - "SemanticVersion": "1.0.85" + "SemanticVersion": "1.1.3" }, "Parameters": { "endpoint": { @@ -740,13 +759,7 @@ ] ] }, - "functionName": "awscdkrdsclusterrotationDatabaseRotation30042AAE", - "vpcSecurityGroupIds": { - "Fn::GetAtt": [ - "DatabaseRotationSecurityGroup17736B63", - "GroupId" - ] - }, + "functionName": "awscdkrdsclusterrotationDatabaseRotationSingleUser171A8E3A", "vpcSubnetIds": { "Fn::Join": [ "", @@ -764,30 +777,15 @@ } ] ] + }, + "vpcSecurityGroupIds": { + "Fn::GetAtt": [ + "DatabaseRotationSingleUserSecurityGroupAC6E0E73", + "GroupId" + ] } } } - }, - "DatabaseRotationPermission64416CB0": { - "Type": "AWS::Lambda::Permission", - "Properties": { - "Action": "lambda:InvokeFunction", - "FunctionName": "awscdkrdsclusterrotationDatabaseRotation30042AAE", - "Principal": { - "Fn::Join": [ - "", - [ - "secretsmanager.", - { - "Ref": "AWS::URLSuffix" - } - ] - ] - } - }, - "DependsOn": [ - "DatabaseRotation6B6E1D86" - ] } } } \ No newline at end of file diff --git a/packages/@aws-cdk/aws-rds/test/integ.cluster-rotation.lit.ts b/packages/@aws-cdk/aws-rds/test/integ.cluster-rotation.lit.ts index 358684cd56830..37e22c9e359f7 100644 --- a/packages/@aws-cdk/aws-rds/test/integ.cluster-rotation.lit.ts +++ b/packages/@aws-cdk/aws-rds/test/integ.cluster-rotation.lit.ts @@ -14,12 +14,12 @@ const cluster = new rds.DatabaseCluster(stack, 'Database', { username: 'admin' }, instanceProps: { - instanceType: ec2.InstanceType.of(ec2.InstanceClass.BURSTABLE2, ec2.InstanceSize.SMALL), + instanceType: ec2.InstanceType.of(ec2.InstanceClass.BURSTABLE3, ec2.InstanceSize.SMALL), vpc } }); -cluster.addRotationSingleUser('Rotation'); +cluster.addRotationSingleUser(); /// !hide app.synth(); diff --git a/packages/@aws-cdk/aws-rds/test/integ.cluster.expected.json b/packages/@aws-cdk/aws-rds/test/integ.cluster.expected.json index 2b103215681a6..4c4442cf044ec 100644 --- a/packages/@aws-cdk/aws-rds/test/integ.cluster.expected.json +++ b/packages/@aws-cdk/aws-rds/test/integ.cluster.expected.json @@ -492,7 +492,7 @@ "DatabaseInstance1844F58FD": { "Type": "AWS::RDS::DBInstance", "Properties": { - "DBInstanceClass": "db.t2.small", + "DBInstanceClass": "db.t3.small", "DBClusterIdentifier": { "Ref": "DatabaseB269D8BB" }, @@ -512,7 +512,7 @@ "DatabaseInstance2AA380DEE": { "Type": "AWS::RDS::DBInstance", "Properties": { - "DBInstanceClass": "db.t2.small", + "DBInstanceClass": "db.t3.small", "DBClusterIdentifier": { "Ref": "DatabaseB269D8BB" }, diff --git a/packages/@aws-cdk/aws-rds/test/integ.cluster.ts b/packages/@aws-cdk/aws-rds/test/integ.cluster.ts index f35b07bc86d68..650b119b82dd9 100644 --- a/packages/@aws-cdk/aws-rds/test/integ.cluster.ts +++ b/packages/@aws-cdk/aws-rds/test/integ.cluster.ts @@ -26,7 +26,7 @@ const cluster = new DatabaseCluster(stack, 'Database', { password: SecretValue.plainText('7959866cacc02c2d243ecfe177464fe6'), }, instanceProps: { - instanceType: ec2.InstanceType.of(ec2.InstanceClass.BURSTABLE2, ec2.InstanceSize.SMALL), + instanceType: ec2.InstanceType.of(ec2.InstanceClass.BURSTABLE3, ec2.InstanceSize.SMALL), vpcSubnets: { subnetType: ec2.SubnetType.PUBLIC }, vpc }, diff --git a/packages/@aws-cdk/aws-rds/test/integ.instance.lit.expected.json b/packages/@aws-cdk/aws-rds/test/integ.instance.lit.expected.json index 66d6b7510e59d..fef1025663829 100644 --- a/packages/@aws-cdk/aws-rds/test/integ.instance.lit.expected.json +++ b/packages/@aws-cdk/aws-rds/test/integ.instance.lit.expected.json @@ -460,11 +460,11 @@ } } }, - "InstanceSecurityGroupfromawscdkrdsinstanceInstanceRotationSecurityGroupBB71D98EIndirectPort60E4E51A": { + "InstanceSecurityGroupfromawscdkrdsinstanceInstanceRotationSingleUserSecurityGroupE959D912IndirectPortFF39F421": { "Type": "AWS::EC2::SecurityGroupIngress", "Properties": { "IpProtocol": "tcp", - "Description": "from awscdkrdsinstanceInstanceRotationSecurityGroupBB71D98E:{IndirectPort}", + "Description": "from awscdkrdsinstanceInstanceRotationSingleUserSecurityGroupE959D912:{IndirectPort}", "FromPort": { "Fn::GetAtt": [ "InstanceC1063A87", @@ -479,7 +479,7 @@ }, "SourceSecurityGroupId": { "Fn::GetAtt": [ - "InstanceRotationSecurityGroupEF8D211E", + "InstanceRotationSingleUserSecurityGroupF3FB5C25", "GroupId" ] }, @@ -533,7 +533,7 @@ } } }, - "InstanceSecretAttachedSecretBACA1D43": { + "InstanceSecretAttachment83BEE581": { "Type": "AWS::SecretsManager::SecretTargetAttachment", "Properties": { "SecretId": { @@ -545,44 +545,63 @@ "TargetType": "AWS::RDS::DBInstance" } }, - "InstanceSecretAttachedSecretRotationSchedule275109B7": { + "InstanceSecretAttachmentRotationScheduleCC555119": { "Type": "AWS::SecretsManager::RotationSchedule", "Properties": { "SecretId": { - "Ref": "InstanceSecretAttachedSecretBACA1D43" + "Ref": "InstanceSecretAttachment83BEE581" }, "RotationLambdaARN": { - "Fn::Join": [ - "", - [ - "arn:", - { - "Ref": "AWS::Partition" - }, - ":lambda:", - { - "Ref": "AWS::Region" - }, - ":", - { - "Ref": "AWS::AccountId" - }, - ":function:awscdkrdsinstanceInstanceRotation0925DC60" - ] + "Fn::GetAtt": [ + "InstanceRotationSingleUser90E8AB49", + "Outputs.RotationLambdaARN" ] }, "RotationRules": { "AutomaticallyAfterDays": 30 } - }, - "DependsOn": [ - "InstanceRotationPermission63844D0A" - ] + } + }, + "InstanceSecretAttachmentPolicy60A8B8DE": { + "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": "InstanceSecretAttachment83BEE581" + } + } }, "InstanceC1063A87": { "Type": "AWS::RDS::DBInstance", "Properties": { - "DBInstanceClass": "db.t2.medium", + "DBInstanceClass": "db.t3.medium", "AllocatedStorage": "100", "AutoMinorVersionUpgrade": false, "BackupRetentionPeriod": 7, @@ -751,10 +770,10 @@ "RetentionInDays": 30 } }, - "InstanceRotationSecurityGroupEF8D211E": { + "InstanceRotationSingleUserSecurityGroupF3FB5C25": { "Type": "AWS::EC2::SecurityGroup", "Properties": { - "GroupDescription": "aws-cdk-rds-instance/Instance/Rotation/SecurityGroup", + "GroupDescription": "aws-cdk-rds-instance/Instance/RotationSingleUser/SecurityGroup", "SecurityGroupEgress": [ { "CidrIp": "0.0.0.0/0", @@ -767,12 +786,12 @@ } } }, - "InstanceRotationAA37A997": { + "InstanceRotationSingleUser90E8AB49": { "Type": "AWS::Serverless::Application", "Properties": { "Location": { "ApplicationId": "arn:aws:serverlessrepo:us-east-1:297356227824:applications/SecretsManagerRDSOracleRotationSingleUser", - "SemanticVersion": "1.0.56" + "SemanticVersion": "1.1.3" }, "Parameters": { "endpoint": { @@ -790,13 +809,7 @@ ] ] }, - "functionName": "awscdkrdsinstanceInstanceRotation0925DC60", - "vpcSecurityGroupIds": { - "Fn::GetAtt": [ - "InstanceRotationSecurityGroupEF8D211E", - "GroupId" - ] - }, + "functionName": "awscdkrdsinstanceInstanceRotationSingleUserAFE3C214", "vpcSubnetIds": { "Fn::Join": [ "", @@ -810,31 +823,16 @@ } ] ] + }, + "vpcSecurityGroupIds": { + "Fn::GetAtt": [ + "InstanceRotationSingleUserSecurityGroupF3FB5C25", + "GroupId" + ] } } } }, - "InstanceRotationPermission63844D0A": { - "Type": "AWS::Lambda::Permission", - "Properties": { - "Action": "lambda:InvokeFunction", - "FunctionName": "awscdkrdsinstanceInstanceRotation0925DC60", - "Principal": { - "Fn::Join": [ - "", - [ - "secretsmanager.", - { - "Ref": "AWS::URLSuffix" - } - ] - ] - } - }, - "DependsOn": [ - "InstanceRotationAA37A997" - ] - }, "InstanceAvailabilityAD5D452C": { "Type": "AWS::Events::Rule", "Properties": { @@ -1002,7 +1000,6 @@ "Properties": { "ComparisonOperator": "GreaterThanOrEqualToThreshold", "EvaluationPeriods": 1, - "Threshold": 90, "Dimensions": [ { "Name": "DBInstanceIdentifier", @@ -1014,7 +1011,8 @@ "MetricName": "CPUUtilization", "Namespace": "AWS/RDS", "Period": 300, - "Statistic": "Average" + "Statistic": "Average", + "Threshold": 90 } }, "FunctionServiceRole675BB04A": { @@ -1088,8 +1086,17 @@ } }, "Parameters": { - "AssetParametersb23e818e172b6771c5f4fde08381b8d0904b5760fcfcceee68523a83fff99c42S3Bucket773E39E4": {"Type":"String","Description":"S3 bucket for asset \"b23e818e172b6771c5f4fde08381b8d0904b5760fcfcceee68523a83fff99c42\""}, - "AssetParametersb23e818e172b6771c5f4fde08381b8d0904b5760fcfcceee68523a83fff99c42S3VersionKey96D611D4": {"Type":"String","Description":"S3 key for asset version \"b23e818e172b6771c5f4fde08381b8d0904b5760fcfcceee68523a83fff99c42\""}, - "AssetParametersb23e818e172b6771c5f4fde08381b8d0904b5760fcfcceee68523a83fff99c42ArtifactHashD6C3B3DD": {"Type":"String","Description":"Artifact hash for asset \"b23e818e172b6771c5f4fde08381b8d0904b5760fcfcceee68523a83fff99c42\""} + "AssetParametersb23e818e172b6771c5f4fde08381b8d0904b5760fcfcceee68523a83fff99c42S3Bucket773E39E4": { + "Type": "String", + "Description": "S3 bucket for asset \"b23e818e172b6771c5f4fde08381b8d0904b5760fcfcceee68523a83fff99c42\"" + }, + "AssetParametersb23e818e172b6771c5f4fde08381b8d0904b5760fcfcceee68523a83fff99c42S3VersionKey96D611D4": { + "Type": "String", + "Description": "S3 key for asset version \"b23e818e172b6771c5f4fde08381b8d0904b5760fcfcceee68523a83fff99c42\"" + }, + "AssetParametersb23e818e172b6771c5f4fde08381b8d0904b5760fcfcceee68523a83fff99c42ArtifactHashD6C3B3DD": { + "Type": "String", + "Description": "Artifact hash for asset \"b23e818e172b6771c5f4fde08381b8d0904b5760fcfcceee68523a83fff99c42\"" + } } -} +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-rds/test/integ.instance.lit.ts b/packages/@aws-cdk/aws-rds/test/integ.instance.lit.ts index 20989d49a6b73..578f7de84a7be 100644 --- a/packages/@aws-cdk/aws-rds/test/integ.instance.lit.ts +++ b/packages/@aws-cdk/aws-rds/test/integ.instance.lit.ts @@ -46,7 +46,7 @@ class DatabaseInstanceStack extends cdk.Stack { const instance = new rds.DatabaseInstance(this, 'Instance', { engine: rds.DatabaseInstanceEngine.ORACLE_SE1, licenseModel: rds.LicenseModel.BRING_YOUR_OWN_LICENSE, - instanceClass: ec2.InstanceType.of(ec2.InstanceClass.BURSTABLE2, ec2.InstanceSize.MEDIUM), + instanceClass: ec2.InstanceType.of(ec2.InstanceClass.BURSTABLE3, ec2.InstanceSize.MEDIUM), multiAz: true, storageType: rds.StorageType.IO1, masterUsername: 'syscdk', @@ -72,7 +72,7 @@ class DatabaseInstanceStack extends cdk.Stack { instance.connections.allowDefaultPortFromAnyIpv4(); // Rotate the master user password every 30 days - instance.addRotationSingleUser('Rotation'); + instance.addRotationSingleUser(); // Add alarm for high CPU new cloudwatch.Alarm(this, 'HighCPU', { diff --git a/packages/@aws-cdk/aws-rds/test/test.cluster.ts b/packages/@aws-cdk/aws-rds/test/test.cluster.ts index 980f6d9950688..22cea759005a8 100644 --- a/packages/@aws-cdk/aws-rds/test/test.cluster.ts +++ b/packages/@aws-cdk/aws-rds/test/test.cluster.ts @@ -470,7 +470,53 @@ export = { }, ResourcePart.Properties)); test.done(); - } + }, + + 'throws when trying to add rotation to a cluster without secret'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.Vpc(stack, 'VPC'); + + // WHEN + const cluster = new DatabaseCluster(stack, 'Database', { + engine: DatabaseClusterEngine.AURORA_MYSQL, + masterUser: { + username: 'admin', + password: SecretValue.plainText('tooshort') + }, + instanceProps: { + instanceType: ec2.InstanceType.of(ec2.InstanceClass.BURSTABLE2, ec2.InstanceSize.SMALL), + vpc + } + }); + + // THEN + test.throws(() => cluster.addRotationSingleUser(), /without secret/); + + test.done(); + }, + + 'throws when trying to add single user rotation multiple timet'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.Vpc(stack, 'VPC'); + const cluster = new DatabaseCluster(stack, 'Database', { + engine: DatabaseClusterEngine.AURORA_MYSQL, + masterUser: { username: 'admin' }, + instanceProps: { + instanceType: ec2.InstanceType.of(ec2.InstanceClass.BURSTABLE2, ec2.InstanceSize.SMALL), + vpc + } + }); + + // WHEN + cluster.addRotationSingleUser(); + + // THEN + test.throws(() => cluster.addRotationSingleUser(), /A single user rotation was already added to this cluster/); + + test.done(); + }, }; function testStack() { diff --git a/packages/@aws-cdk/aws-rds/test/test.instance.ts b/packages/@aws-cdk/aws-rds/test/test.instance.ts index 9c012ea3fd32d..747006176d2a8 100644 --- a/packages/@aws-cdk/aws-rds/test/test.instance.ts +++ b/packages/@aws-cdk/aws-rds/test/test.instance.ts @@ -623,5 +623,43 @@ export = { })); test.done(); - } + }, + + 'throws when trying to add rotation to an instance without secret'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.Vpc(stack, 'VPC'); + const instance = new rds.DatabaseInstance(stack, 'Database', { + engine: rds.DatabaseInstanceEngine.SQL_SERVER_EE, + instanceClass: ec2.InstanceType.of(ec2.InstanceClass.BURSTABLE2, ec2.InstanceSize.SMALL), + masterUsername: 'syscdk', + masterUserPassword: cdk.SecretValue.plainText('tooshort'), + vpc + }); + + // THEN + test.throws(() => instance.addRotationSingleUser(), /without secret/); + + test.done(); + }, + + 'throws when trying to add single user rotation multiple times'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.Vpc(stack, 'VPC'); + const instance = new rds.DatabaseInstance(stack, 'Database', { + engine: rds.DatabaseInstanceEngine.SQL_SERVER_EE, + instanceClass: ec2.InstanceType.of(ec2.InstanceClass.BURSTABLE2, ec2.InstanceSize.SMALL), + masterUsername: 'syscdk', + vpc + }); + + // WHEN + instance.addRotationSingleUser(); + + // THEN + test.throws(() => instance.addRotationSingleUser(), /A single user rotation was already added to this instance/); + + test.done(); + }, }; diff --git a/packages/@aws-cdk/aws-rds/test/test.secret-rotation.ts b/packages/@aws-cdk/aws-rds/test/test.secret-rotation.ts deleted file mode 100644 index bca042afb8f43..0000000000000 --- a/packages/@aws-cdk/aws-rds/test/test.secret-rotation.ts +++ /dev/null @@ -1,367 +0,0 @@ -import { expect, haveResource } from '@aws-cdk/assert'; -import ec2 = require('@aws-cdk/aws-ec2'); -import secretsmanager = require('@aws-cdk/aws-secretsmanager'); -import cdk = require('@aws-cdk/core'); -import { SecretValue } from '@aws-cdk/core'; -import { Test } from 'nodeunit'; -import rds = require('../lib'); -import { SecretRotationApplication } from '../lib'; - -// tslint:disable:object-literal-key-quotes - -export = { - 'add a rds rotation single user to a cluster'(test: Test) { - // GIVEN - const stack = new cdk.Stack(); - const vpc = new ec2.Vpc(stack, 'VPC'); - const cluster = new rds.DatabaseCluster(stack, 'Database', { - engine: rds.DatabaseClusterEngine.AURORA_MYSQL, - masterUser: { - username: 'admin' - }, - instanceProps: { - instanceType: ec2.InstanceType.of(ec2.InstanceClass.BURSTABLE2, ec2.InstanceSize.SMALL), - vpc - } - }); - - // WHEN - cluster.addRotationSingleUser('Rotation'); - - // THEN - expect(stack).to(haveResource('AWS::EC2::SecurityGroupIngress', { - "IpProtocol": "tcp", - "Description": "from DatabaseRotationSecurityGroup1C5A8031:{IndirectPort}", - "FromPort": { - "Fn::GetAtt": [ - "DatabaseB269D8BB", - "Endpoint.Port" - ] - }, - "GroupId": { - "Fn::GetAtt": [ - "DatabaseSecurityGroup5C91FDCB", - "GroupId" - ] - }, - "SourceSecurityGroupId": { - "Fn::GetAtt": [ - "DatabaseRotationSecurityGroup17736B63", - "GroupId" - ] - }, - "ToPort": { - "Fn::GetAtt": [ - "DatabaseB269D8BB", - "Endpoint.Port" - ] - } - })); - - expect(stack).to(haveResource('AWS::SecretsManager::RotationSchedule', { - "SecretId": { - "Ref": "DatabaseSecretAttachedSecretE6CAC445" - }, - "RotationLambdaARN": { - "Fn::Join": [ - "", - [ - "arn:", - { - "Ref": "AWS::Partition" - }, - ":lambda:", - { - "Ref": "AWS::Region" - }, - ":", - { - "Ref": "AWS::AccountId" - }, - ":function:DatabaseRotation0D47EBD2" - ] - ] - }, - "RotationRules": { - "AutomaticallyAfterDays": 30 - } - })); - - expect(stack).to(haveResource('AWS::EC2::SecurityGroup', { - "GroupDescription": "Database/Rotation/SecurityGroup" - })); - - expect(stack).to(haveResource('AWS::Serverless::Application', { - "Location": { - "ApplicationId": "arn:aws:serverlessrepo:us-east-1:297356227824:applications/SecretsManagerRDSMySQLRotationSingleUser", - "SemanticVersion": "1.0.85" - }, - "Parameters": { - "endpoint": { - "Fn::Join": [ - "", - [ - "https://secretsmanager.", - { - "Ref": "AWS::Region" - }, - ".", - { - "Ref": "AWS::URLSuffix" - } - ] - ] - }, - "functionName": "DatabaseRotation0D47EBD2", - "vpcSecurityGroupIds": { - "Fn::GetAtt": [ - "DatabaseRotationSecurityGroup17736B63", - "GroupId" - ] - }, - "vpcSubnetIds": { - "Fn::Join": [ - "", - [ - { - "Ref": "VPCPrivateSubnet1Subnet8BCA10E0" - }, - ",", - { - "Ref": "VPCPrivateSubnet2SubnetCFCDAA7A" - }, - ] - ] - } - } - })); - - expect(stack).to(haveResource('AWS::Lambda::Permission', { - "Action": "lambda:InvokeFunction", - "FunctionName": "DatabaseRotation0D47EBD2", - "Principal": { - "Fn::Join": [ - "", - [ - "secretsmanager.", - { - "Ref": "AWS::URLSuffix" - } - ] - ] - } - })); - - test.done(); - }, - - 'throws when trying to add rotation to a cluster without secret'(test: Test) { - // GIVEN - const stack = new cdk.Stack(); - const vpc = new ec2.Vpc(stack, 'VPC'); - - // WHEN - const cluster = new rds.DatabaseCluster(stack, 'Database', { - engine: rds.DatabaseClusterEngine.AURORA_MYSQL, - masterUser: { - username: 'admin', - password: SecretValue.plainText('tooshort') - }, - instanceProps: { - instanceType: ec2.InstanceType.of(ec2.InstanceClass.BURSTABLE2, ec2.InstanceSize.SMALL), - vpc - } - }); - - // THEN - test.throws(() => cluster.addRotationSingleUser('Rotation'), /without secret/); - - test.done(); - }, - - 'throws when connections object has no default port range'(test: Test) { - // GIVEN - const stack = new cdk.Stack(); - const vpc = new ec2.Vpc(stack, 'VPC'); - const secret = new secretsmanager.Secret(stack, 'Secret'); - const securityGroup = new ec2.SecurityGroup(stack, 'SecurityGroup', { - vpc, - }); - - // WHEN - const target = new ec2.Connections({ - securityGroups: [securityGroup] - }); - - // THEN - test.throws(() => new rds.SecretRotation(stack, 'Rotation', { - secret, - application: SecretRotationApplication.MYSQL_ROTATION_SINGLE_USER, - vpc, - target - }), /`target`.+default port range/); - - test.done(); - }, - - 'add a rds rotation single user to an instance'(test: Test) { - // GIVEN - const stack = new cdk.Stack(); - const vpc = new ec2.Vpc(stack, 'VPC'); - const instance = new rds.DatabaseInstance(stack, 'Database', { - engine: rds.DatabaseInstanceEngine.MARIADB, - instanceClass: ec2.InstanceType.of(ec2.InstanceClass.BURSTABLE2, ec2.InstanceSize.SMALL), - masterUsername: 'syscdk', - vpc - }); - - // WHEN - instance.addRotationSingleUser('Rotation'); - - // THEN - expect(stack).to(haveResource('AWS::EC2::SecurityGroupIngress', { - "IpProtocol": "tcp", - "Description": "from DatabaseRotationSecurityGroup1C5A8031:{IndirectPort}", - "FromPort": { - "Fn::GetAtt": [ - "DatabaseB269D8BB", - "Endpoint.Port" - ] - }, - "GroupId": { - "Fn::GetAtt": [ - "DatabaseSecurityGroup5C91FDCB", - "GroupId" - ] - }, - "SourceSecurityGroupId": { - "Fn::GetAtt": [ - "DatabaseRotationSecurityGroup17736B63", - "GroupId" - ] - }, - "ToPort": { - "Fn::GetAtt": [ - "DatabaseB269D8BB", - "Endpoint.Port" - ] - } - })); - - expect(stack).to(haveResource('AWS::SecretsManager::RotationSchedule', { - "SecretId": { - "Ref": "DatabaseSecretAttachedSecretE6CAC445" - }, - "RotationLambdaARN": { - "Fn::Join": [ - "", - [ - "arn:", - { - "Ref": "AWS::Partition" - }, - ":lambda:", - { - "Ref": "AWS::Region" - }, - ":", - { - "Ref": "AWS::AccountId" - }, - ":function:DatabaseRotation0D47EBD2" - ] - ] - }, - "RotationRules": { - "AutomaticallyAfterDays": 30 - } - })); - - expect(stack).to(haveResource('AWS::EC2::SecurityGroup', { - "GroupDescription": "Database/Rotation/SecurityGroup" - })); - - expect(stack).to(haveResource('AWS::Serverless::Application', { - "Location": { - "ApplicationId": "arn:aws:serverlessrepo:us-east-1:297356227824:applications/SecretsManagerRDSMariaDBRotationSingleUser", - "SemanticVersion": "1.0.57" - }, - "Parameters": { - "endpoint": { - "Fn::Join": [ - "", - [ - "https://secretsmanager.", - { - "Ref": "AWS::Region" - }, - ".", - { - "Ref": "AWS::URLSuffix" - } - ] - ] - }, - "functionName": "DatabaseRotation0D47EBD2", - "vpcSecurityGroupIds": { - "Fn::GetAtt": [ - "DatabaseRotationSecurityGroup17736B63", - "GroupId" - ] - }, - "vpcSubnetIds": { - "Fn::Join": [ - "", - [ - { - "Ref": "VPCPrivateSubnet1Subnet8BCA10E0" - }, - ",", - { - "Ref": "VPCPrivateSubnet2SubnetCFCDAA7A" - }, - ] - ] - } - } - })); - - expect(stack).to(haveResource('AWS::Lambda::Permission', { - "Action": "lambda:InvokeFunction", - "FunctionName": "DatabaseRotation0D47EBD2", - "Principal": { - "Fn::Join": [ - "", - [ - "secretsmanager.", - { - "Ref": "AWS::URLSuffix" - } - ] - ] - } - })); - - test.done(); - }, - - 'throws when trying to add rotation to an instance without secret'(test: Test) { - // GIVEN - const stack = new cdk.Stack(); - const vpc = new ec2.Vpc(stack, 'VPC'); - - // WHEN - const instance = new rds.DatabaseInstance(stack, 'Database', { - engine: rds.DatabaseInstanceEngine.SQL_SERVER_EE, - instanceClass: ec2.InstanceType.of(ec2.InstanceClass.BURSTABLE2, ec2.InstanceSize.SMALL), - masterUsername: 'syscdk', - masterUserPassword: SecretValue.plainText('tooshort'), - vpc - }); - - // THEN - test.throws(() => instance.addRotationSingleUser('Rotation'), /without secret/); - - test.done(); - }, -}; diff --git a/packages/@aws-cdk/aws-secretsmanager/README.md b/packages/@aws-cdk/aws-secretsmanager/README.md index b36a9d7478089..b48b11c08a2ea 100644 --- a/packages/@aws-cdk/aws-secretsmanager/README.md +++ b/packages/@aws-cdk/aws-secretsmanager/README.md @@ -39,8 +39,8 @@ const secret = secretsmanager.Secret.fromSecretAttributes(scope, 'ImportedSecret SecretsManager secret values can only be used in select set of properties. For the list of properties, see [the CloudFormation Dynamic References documentation](https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/dynamic-references.htm). -### Rotating a Secret -A rotation schedule can be added to a Secret: +### Rotating a Secret with a custom Lambda function +A rotation schedule can be added to a Secret using a custom Lambda function: ```ts const fn = new lambda.Function(...); const secret = new secretsmanager.Secret(this, 'Secret'); @@ -52,4 +52,40 @@ 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. -For RDS credentials rotation, see [aws-rds](https://github.com/aws/aws-cdk/blob/master/packages/%40aws-cdk/aws-rds/README.md). +### Rotating database credentials +Define a `SecretRotation` to rotate database credentials: +```ts +new SecretRotation(this, 'SecretRotation', { + application: SecretRotationApplication.MYSQL_ROTATION_SINGLE_USER, // MySQL single user scheme + secret: mySecret, + target: myDatabase, // a Connectable + vpc: myVpc, // The VPC where the secret rotation application will be deployed +}); +``` + +The secret must be a JSON string with the following format: +```json +{ + "engine": "", + "host": "", + "username": "", + "password": "", + "dbname": "", + "port": "", + "masterarn": "" +} +``` + +For the multi user scheme, a `masterSecret` must be specified: +```ts +new SecretRotation(stack, 'SecretRotation', { + application: SecretRotationApplication.MYSQL_ROTATION_MULTI_USER, + secret: myUserSecret, // The secret that will be rotated + masterSecret: myMasterSecret, // The secret used for the rotation + target: myDatabase, + vpc: myVpc, +}); +``` + +See also [aws-rds](https://github.com/aws/aws-cdk/blob/master/packages/%40aws-cdk/aws-rds/README.md) where +credentials generation and rotation is integrated. diff --git a/packages/@aws-cdk/aws-secretsmanager/lib/index.ts b/packages/@aws-cdk/aws-secretsmanager/lib/index.ts index 79568e657c24c..7a001d748decf 100644 --- a/packages/@aws-cdk/aws-secretsmanager/lib/index.ts +++ b/packages/@aws-cdk/aws-secretsmanager/lib/index.ts @@ -1,5 +1,7 @@ export * from './secret'; export * from './rotation-schedule'; +export * from './policy'; +export * from './secret-rotation'; // AWS::SecretsManager CloudFormation Resources: export * from './secretsmanager.generated'; diff --git a/packages/@aws-cdk/aws-secretsmanager/lib/policy.ts b/packages/@aws-cdk/aws-secretsmanager/lib/policy.ts new file mode 100644 index 0000000000000..cff0e24f803f2 --- /dev/null +++ b/packages/@aws-cdk/aws-secretsmanager/lib/policy.ts @@ -0,0 +1,33 @@ +import iam = require('@aws-cdk/aws-iam'); +import { Construct, Resource } from '@aws-cdk/core'; +import { ISecret } from './secret'; +import { CfnResourcePolicy } from './secretsmanager.generated'; + +/** + * Construction properties for a ResourcePolicy + */ +export interface ResourcePolicyProps { + /** + * The secret to attach a resource-based permissions policy + */ + readonly secret: ISecret; +} + +/** + * Secret Resource Policy + */ +export class ResourcePolicy extends Resource { + /** + * The IAM policy document for this policy. + */ + public readonly document = new iam.PolicyDocument(); + + constructor(scope: Construct, id: string, props: ResourcePolicyProps) { + super(scope, id); + + new CfnResourcePolicy(this, 'Resource', { + resourcePolicy: this.document, + secretId: props.secret.secretArn, + }); + } +} diff --git a/packages/@aws-cdk/aws-secretsmanager/lib/secret-rotation.ts b/packages/@aws-cdk/aws-secretsmanager/lib/secret-rotation.ts new file mode 100644 index 0000000000000..3523b38a4cf69 --- /dev/null +++ b/packages/@aws-cdk/aws-secretsmanager/lib/secret-rotation.ts @@ -0,0 +1,234 @@ +import ec2 = require('@aws-cdk/aws-ec2'); +import lambda = require('@aws-cdk/aws-lambda'); +import serverless = require('@aws-cdk/aws-sam'); +import { Construct, Duration, Stack, Token } from '@aws-cdk/core'; +import { ISecret } from './secret'; + +/** + * A secret rotation serverless application. + */ +export class SecretRotationApplication { + /** + * Conducts an AWS SecretsManager secret rotation for RDS MariaDB using the single user rotation scheme + */ + public static readonly MARIADB_ROTATION_SINGLE_USER = new SecretRotationApplication('SecretsManagerRDSMariaDBRotationSingleUser', '1.1.3'); + + /** + * Conducts an AWS SecretsManager secret rotation for RDS MariaDB using the multi user rotation scheme + */ + public static readonly MARIADB_ROTATION_MULTI_USER = new SecretRotationApplication('SecretsManagerRDSMariaDBRotationMultiUser', '1.1.3', true); + + /** + * Conducts an AWS SecretsManager secret rotation for RDS MySQL using the single user rotation scheme + */ + public static readonly MYSQL_ROTATION_SINGLE_USER = new SecretRotationApplication('SecretsManagerRDSMySQLRotationSingleUser', '1.1.3'); + + /** + * Conducts an AWS SecretsManager secret rotation for RDS MySQL using the multi user rotation scheme + */ + public static readonly MYSQL_ROTATION_MULTI_USER = new SecretRotationApplication('SecretsManagerRDSMySQLRotationMultiUser', '1.1.3', true); + + /** + * Conducts an AWS SecretsManager secret rotation for RDS Oracle using the single user rotation scheme + */ + public static readonly ORACLE_ROTATION_SINGLE_USER = new SecretRotationApplication('SecretsManagerRDSOracleRotationSingleUser', '1.1.3'); + + /** + * Conducts an AWS SecretsManager secret rotation for RDS Oracle using the multi user rotation scheme + */ + public static readonly ORACLE_ROTATION_MULTI_USER = new SecretRotationApplication('SecretsManagerRDSOracleRotationMultiUser', '1.1.3', true); + + /** + * Conducts an AWS SecretsManager secret rotation for RDS PostgreSQL using the single user rotation scheme + */ + public static readonly POSTGRES_ROTATION_SINGLE_USER = new SecretRotationApplication('SecretsManagerRDSPostgreSQLRotationSingleUser', '1.1.3'); + + /** + * Conducts an AWS SecretsManager secret rotation for RDS PostgreSQL using the multi user rotation scheme + */ + public static readonly POSTGRES_ROTATION_MULTI_USER = new SecretRotationApplication('SecretsManagerRDSPostgreSQLRotationMultiUser ', '1.1.3', true); // tslint:disable-line max-line-length + + /** + * Conducts an AWS SecretsManager secret rotation for RDS SQL Server using the single user rotation scheme + */ + public static readonly SQLSERVER_ROTATION_SINGLE_USER = new SecretRotationApplication('SecretsManagerRDSSQLServerRotationSingleUser', '1.1.3'); + + /** + * Conducts an AWS SecretsManager secret rotation for RDS SQL Server using the multi user rotation scheme + */ + public static readonly SQLSERVER_ROTATION_MULTI_USER = new SecretRotationApplication('SecretsManagerRDSSQLServerRotationMultiUser', '1.1.3', true); + + /** + * Conducts an AWS SecretsManager secret rotation for Amazon Redshift using the single user rotation scheme + */ + public static readonly REDSHIFT_ROTATION_SINGLE_USER = new SecretRotationApplication('SecretsManagerRedshiftRotationSingleUser', '1.1.3'); + + /** + * Conducts an AWS SecretsManager secret rotation for Amazon Redshift using the multi user rotation scheme + */ + public static readonly REDSHIFT_ROTATION_MULTI_USER = new SecretRotationApplication('SecretsManagerRedshiftRotationMultiUser', '1.1.3', true); + + /** + * Conducts an AWS SecretsManager secret rotation for MongoDB using the single user rotation scheme + */ + public static readonly MONGODB_ROTATION_SINGLE_USER = new SecretRotationApplication('SecretsManagerMongoDBRotationSingleUser', '1.1.3'); + + /** + * Conducts an AWS SecretsManager secret rotation for MongoDB using the multi user rotation scheme + */ + public static readonly MONGODB_ROTATION_MULTI_USER = new SecretRotationApplication('SecretsManagerMongoDBRotationMultiUser', '1.1.3', true); + + /** + * The application identifier of the rotation application + */ + public readonly applicationId: string; + + /** + * The semantic version of the rotation application + */ + public readonly semanticVersion: string; + + /** + * Wether the rotation application uses the mutli user scheme + */ + public readonly isMulti?: boolean; + + constructor(applicationId: string, semanticVersion: string, isMulti?: boolean) { + this.applicationId = `arn:aws:serverlessrepo:us-east-1:297356227824:applications/${applicationId}`; + this.semanticVersion = semanticVersion; + this.isMulti = isMulti; + } +} + +/** + * Construction properties for a SecretRotation. + */ +export interface SecretRotationProps { + /** + * The secret to rotate. It 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`. + * + * @see https://docs.aws.amazon.com/AWSCloudFormation/latest/UserGuide/aws-resource-secretsmanager-secrettargetattachment.html + */ + readonly secret: ISecret; + + /** + * The master secret for a multi user rotation scheme + * + * @default - single user rotation scheme + */ + readonly masterSecret?: ISecret; + + /** + * Specifies the number of days after the previous rotation before + * Secrets Manager triggers the next automatic rotation. + * + * @default Duration.days(30) + */ + readonly automaticallyAfter?: Duration; + + /** + * The serverless application for the rotation. + */ + readonly application: SecretRotationApplication; + + /** + * The VPC where the Lambda rotation function will run. + */ + readonly vpc: ec2.IVpc; + + /** + * The type of subnets in the VPC where the Lambda rotation function will run. + * + * @default - Private subnets. + */ + readonly vpcSubnets?: ec2.SubnetSelection; + + /** + * The target service or database + */ + readonly target: ec2.IConnectable; + + /** + * The security group for the Lambda rotation function + * + * @default - a new security group is created + */ + readonly securityGroup?: ec2.ISecurityGroup; +} + +/** + * Secret rotation for a service or database + */ +export class SecretRotation extends Construct { + constructor(scope: Construct, id: string, props: SecretRotationProps) { + super(scope, id); + + if (!props.target.connections.defaultPort) { + throw new Error('The `target` connections must have a default port range.'); + } + + if (props.application.isMulti && !props.masterSecret) { + throw new Error('The `masterSecret` must be specified for application using the multi user scheme.'); + } + + const rotationFunctionName = this.node.uniqueId; + + const securityGroup = props.securityGroup || new ec2.SecurityGroup(this, 'SecurityGroup', { + vpc: props.vpc + }); + props.target.connections.allowDefaultPortFrom(securityGroup); + + const parameters: { [key: string]: string } = { + endpoint: `https://secretsmanager.${Stack.of(this).region}.${Stack.of(this).urlSuffix}`, + functionName: rotationFunctionName, + vpcSubnetIds: props.vpc.selectSubnets(props.vpcSubnets).subnetIds.join(','), + vpcSecurityGroupIds: securityGroup.securityGroupId, + }; + + if (props.secret.encryptionKey) { + parameters.kmsKeyArn = props.secret.encryptionKey.keyArn; + } + + if (props.masterSecret) { + parameters.masterSecretArn = props.masterSecret.secretArn; + + if (props.masterSecret.encryptionKey) { + parameters.masterSecretKmsKeyArn = props.masterSecret.encryptionKey.keyArn; + } + } + + const application = new serverless.CfnApplication(this, 'Resource', { + location: props.application, + parameters, + }); + + // This creates a CF a dependency between the rotation schedule and the + // serverless application. This is needed because it's the application + // that creates the Lambda permission to invoke the function. + // See https://docs.aws.amazon.com/secretsmanager/latest/userguide/integrating_cloudformation.html + const rotationLambda = lambda.Function.fromFunctionArn(this, 'RotationLambda', Token.asString(application.getAtt('Outputs.RotationLambdaARN'))); + + props.secret.addRotationSchedule('RotationSchedule', { + rotationLambda, + automaticallyAfter: props.automaticallyAfter + }); + + // Prevent secrets deletions when rotation is in place + props.secret.denyAccountRootDelete(); + if (props.masterSecret) { + props.masterSecret.denyAccountRootDelete(); + } + } +} diff --git a/packages/@aws-cdk/aws-secretsmanager/lib/secret.ts b/packages/@aws-cdk/aws-secretsmanager/lib/secret.ts index 09359f1696859..5fea0c2102d03 100644 --- a/packages/@aws-cdk/aws-secretsmanager/lib/secret.ts +++ b/packages/@aws-cdk/aws-secretsmanager/lib/secret.ts @@ -1,6 +1,7 @@ import iam = require('@aws-cdk/aws-iam'); import kms = require('@aws-cdk/aws-kms'); import { Construct, IResource, Resource, SecretValue, Stack } from '@aws-cdk/core'; +import { ResourcePolicy } from './policy'; import { RotationSchedule, RotationScheduleOptions } from './rotation-schedule'; import secretsmanager = require('./secretsmanager.generated'); @@ -44,6 +45,21 @@ export interface ISecret extends IResource { * Adds a rotation schedule to the secret. */ addRotationSchedule(id: string, options: RotationScheduleOptions): RotationSchedule; + + /** + * Adds a statement to the IAM resource policy associated with this secret. + * + * If this secret was created in this stack, a resource policy will be + * automatically created upon the first call to `addToResourcePolicy`. If + * the secret is imported, then this is a no-op. + */ + addToResourcePolicy(statement: iam.PolicyStatement): void; + + /** + * Denies the `DeleteSecret` action to all principals within the current + * account. + */ + denyAccountRootDelete(): void; } /** @@ -103,6 +119,10 @@ abstract class SecretBase extends Resource implements ISecret { public abstract readonly encryptionKey?: kms.IKey; public abstract readonly secretArn: string; + protected abstract readonly autoCreatePolicy: boolean; + + private policy?: ResourcePolicy; + public grantRead(grantee: iam.IGrantable, versionStages?: string[]): iam.Grant { // @see https://docs.aws.amazon.com/fr_fr/secretsmanager/latest/userguide/auth-and-access_identity-based-policies.html @@ -142,6 +162,25 @@ abstract class SecretBase extends Resource implements ISecret { ...options }); } + + public addToResourcePolicy(statement: iam.PolicyStatement) { + if (!this.policy && this.autoCreatePolicy) { + this.policy = new ResourcePolicy(this, 'Policy', { secret: this }); + } + + if (this.policy) { + this.policy.document.addStatements(statement); + } + } + + public denyAccountRootDelete() { + this.addToResourcePolicy(new iam.PolicyStatement({ + actions: ['secretsmanager:DeleteSecret'], + effect: iam.Effect.DENY, + resources: ['*'], + principals: [new iam.AccountRootPrincipal()] + })); + } } /** @@ -164,6 +203,7 @@ export class Secret extends SecretBase { class Import extends SecretBase { public readonly encryptionKey = attrs.encryptionKey; public readonly secretArn = attrs.secretArn; + protected readonly autoCreatePolicy = false; } return new Import(scope, id); @@ -172,6 +212,8 @@ export class Secret extends SecretBase { public readonly encryptionKey?: kms.IKey; public readonly secretArn: string; + protected readonly autoCreatePolicy = true; + constructor(scope: Construct, id: string, props: SecretProps = {}) { super(scope, id, { physicalName: props.secretName, @@ -204,6 +246,8 @@ export class Secret extends SecretBase { * Adds a target attachment to the secret. * * @returns an AttachedSecret + * + * @deprecated use `attach()` instead */ public addTargetAttachment(id: string, options: AttachedSecretOptions): SecretTargetAttachment { return new SecretTargetAttachment(this, id, { @@ -211,6 +255,26 @@ export class Secret extends SecretBase { ...options }); } + + /** + * Attach a target to this secret + * + * @param target The target to attach + * @returns An attached secret + */ + public attach(target: ISecretAttachmentTarget): ISecret { + const id = 'Attachment'; + const existing = this.node.tryFindChild(id); + + if (existing) { + throw new Error('Secret is already attached to a target.'); + } + + return new SecretTargetAttachment(this, id, { + secret: this, + target, + }); + } } /** @@ -225,17 +289,48 @@ export interface ISecretAttachmentTarget { /** * The type of service or database that's being associated with the secret. + * + * @deprecated use `SecretTargetType` instead */ export enum AttachmentTargetType { /** * A database instance + * + * @deprecated use RDS_DB_INSTANCE instead */ INSTANCE = 'AWS::RDS::DBInstance', /** * A database cluster + * + * @deprecated use RDS_DB_CLUSTER instead + */ + CLUSTER = 'AWS::RDS::DBCluster', + + /** + * AWS::RDS::DBInstance */ - CLUSTER = 'AWS::RDS::DBCluster' + RDS_DB_INSTANCE = 'AWS::RDS::DBInstance', + + /** + * AWS::RDS::DBCluster + */ + RDS_DB_CLUSTER = 'AWS::RDS::DBCluster', + + /** + * AWS::Redshift::Cluster + */ + REDSHIFT_CLUSTER = 'AWS::Redshift::Cluster', + + /** + * AWS::DocDB::DBInstance + */ + DOCDB_DB_INSTANCE = 'AWS::DocDB::DBInstance', + + /** + * AWS::DocDB::DBCluster + */ + DOCDB_DB_CLUSTER = 'AWS::DocDB::DBCluster' } /** @@ -255,6 +350,8 @@ export interface SecretAttachmentTargetProps { /** * Options to add a secret attachment to a secret. + * + * @deprecated use `secret.attach()` instead */ export interface AttachedSecretOptions { /** @@ -292,6 +389,7 @@ export class SecretTargetAttachment extends SecretBase implements ISecretTargetA public encryptionKey?: kms.IKey | undefined; public secretArn = secretTargetAttachmentSecretArn; public secretTargetAttachmentSecretArn = secretTargetAttachmentSecretArn; + protected readonly autoCreatePolicy = false; } return new Import(scope, id); @@ -305,6 +403,8 @@ export class SecretTargetAttachment extends SecretBase implements ISecretTargetA */ public readonly secretTargetAttachmentSecretArn: string; + protected readonly autoCreatePolicy = true; + constructor(scope: Construct, id: string, props: SecretTargetAttachmentProps) { super(scope, id); diff --git a/packages/@aws-cdk/aws-secretsmanager/package.json b/packages/@aws-cdk/aws-secretsmanager/package.json index 09ee28d25f7f3..f18e912ff32b0 100644 --- a/packages/@aws-cdk/aws-secretsmanager/package.json +++ b/packages/@aws-cdk/aws-secretsmanager/package.json @@ -74,6 +74,7 @@ "@aws-cdk/aws-iam": "1.18.0", "@aws-cdk/aws-kms": "1.18.0", "@aws-cdk/aws-lambda": "1.18.0", + "@aws-cdk/aws-sam": "1.18.0", "@aws-cdk/core": "1.18.0" }, "peerDependencies": { @@ -81,6 +82,7 @@ "@aws-cdk/aws-iam": "1.18.0", "@aws-cdk/aws-kms": "1.18.0", "@aws-cdk/aws-lambda": "1.18.0", + "@aws-cdk/aws-sam": "1.18.0", "@aws-cdk/core": "1.18.0" }, "engines": { @@ -97,8 +99,9 @@ "props-default-doc:@aws-cdk/aws-secretsmanager.SecretStringGenerator.generateStringKey", "props-default-doc:@aws-cdk/aws-secretsmanager.SecretAttributes.encryptionKey", "docs-public-apis:@aws-cdk/aws-secretsmanager.ISecretTargetAttachment", - "docs-public-apis:@aws-cdk/aws-secretsmanager.SecretTargetAttachment.fromSecretTargetAttachmentSecretArn" + "docs-public-apis:@aws-cdk/aws-secretsmanager.SecretTargetAttachment.fromSecretTargetAttachmentSecretArn", + "props-physical-name:@aws-cdk/aws-secretsmanager.ResourcePolicyProps" ] }, "stability": "stable" -} \ No newline at end of file +} diff --git a/packages/@aws-cdk/aws-secretsmanager/test/test.secret-rotation.ts b/packages/@aws-cdk/aws-secretsmanager/test/test.secret-rotation.ts new file mode 100644 index 0000000000000..b5d953d0d9eb2 --- /dev/null +++ b/packages/@aws-cdk/aws-secretsmanager/test/test.secret-rotation.ts @@ -0,0 +1,294 @@ +import { expect, haveResource } from '@aws-cdk/assert'; +import ec2 = require('@aws-cdk/aws-ec2'); +import cdk = require('@aws-cdk/core'); +import { Test } from 'nodeunit'; +import secretsmanager = require('../lib'); + +export = { + 'secret rotation single user'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.Vpc(stack, 'VPC'); + const secret = new secretsmanager.Secret(stack, 'Secret'); + const target = new ec2.Connections({ + defaultPort: ec2.Port.tcp(3306), + securityGroups: [new ec2.SecurityGroup(stack, 'SecurityGroup', { vpc })] + }); + + // WHEN + new secretsmanager.SecretRotation(stack, 'SecretRotation', { + application: secretsmanager.SecretRotationApplication.MYSQL_ROTATION_SINGLE_USER, + secret, + target, + vpc, + }); + + // THEN + expect(stack).to(haveResource('AWS::EC2::SecurityGroupIngress', { + IpProtocol: 'tcp', + Description: 'from SecretRotationSecurityGroupAEC520AB:3306', + FromPort: 3306, + GroupId: { + 'Fn::GetAtt': [ + 'SecurityGroupDD263621', + 'GroupId' + ] + }, + SourceSecurityGroupId: { + 'Fn::GetAtt': [ + 'SecretRotationSecurityGroup9985012B', + 'GroupId' + ] + }, + ToPort: 3306 + })); + + expect(stack).to(haveResource('AWS::SecretsManager::RotationSchedule', { + SecretId: { + Ref: 'SecretA720EF05' + }, + RotationLambdaARN: { + 'Fn::GetAtt': [ + "SecretRotationA9FFCFA9", + "Outputs.RotationLambdaARN" + ] + }, + RotationRules: { + AutomaticallyAfterDays: 30 + } + })); + + expect(stack).to(haveResource('AWS::EC2::SecurityGroup', { + GroupDescription: 'SecretRotation/SecurityGroup' + })); + + expect(stack).to(haveResource('AWS::Serverless::Application', { + Location: { + ApplicationId: 'arn:aws:serverlessrepo:us-east-1:297356227824:applications/SecretsManagerRDSMySQLRotationSingleUser', + SemanticVersion: '1.1.3' + }, + Parameters: { + endpoint: { + 'Fn::Join': [ + '', + [ + 'https://secretsmanager.', + { + Ref: 'AWS::Region' + }, + '.', + { + Ref: 'AWS::URLSuffix' + } + ] + ] + }, + functionName: 'SecretRotation', + vpcSecurityGroupIds: { + 'Fn::GetAtt': [ + 'SecretRotationSecurityGroup9985012B', + 'GroupId' + ] + }, + vpcSubnetIds: { + 'Fn::Join': [ + '', + [ + { + Ref: 'VPCPrivateSubnet1Subnet8BCA10E0' + }, + ',', + { + Ref: 'VPCPrivateSubnet2SubnetCFCDAA7A' + }, + ] + ] + } + } + })); + + expect(stack).to(haveResource('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.done(); + }, + + 'secret rotation multi user'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.Vpc(stack, 'VPC'); + const secret = new secretsmanager.Secret(stack, 'Secret'); + const masterSecret = new secretsmanager.Secret(stack, 'MasterSecret'); + const target = new ec2.Connections({ + defaultPort: ec2.Port.tcp(3306), + securityGroups: [new ec2.SecurityGroup(stack, 'SecurityGroup', { vpc })] + }); + + // WHEN + new secretsmanager.SecretRotation(stack, 'SecretRotation', { + application: secretsmanager.SecretRotationApplication.MYSQL_ROTATION_MULTI_USER, + secret, + masterSecret, + target, + vpc, + }); + + // THEN + expect(stack).to(haveResource('AWS::Serverless::Application', { + Parameters: { + endpoint: { + 'Fn::Join': [ + '', + [ + 'https://secretsmanager.', + { + Ref: 'AWS::Region' + }, + '.', + { + Ref: 'AWS::URLSuffix' + } + ] + ] + }, + functionName: 'SecretRotation', + vpcSecurityGroupIds: { + 'Fn::GetAtt': [ + 'SecretRotationSecurityGroup9985012B', + 'GroupId' + ] + }, + vpcSubnetIds: { + 'Fn::Join': [ + '', + [ + { + Ref: 'VPCPrivateSubnet1Subnet8BCA10E0' + }, + ',', + { + Ref: 'VPCPrivateSubnet2SubnetCFCDAA7A' + }, + ] + ] + }, + masterSecretArn: { + Ref: 'MasterSecretA11BF785' + } + } + })); + + expect(stack).to(haveResource('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.done(); + }, + + 'throws when connections object has no default port range'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.Vpc(stack, 'VPC'); + const secret = new secretsmanager.Secret(stack, 'Secret'); + const securityGroup = new ec2.SecurityGroup(stack, 'SecurityGroup', { + vpc, + }); + + // WHEN + const target = new ec2.Connections({ + securityGroups: [securityGroup] + }); + + // THEN + test.throws(() => new secretsmanager.SecretRotation(stack, 'Rotation', { + secret, + application: secretsmanager.SecretRotationApplication.MYSQL_ROTATION_SINGLE_USER, + vpc, + target + }), /`target`.+default port range/); + + test.done(); + }, + + 'throws when master secret is missing for a multi user application'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.Vpc(stack, 'VPC'); + const secret = new secretsmanager.Secret(stack, 'Secret'); + const target = new ec2.Connections({ + defaultPort: ec2.Port.tcp(3306), + securityGroups: [new ec2.SecurityGroup(stack, 'SecurityGroup', { vpc })] + }); + + // THEN + test.throws(() => new secretsmanager.SecretRotation(stack, 'Rotation', { + secret, + application: secretsmanager.SecretRotationApplication.MYSQL_ROTATION_MULTI_USER, + vpc, + target + }), /The `masterSecret` must be specified for application using the multi user scheme/); + + test.done(); + }, +}; diff --git a/packages/@aws-cdk/aws-secretsmanager/test/test.secret.ts b/packages/@aws-cdk/aws-secretsmanager/test/test.secret.ts index ee83d609f96c4..2def00d5fd53d 100644 --- a/packages/@aws-cdk/aws-secretsmanager/test/test.secret.ts +++ b/packages/@aws-cdk/aws-secretsmanager/test/test.secret.ts @@ -310,43 +310,59 @@ export = { test.done(); }, - 'attached secret'(test: Test) { + 'can attach a secret with attach()'(test: Test) { // GIVEN - const stack = new cdk.Stack(); + const stack = new Stack(); const secret = new secretsmanager.Secret(stack, 'Secret'); - const target: secretsmanager.ISecretAttachmentTarget = { - asSecretAttachmentTarget: () => ({ - targetId: 'instance', - targetType: secretsmanager.AttachmentTargetType.INSTANCE - }) - }; // WHEN - secret.addTargetAttachment('AttachedSecret', { target }); + secret.attach({ + asSecretAttachmentTarget: () => ({ + targetId: 'target-id', + targetType: 'target-type' as secretsmanager.AttachmentTargetType + }) + }); // THEN expect(stack).to(haveResource('AWS::SecretsManager::SecretTargetAttachment', { SecretId: { Ref: 'SecretA720EF05' }, - TargetId: 'instance', - TargetType: 'AWS::RDS::DBInstance' + TargetId: 'target-id', + TargetType: 'target-type' })); test.done(); }, + 'throws when trying to attach a target multiple times to a secret'(test: Test) { + // GIVEN + const stack = new Stack(); + const secret = new secretsmanager.Secret(stack, 'Secret'); + const target = { + asSecretAttachmentTarget: () => ({ + targetId: 'target-id', + targetType: 'target-type' as secretsmanager.AttachmentTargetType + }) + }; + secret.attach(target); + + // THEN + test.throws(() => secret.attach(target), /Secret is already attached to a target/); + + test.done(); + }, + 'add a rotation schedule to an attached secret'(test: Test) { // GIVEN const stack = new cdk.Stack(); const secret = new secretsmanager.Secret(stack, 'Secret'); - const target: secretsmanager.ISecretAttachmentTarget = { + const attachedSecret = secret.attach({ asSecretAttachmentTarget: () => ({ - targetId: 'cluster', - targetType: secretsmanager.AttachmentTargetType.CLUSTER + targetId: 'target-id', + targetType: 'target-type' as secretsmanager.AttachmentTargetType }) - }; - const attachedSecret = secret.addTargetAttachment('AttachedSecret', { target }); + }); const rotationLambda = new lambda.Function(stack, 'Lambda', { runtime: lambda.Runtime.NODEJS_10_X, code: lambda.Code.fromInline('export.handler = event => event;'), @@ -361,7 +377,7 @@ export = { // THEN expect(stack).to(haveResource('AWS::SecretsManager::RotationSchedule', { SecretId: { - Ref: 'SecretAttachedSecret94145316' // The secret returned by the attachment, not the secret itself. + Ref: 'SecretAttachment2E1B7C3B' // The secret returned by the attachment, not the secret itself. } })); @@ -406,6 +422,41 @@ export = { // THEN test.deepEqual(stack.resolve(imported), stack.resolve(value)); + test.done(); + }, + + 'can add to the resource policy of a secret'(test: Test) { + // GIVEN + const stack = new Stack(); + const secret = new secretsmanager.Secret(stack, 'Secret'); + + // WHEN + secret.addToResourcePolicy(new iam.PolicyStatement({ + actions: ['secretsmanager:GetSecretValue'], + resources: ['*'], + principals: [new iam.ArnPrincipal('arn:aws:iam::123456789012:user/cool-user')] + })); + + // THEN + expect(stack).to(haveResource('AWS::SecretsManager::ResourcePolicy', { + ResourcePolicy: { + Statement: [ + { + Action: 'secretsmanager:GetSecretValue', + Effect: 'Allow', + Principal: { + AWS: 'arn:aws:iam::123456789012:user/cool-user' + }, + Resource: '*' + } + ], + Version: '2012-10-17' + }, + SecretId: { + Ref: 'SecretA720EF05' + } + })); + test.done(); } };