From bf79c82890ce8baf50b5de5185a6809a212875e4 Mon Sep 17 00:00:00 2001 From: Jonathan Goldwasser Date: Wed, 20 Mar 2019 19:10:51 +0100 Subject: [PATCH] feat(secretsmanager/rds): support credential rotation (#2052) Add construct for rotation schedule, secret target attachment and RDS rotation single user. --- packages/@aws-cdk/aws-rds/README.md | 31 +- packages/@aws-cdk/aws-rds/lib/cluster-ref.ts | 3 +- packages/@aws-cdk/aws-rds/lib/cluster.ts | 141 +++- .../@aws-cdk/aws-rds/lib/database-secret.ts | 37 + packages/@aws-cdk/aws-rds/lib/index.ts | 2 + packages/@aws-cdk/aws-rds/lib/props.ts | 15 +- .../aws-rds/lib/rotation-single-user.ts | 204 +++++ packages/@aws-cdk/aws-rds/package.json | 7 +- .../integ.cluster-rotation.lit.expected.json | 790 ++++++++++++++++++ .../test/integ.cluster-rotation.lit.ts | 25 + .../@aws-cdk/aws-rds/test/test.cluster.ts | 57 ++ .../aws-rds/test/test.rotation-single-user.ts | 231 +++++ .../@aws-cdk/aws-secretsmanager/README.md | 17 +- .../@aws-cdk/aws-secretsmanager/lib/index.ts | 1 + .../lib/rotation-schedule.ts | 49 ++ .../@aws-cdk/aws-secretsmanager/lib/secret.ts | 115 +++ .../@aws-cdk/aws-secretsmanager/package.json | 6 +- .../test/test.rotation-schedule.ts | 42 + .../aws-secretsmanager/test/test.secret.ts | 59 ++ 19 files changed, 1816 insertions(+), 16 deletions(-) create mode 100644 packages/@aws-cdk/aws-rds/lib/database-secret.ts create mode 100644 packages/@aws-cdk/aws-rds/lib/rotation-single-user.ts create mode 100644 packages/@aws-cdk/aws-rds/test/integ.cluster-rotation.lit.expected.json create mode 100644 packages/@aws-cdk/aws-rds/test/integ.cluster-rotation.lit.ts create mode 100644 packages/@aws-cdk/aws-rds/test/test.rotation-single-user.ts create mode 100644 packages/@aws-cdk/aws-secretsmanager/lib/rotation-schedule.ts create mode 100644 packages/@aws-cdk/aws-secretsmanager/test/test.rotation-schedule.ts diff --git a/packages/@aws-cdk/aws-rds/README.md b/packages/@aws-cdk/aws-rds/README.md index 9fd10331d0170..74c6a8f402e93 100644 --- a/packages/@aws-cdk/aws-rds/README.md +++ b/packages/@aws-cdk/aws-rds/README.md @@ -22,8 +22,7 @@ your instances will be launched privately or publicly: const cluster = new DatabaseCluster(this, 'Database', { engine: DatabaseClusterEngine.Aurora, masterUser: { - username: 'admin', - password: '7959866cacc02c2d243ecfe177464fe6', + username: 'admin' }, instanceProps: { instanceType: new InstanceTypePair(InstanceClass.Burstable2, InstanceSize.Small), @@ -34,6 +33,7 @@ const cluster = new DatabaseCluster(this, 'Database', { } }); ``` +By default, the master password will be generated and stored in AWS Secrets Manager. Your cluster will be empty by default. To add a default database upon construction, specify the `defaultDatabaseName` attribute. @@ -53,3 +53,30 @@ attributes: ```ts const writeAddress = cluster.clusterEndpoint.socketAddress; // "HOSTNAME:PORT" ``` + +### Rotating master password +When the master password is generated and stored in AWS Secrets Manager, it can be rotated automatically: + +[example of setting up master password rotation](test/integ.cluster-rotation.lit.ts) + +Rotation of the master password is also supported for an existing cluster: +```ts +new rds.RotationSingleUser(stack, 'Rotation', { + secret: importedSecret, + engine: DatabaseEngine.Oracle, + target: importedCluster, + vpc: importedVpc, +}) +``` + +The `importedSecret` must be a JSON string with the following format: +```json +{ + "engine": "", + "host": "", + "username": "", + "password": "", + "dbname": "", + "port": "" +} +``` diff --git a/packages/@aws-cdk/aws-rds/lib/cluster-ref.ts b/packages/@aws-cdk/aws-rds/lib/cluster-ref.ts index 3a6803c587ecb..6bbb799f1e765 100644 --- a/packages/@aws-cdk/aws-rds/lib/cluster-ref.ts +++ b/packages/@aws-cdk/aws-rds/lib/cluster-ref.ts @@ -1,10 +1,11 @@ import ec2 = require('@aws-cdk/aws-ec2'); +import secretsmanager = require('@aws-cdk/aws-secretsmanager'); import cdk = require('@aws-cdk/cdk'); /** * Create a clustered database with a given number of instances. */ -export interface IDatabaseCluster extends cdk.IConstruct, ec2.IConnectable { +export interface IDatabaseCluster extends cdk.IConstruct, ec2.IConnectable, secretsmanager.ISecretAttachmentTarget { /** * Identifier of the cluster */ diff --git a/packages/@aws-cdk/aws-rds/lib/cluster.ts b/packages/@aws-cdk/aws-rds/lib/cluster.ts index 5510bfafebc1c..66fda81a3fac2 100644 --- a/packages/@aws-cdk/aws-rds/lib/cluster.ts +++ b/packages/@aws-cdk/aws-rds/lib/cluster.ts @@ -1,9 +1,12 @@ import ec2 = require('@aws-cdk/aws-ec2'); +import secretsmanager = require('@aws-cdk/aws-secretsmanager'); import cdk = require('@aws-cdk/cdk'); import { IClusterParameterGroup } from './cluster-parameter-group'; import { DatabaseClusterImportProps, Endpoint, IDatabaseCluster } from './cluster-ref'; +import { DatabaseSecret } from './database-secret'; import { BackupProps, DatabaseClusterEngine, InstanceProps, Login } from './props'; import { CfnDBCluster, CfnDBInstance, CfnDBSubnetGroup } from './rds.generated'; +import { DatabaseEngine, RotationSingleUser, RotationSingleUserOptions } from './rotation-single-user'; /** * Properties for a new database cluster @@ -91,9 +94,9 @@ export interface DatabaseClusterProps { } /** - * Create a clustered database with a given number of instances. + * A new or imported clustered database. */ -export class DatabaseCluster extends cdk.Construct implements IDatabaseCluster { +export abstract class DatabaseClusterBase extends cdk.Construct implements IDatabaseCluster { /** * Import an existing DatabaseCluster from properties */ @@ -101,6 +104,57 @@ export class DatabaseCluster extends cdk.Construct implements IDatabaseCluster { return new ImportedDatabaseCluster(scope, id, props); } + /** + * Identifier of the cluster + */ + public abstract readonly clusterIdentifier: string; + /** + * Identifiers of the replicas + */ + public abstract readonly instanceIdentifiers: string[]; + + /** + * The endpoint to use for read/write operations + */ + public abstract readonly clusterEndpoint: Endpoint; + + /** + * Endpoint to use for load-balanced read-only operations. + */ + public abstract readonly readerEndpoint: Endpoint; + + /** + * Endpoints which address each individual replica. + */ + public abstract readonly instanceEndpoints: Endpoint[]; + + /** + * Access to the network connections + */ + public abstract readonly connections: ec2.Connections; + + /** + * Security group identifier of this database + */ + public abstract readonly securityGroupId: string; + + public abstract export(): DatabaseClusterImportProps; + + /** + * Renders the secret attachment target specifications. + */ + public asSecretAttachmentTarget(): secretsmanager.SecretAttachmentTargetProps { + return { + targetId: this.clusterIdentifier, + targetType: secretsmanager.AttachmentTargetType.Cluster + }; + } +} + +/** + * Create a clustered database with a given number of instances. + */ +export class DatabaseCluster extends DatabaseClusterBase implements IDatabaseCluster { /** * Identifier of the cluster */ @@ -136,10 +190,33 @@ export class DatabaseCluster extends cdk.Construct implements IDatabaseCluster { */ public readonly securityGroupId: string; + /** + * The secret attached to this cluster + */ + public readonly secret?: secretsmanager.ISecret; + + /** + * The database engine of this cluster + */ + public readonly engine: DatabaseClusterEngine; + + /** + * The VPC where the DB subnet group is created. + */ + public readonly vpc: ec2.IVpcNetwork; + + /** + * The subnets used by the DB subnet group. + */ + public readonly vpcPlacement?: ec2.VpcPlacementStrategy; + constructor(scope: cdk.Construct, id: string, props: DatabaseClusterProps) { super(scope, id); - const subnets = props.instanceProps.vpc.subnets(props.instanceProps.vpcPlacement); + this.vpc = props.instanceProps.vpc; + this.vpcPlacement = props.instanceProps.vpcPlacement; + + const subnets = this.vpc.subnets(this.vpcPlacement); // Cannot test whether the subnets are in different AZs, but at least we can test the amount. if (subnets.length < 2) { @@ -158,17 +235,27 @@ export class DatabaseCluster extends cdk.Construct implements IDatabaseCluster { }); this.securityGroupId = securityGroup.securityGroupId; + let secret; + if (!props.masterUser.password) { + secret = new DatabaseSecret(this, 'Secret', { + username: props.masterUser.username, + encryptionKey: props.masterUser.kmsKey + }); + } + + this.engine = props.engine; + const cluster = new CfnDBCluster(this, 'Resource', { // Basic - engine: props.engine, + engine: this.engine, dbClusterIdentifier: props.clusterIdentifier, dbSubnetGroupName: subnetGroup.ref, vpcSecurityGroupIds: [this.securityGroupId], port: props.port, dbClusterParameterGroupName: props.parameterGroup && props.parameterGroup.parameterGroupName, // Admin - masterUsername: props.masterUser.username, - masterUserPassword: props.masterUser.password, + masterUsername: secret ? secret.jsonFieldValue('username') : props.masterUser.username, + masterUserPassword: secret ? secret.jsonFieldValue('password') : props.masterUser.password, backupRetentionPeriod: props.backup && props.backup.retentionDays, preferredBackupWindow: props.backup && props.backup.preferredWindow, preferredMaintenanceWindow: props.preferredMaintenanceWindow, @@ -182,6 +269,12 @@ export class DatabaseCluster extends cdk.Construct implements IDatabaseCluster { this.clusterEndpoint = new Endpoint(cluster.dbClusterEndpointAddress, cluster.dbClusterEndpointPort); this.readerEndpoint = new Endpoint(cluster.dbClusterReadEndpointAddress, cluster.dbClusterEndpointPort); + if (secret) { + this.secret = secret.addTargetAttachment('AttachedSecret', { + target: this + }); + } + const instanceCount = props.instances != null ? props.instances : 2; if (instanceCount < 1) { throw new Error('At least one instance is required'); @@ -220,6 +313,23 @@ export class DatabaseCluster extends cdk.Construct implements IDatabaseCluster { this.connections = new ec2.Connections({ securityGroups: [securityGroup], defaultPortRange }); } + /** + * Adds the single user rotation of the master password to this cluster. + */ + public addRotationSingleUser(id: string, options: RotationSingleUserOptions = {}): RotationSingleUser { + if (!this.secret) { + throw new Error('Cannot add single user rotation for a cluster without secret.'); + } + return new RotationSingleUser(this, id, { + secret: this.secret, + engine: toDatabaseEngine(this.engine), + vpc: this.vpc, + vpcPlacement: this.vpcPlacement, + target: this, + ...options + }); + } + /** * Export a Database Cluster for importing in another stack */ @@ -248,7 +358,7 @@ function databaseInstanceType(instanceType: ec2.InstanceType) { /** * An imported Database Cluster */ -class ImportedDatabaseCluster extends cdk.Construct implements IDatabaseCluster { +class ImportedDatabaseCluster extends DatabaseClusterBase implements IDatabaseCluster { /** * Default port to connect to this database */ @@ -308,3 +418,20 @@ class ImportedDatabaseCluster extends cdk.Construct implements IDatabaseCluster return this.props; } } + +/** + * Transforms a DatbaseClusterEngine to a DatabaseEngine. + * + * @param engine the engine to transform + */ +function toDatabaseEngine(engine: DatabaseClusterEngine): DatabaseEngine { + switch (engine) { + case DatabaseClusterEngine.Aurora: + case DatabaseClusterEngine.AuroraMysql: + return DatabaseEngine.Mysql; + case DatabaseClusterEngine.AuroraPostgresql: + return DatabaseEngine.Postgres; + default: + throw new Error('Unknown engine'); + } +} diff --git a/packages/@aws-cdk/aws-rds/lib/database-secret.ts b/packages/@aws-cdk/aws-rds/lib/database-secret.ts new file mode 100644 index 0000000000000..c28750e1b56cc --- /dev/null +++ b/packages/@aws-cdk/aws-rds/lib/database-secret.ts @@ -0,0 +1,37 @@ +import kms = require('@aws-cdk/aws-kms'); +import secretsmanager = require('@aws-cdk/aws-secretsmanager'); +import cdk = require('@aws-cdk/cdk'); + +/** + * Construction properties for a DatabaseSecret. + */ +export interface DatabaseSecretProps { + /** + * The username. + */ + username: string; + + /** + * The KMS key to use to encrypt the secret. + * + * @default default master key + */ + encryptionKey?: kms.IEncryptionKey; +} + +/** + * A database secret. + */ +export class DatabaseSecret extends secretsmanager.Secret { + constructor(scope: cdk.Construct, id: string, props: DatabaseSecretProps) { + super(scope, id, { + encryptionKey: props.encryptionKey, + generateSecretString: ({ + passwordLength: 30, // Oracle password cannot have more than 30 characters + secretStringTemplate: JSON.stringify({ username: props.username }), + generateStringKey: 'password', + excludeCharacters: '"@/\\' + }) as secretsmanager.TemplatedSecretStringGenerator + }); + } +} diff --git a/packages/@aws-cdk/aws-rds/lib/index.ts b/packages/@aws-cdk/aws-rds/lib/index.ts index 4a0f2ed04ee88..290e0153e4c74 100644 --- a/packages/@aws-cdk/aws-rds/lib/index.ts +++ b/packages/@aws-cdk/aws-rds/lib/index.ts @@ -3,6 +3,8 @@ export * from './cluster-ref'; export * from './instance'; export * from './props'; export * from './cluster-parameter-group'; +export * from './rotation-single-user'; +export * from './database-secret'; // AWS::RDS CloudFormation Resources: export * from './rds.generated'; diff --git a/packages/@aws-cdk/aws-rds/lib/props.ts b/packages/@aws-cdk/aws-rds/lib/props.ts index 366260c82791f..68925721034c1 100644 --- a/packages/@aws-cdk/aws-rds/lib/props.ts +++ b/packages/@aws-cdk/aws-rds/lib/props.ts @@ -1,4 +1,5 @@ import ec2 = require('@aws-cdk/aws-ec2'); +import kms = require('@aws-cdk/aws-kms'); /** * The engine for the database cluster @@ -69,10 +70,18 @@ export interface Login { /** * Password * - * Do not put passwords in your CDK code directly. Import it from a Stack - * Parameter or the SSM Parameter Store instead. + * Do not put passwords in your CDK code directly. + * + * @default a Secrets Manager generated password + */ + password?: string; + + /** + * KMS encryption key to encrypt the generated secret. + * + * @default default master key */ - password: string; + kmsKey?: kms.IEncryptionKey; } /** diff --git a/packages/@aws-cdk/aws-rds/lib/rotation-single-user.ts b/packages/@aws-cdk/aws-rds/lib/rotation-single-user.ts new file mode 100644 index 0000000000000..5f269534334e3 --- /dev/null +++ b/packages/@aws-cdk/aws-rds/lib/rotation-single-user.ts @@ -0,0 +1,204 @@ +import ec2 = require('@aws-cdk/aws-ec2'); +import lambda = require('@aws-cdk/aws-lambda'); +import secretsmanager = require('@aws-cdk/aws-secretsmanager'); +import serverless = require('@aws-cdk/aws-serverless'); +import cdk = require('@aws-cdk/cdk'); + +/** + * A serverless application location. + */ +export class ServerlessApplicationLocation { + public static readonly MariaDbRotationSingleUser = new ServerlessApplicationLocation('SecretsManagerRDSMariaDBRotationSingleUser', '1.0.46'); + public static readonly MysqlRotationSingleUser = new ServerlessApplicationLocation('SecretsManagerRDSMySQLRotationSingleUser', '1.0.74'); + public static readonly OracleRotationSingleUser = new ServerlessApplicationLocation('SecretsManagerRDSOracleRotationSingleUser', '1.0.45'); + public static readonly PostgresRotationSingleUser = new ServerlessApplicationLocation('SecretsManagerRDSPostgreSQLRotationSingleUser', '1.0.75'); + public static readonly SqlServerRotationSingleUser = new ServerlessApplicationLocation('SecretsManagerRDSSQLServerRotationSingleUser', '1.0.74'); + + 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; + } +} + +/** + * The RDS database engine + */ +export enum DatabaseEngine { + /** + * MariaDB + */ + MariaDb = 'mariadb', + + /** + * MySQL + */ + Mysql = 'mysql', + + /** + * Oracle + */ + Oracle = 'oracle', + + /** + * PostgreSQL + */ + Postgres = 'postgres', + + /** + * SQL Server + */ + SqlServer = 'sqlserver' +} + +/** + * Options to add single user rotation to a database instance or cluster. + */ +export interface RotationSingleUserOptions { + /** + * Specifies the number of days after the previous rotation before + * Secrets Manager triggers the next automatic rotation. + * + * @default 30 days + */ + automaticallyAfterDays?: number; + + /** + * The location of the serverless application for the rotation. + * + * @default derived from the target's engine + */ + serverlessApplicationLocation?: ServerlessApplicationLocation +} + +/** + * Construction properties for a RotationSingleUser. + */ +export interface RotationSingleUserProps extends RotationSingleUserOptions { + /** + * The secret to rotate. It must be a JSON string with the following format: + * { + * 'engine': , + * 'host': , + * 'username': , + * 'password': , + * 'dbname': , + * 'port': + * } + * + * 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 + */ + secret: secretsmanager.ISecret; + + /** + * The database engine. Either `serverlessApplicationLocation` or `engine` must be specified. + * + * @default no engine specified + */ + engine?: DatabaseEngine; + + /** + * The VPC where the Lambda rotation function will run. + */ + vpc: ec2.IVpcNetwork; + + /** + * The type of subnets in the VPC where the Lambda rotation function will run. + * + * @default private subnets + */ + vpcPlacement?: ec2.VpcPlacementStrategy; + + /** + * The target database cluster or instance + */ + target: ec2.IConnectable; +} + +/** + * Single user secret rotation for a database instance or cluster. + */ +export class RotationSingleUser extends cdk.Construct { + constructor(scope: cdk.Construct, id: string, props: RotationSingleUserProps) { + super(scope, id); + + if (!props.serverlessApplicationLocation && !props.engine) { + throw new Error('Either `serverlessApplicationLocation` or `engine` must be specified.'); + } + + if (!props.target.connections.defaultPortRange) { + 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 subnets = props.vpc.subnets(props.vpcPlacement); + + props.target.connections.allowDefaultPortFrom(securityGroup); + + const application = new serverless.CfnApplication(this, 'Resource', { + location: props.serverlessApplicationLocation || getApplicationLocation(props.engine), + parameters: { + endpoint: `https://secretsmanager.${this.node.stack.region}.${this.node.stack.urlSuffix}`, + functionName: rotationFunctionName, + vpcSecurityGroupIds: securityGroup.securityGroupId, + vpcSubnetIds: subnets.map(s => s.subnetId).join(',') + } + }); + + // Dummy import to reference this function in the rotation schedule + const rotationLambda = lambda.Function.import(this, 'RotationLambda', { + functionArn: this.node.stack.formatArn({ + service: 'lambda', + resource: 'function', + sep: ':', + resourceName: rotationFunctionName + }), + }); + + // Cannot use rotationLambda.addPermission because it currently does not + // return a cdk.Construct and we need to add a dependency. + const permission = new lambda.CfnPermission(this, 'Permission', { + action: 'lambda:InvokeFunction', + functionName: rotationFunctionName, + principal: `secretsmanager.${this.node.stack.urlSuffix}` + }); + permission.node.addDependency(application); // Add permission after application is deployed + + const rotationSchedule = props.secret.addRotationSchedule('RotationSchedule', { + rotationLambda, + automaticallyAfterDays: props.automaticallyAfterDays + }); + rotationSchedule.node.addDependency(permission); // Cannot rotate without permission + } +} + +/** + * Returns the location for the rotation single user application. + * + * @param engine the database engine + * @throws if the engine is not supported + */ +function getApplicationLocation(engine: string = ''): ServerlessApplicationLocation { + switch (engine) { + case DatabaseEngine.MariaDb: + return ServerlessApplicationLocation.MariaDbRotationSingleUser; + case DatabaseEngine.Mysql: + return ServerlessApplicationLocation.MysqlRotationSingleUser; + case DatabaseEngine.Oracle: + return ServerlessApplicationLocation.OracleRotationSingleUser; + case DatabaseEngine.Postgres: + return ServerlessApplicationLocation.PostgresRotationSingleUser; + case DatabaseEngine.SqlServer: + return ServerlessApplicationLocation.SqlServerRotationSingleUser; + default: + throw new Error(`Engine ${engine} not supported for single user rotation.`); + } +} diff --git a/packages/@aws-cdk/aws-rds/package.json b/packages/@aws-cdk/aws-rds/package.json index 3dc5f1d3ba3b4..79999fb99e94d 100644 --- a/packages/@aws-cdk/aws-rds/package.json +++ b/packages/@aws-cdk/aws-rds/package.json @@ -64,14 +64,19 @@ "@aws-cdk/aws-ec2": "^0.26.0", "@aws-cdk/aws-iam": "^0.26.0", "@aws-cdk/aws-kms": "^0.26.0", + "@aws-cdk/aws-lambda": "^0.26.0", + "@aws-cdk/aws-secretsmanager": "^0.26.0", + "@aws-cdk/aws-serverless": "^0.26.0", "@aws-cdk/cdk": "^0.26.0" }, "homepage": "https://github.com/awslabs/aws-cdk", "peerDependencies": { "@aws-cdk/aws-ec2": "^0.26.0", + "@aws-cdk/aws-kms": "^0.26.0", + "@aws-cdk/aws-secretsmanager": "^0.26.0", "@aws-cdk/cdk": "^0.26.0" }, "engines": { "node": ">= 8.10.0" } -} \ No newline at end of file +} 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 new file mode 100644 index 0000000000000..6f80809f40e1e --- /dev/null +++ b/packages/@aws-cdk/aws-rds/test/integ.cluster-rotation.lit.expected.json @@ -0,0 +1,790 @@ +{ + "Transform": "AWS::Serverless-2016-10-31", + "Resources": { + "VPCB9E5F0B4": { + "Type": "AWS::EC2::VPC", + "Properties": { + "CidrBlock": "10.0.0.0/16", + "EnableDnsHostnames": true, + "EnableDnsSupport": true, + "InstanceTenancy": "default", + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-rds-cluster-rotation/VPC" + } + ] + } + }, + "VPCPublicSubnet1SubnetB4246D30": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.0.0/19", + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "AvailabilityZone": "test-region-1a", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-rds-cluster-rotation/VPC/PublicSubnet1" + }, + { + "Key": "aws-cdk:subnet-name", + "Value": "Public" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Public" + } + ] + } + }, + "VPCPublicSubnet1RouteTableFEE4B781": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-rds-cluster-rotation/VPC/PublicSubnet1" + } + ] + } + }, + "VPCPublicSubnet1RouteTableAssociation0B0896DC": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VPCPublicSubnet1RouteTableFEE4B781" + }, + "SubnetId": { + "Ref": "VPCPublicSubnet1SubnetB4246D30" + } + } + }, + "VPCPublicSubnet1DefaultRoute91CEF279": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VPCPublicSubnet1RouteTableFEE4B781" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": { + "Ref": "VPCIGWB7E252D3" + } + }, + "DependsOn": [ + "VPCVPCGW99B986DC" + ] + }, + "VPCPublicSubnet1EIP6AD938E8": { + "Type": "AWS::EC2::EIP", + "Properties": { + "Domain": "vpc" + } + }, + "VPCPublicSubnet1NATGatewayE0556630": { + "Type": "AWS::EC2::NatGateway", + "Properties": { + "AllocationId": { + "Fn::GetAtt": [ + "VPCPublicSubnet1EIP6AD938E8", + "AllocationId" + ] + }, + "SubnetId": { + "Ref": "VPCPublicSubnet1SubnetB4246D30" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-rds-cluster-rotation/VPC/PublicSubnet1" + } + ] + } + }, + "VPCPublicSubnet2Subnet74179F39": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.32.0/19", + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "AvailabilityZone": "test-region-1b", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-rds-cluster-rotation/VPC/PublicSubnet2" + }, + { + "Key": "aws-cdk:subnet-name", + "Value": "Public" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Public" + } + ] + } + }, + "VPCPublicSubnet2RouteTable6F1A15F1": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-rds-cluster-rotation/VPC/PublicSubnet2" + } + ] + } + }, + "VPCPublicSubnet2RouteTableAssociation5A808732": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VPCPublicSubnet2RouteTable6F1A15F1" + }, + "SubnetId": { + "Ref": "VPCPublicSubnet2Subnet74179F39" + } + } + }, + "VPCPublicSubnet2DefaultRouteB7481BBA": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VPCPublicSubnet2RouteTable6F1A15F1" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": { + "Ref": "VPCIGWB7E252D3" + } + }, + "DependsOn": [ + "VPCVPCGW99B986DC" + ] + }, + "VPCPublicSubnet2EIP4947BC00": { + "Type": "AWS::EC2::EIP", + "Properties": { + "Domain": "vpc" + } + }, + "VPCPublicSubnet2NATGateway3C070193": { + "Type": "AWS::EC2::NatGateway", + "Properties": { + "AllocationId": { + "Fn::GetAtt": [ + "VPCPublicSubnet2EIP4947BC00", + "AllocationId" + ] + }, + "SubnetId": { + "Ref": "VPCPublicSubnet2Subnet74179F39" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-rds-cluster-rotation/VPC/PublicSubnet2" + } + ] + } + }, + "VPCPublicSubnet3Subnet631C5E25": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.64.0/19", + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "AvailabilityZone": "test-region-1c", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-rds-cluster-rotation/VPC/PublicSubnet3" + }, + { + "Key": "aws-cdk:subnet-name", + "Value": "Public" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Public" + } + ] + } + }, + "VPCPublicSubnet3RouteTable98AE0E14": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-rds-cluster-rotation/VPC/PublicSubnet3" + } + ] + } + }, + "VPCPublicSubnet3RouteTableAssociation427FE0C6": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VPCPublicSubnet3RouteTable98AE0E14" + }, + "SubnetId": { + "Ref": "VPCPublicSubnet3Subnet631C5E25" + } + } + }, + "VPCPublicSubnet3DefaultRouteA0D29D46": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VPCPublicSubnet3RouteTable98AE0E14" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": { + "Ref": "VPCIGWB7E252D3" + } + }, + "DependsOn": [ + "VPCVPCGW99B986DC" + ] + }, + "VPCPublicSubnet3EIPAD4BC883": { + "Type": "AWS::EC2::EIP", + "Properties": { + "Domain": "vpc" + } + }, + "VPCPublicSubnet3NATGatewayD3048F5C": { + "Type": "AWS::EC2::NatGateway", + "Properties": { + "AllocationId": { + "Fn::GetAtt": [ + "VPCPublicSubnet3EIPAD4BC883", + "AllocationId" + ] + }, + "SubnetId": { + "Ref": "VPCPublicSubnet3Subnet631C5E25" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-rds-cluster-rotation/VPC/PublicSubnet3" + } + ] + } + }, + "VPCPrivateSubnet1Subnet8BCA10E0": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.96.0/19", + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "AvailabilityZone": "test-region-1a", + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-rds-cluster-rotation/VPC/PrivateSubnet1" + }, + { + "Key": "aws-cdk:subnet-name", + "Value": "Private" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Private" + } + ] + } + }, + "VPCPrivateSubnet1RouteTableBE8A6027": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-rds-cluster-rotation/VPC/PrivateSubnet1" + } + ] + } + }, + "VPCPrivateSubnet1RouteTableAssociation347902D1": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VPCPrivateSubnet1RouteTableBE8A6027" + }, + "SubnetId": { + "Ref": "VPCPrivateSubnet1Subnet8BCA10E0" + } + } + }, + "VPCPrivateSubnet1DefaultRouteAE1D6490": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VPCPrivateSubnet1RouteTableBE8A6027" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "NatGatewayId": { + "Ref": "VPCPublicSubnet1NATGatewayE0556630" + } + } + }, + "VPCPrivateSubnet2SubnetCFCDAA7A": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.128.0/19", + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "AvailabilityZone": "test-region-1b", + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-rds-cluster-rotation/VPC/PrivateSubnet2" + }, + { + "Key": "aws-cdk:subnet-name", + "Value": "Private" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Private" + } + ] + } + }, + "VPCPrivateSubnet2RouteTable0A19E10E": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-rds-cluster-rotation/VPC/PrivateSubnet2" + } + ] + } + }, + "VPCPrivateSubnet2RouteTableAssociation0C73D413": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VPCPrivateSubnet2RouteTable0A19E10E" + }, + "SubnetId": { + "Ref": "VPCPrivateSubnet2SubnetCFCDAA7A" + } + } + }, + "VPCPrivateSubnet2DefaultRouteF4F5CFD2": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VPCPrivateSubnet2RouteTable0A19E10E" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "NatGatewayId": { + "Ref": "VPCPublicSubnet2NATGateway3C070193" + } + } + }, + "VPCPrivateSubnet3Subnet3EDCD457": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.160.0/19", + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "AvailabilityZone": "test-region-1c", + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-rds-cluster-rotation/VPC/PrivateSubnet3" + }, + { + "Key": "aws-cdk:subnet-name", + "Value": "Private" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Private" + } + ] + } + }, + "VPCPrivateSubnet3RouteTable192186F8": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-rds-cluster-rotation/VPC/PrivateSubnet3" + } + ] + } + }, + "VPCPrivateSubnet3RouteTableAssociationC28D144E": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VPCPrivateSubnet3RouteTable192186F8" + }, + "SubnetId": { + "Ref": "VPCPrivateSubnet3Subnet3EDCD457" + } + } + }, + "VPCPrivateSubnet3DefaultRoute27F311AE": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VPCPrivateSubnet3RouteTable192186F8" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "NatGatewayId": { + "Ref": "VPCPublicSubnet3NATGatewayD3048F5C" + } + } + }, + "VPCIGWB7E252D3": { + "Type": "AWS::EC2::InternetGateway", + "Properties": { + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-rds-cluster-rotation/VPC" + } + ] + } + }, + "VPCVPCGW99B986DC": { + "Type": "AWS::EC2::VPCGatewayAttachment", + "Properties": { + "VpcId": { + "Ref": "VPCB9E5F0B4" + }, + "InternetGatewayId": { + "Ref": "VPCIGWB7E252D3" + } + } + }, + "DatabaseSubnets56F17B9A": { + "Type": "AWS::RDS::DBSubnetGroup", + "Properties": { + "DBSubnetGroupDescription": "Subnets for Database database", + "SubnetIds": [ + { + "Ref": "VPCPrivateSubnet1Subnet8BCA10E0" + }, + { + "Ref": "VPCPrivateSubnet2SubnetCFCDAA7A" + }, + { + "Ref": "VPCPrivateSubnet3Subnet3EDCD457" + } + ] + } + }, + "DatabaseSecurityGroup5C91FDCB": { + "Type": "AWS::EC2::SecurityGroup", + "Properties": { + "GroupDescription": "RDS security group", + "SecurityGroupEgress": [ + { + "CidrIp": "0.0.0.0/0", + "Description": "Allow all outbound traffic by default", + "IpProtocol": "-1" + } + ], + "SecurityGroupIngress": [], + "VpcId": { + "Ref": "VPCB9E5F0B4" + } + } + }, + "DatabaseSecurityGroupfromawscdkrdsclusterrotationDatabaseRotationSecurityGroup35913E19IndirectPort12DA2942": { + "Type": "AWS::EC2::SecurityGroupIngress", + "Properties": { + "IpProtocol": "tcp", + "Description": "from awscdkrdsclusterrotationDatabaseRotationSecurityGroup35913E19:{IndirectPort}", + "FromPort": { + "Fn::GetAtt": [ + "DatabaseB269D8BB", + "Endpoint.Port" + ] + }, + "GroupId": { + "Fn::GetAtt": [ + "DatabaseSecurityGroup5C91FDCB", + "GroupId" + ] + }, + "SourceSecurityGroupId": { + "Fn::GetAtt": [ + "DatabaseRotationSecurityGroup17736B63", + "GroupId" + ] + }, + "ToPort": { + "Fn::GetAtt": [ + "DatabaseB269D8BB", + "Endpoint.Port" + ] + } + } + }, + "DatabaseSecret3B817195": { + "Type": "AWS::SecretsManager::Secret", + "Properties": { + "GenerateSecretString": { + "ExcludeCharacters": "\"@/\\", + "GenerateStringKey": "password", + "PasswordLength": 30, + "SecretStringTemplate": "{\"username\":\"admin\"}" + } + } + }, + "DatabaseSecretAttachedSecretE6CAC445": { + "Type": "AWS::SecretsManager::SecretTargetAttachment", + "Properties": { + "SecretId": { + "Ref": "DatabaseSecret3B817195" + }, + "TargetId": { + "Ref": "DatabaseB269D8BB" + }, + "TargetType": "AWS::RDS::DBCluster" + } + }, + "DatabaseSecretAttachedSecretRotationSchedule93D67FF7": { + "Type": "AWS::SecretsManager::RotationSchedule", + "Properties": { + "SecretId": { + "Ref": "DatabaseSecretAttachedSecretE6CAC445" + }, + "RotationLambdaARN": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":lambda:", + { + "Ref": "AWS::Region" + }, + ":", + { + "Ref": "AWS::AccountId" + }, + ":function:awscdkrdsclusterrotationDatabaseRotation30042AAE" + ] + ] + }, + "RotationRules": { + "AutomaticallyAfterDays": 30 + } + }, + "DependsOn": [ + "DatabaseRotationPermission64416CB0" + ] + }, + "DatabaseB269D8BB": { + "Type": "AWS::RDS::DBCluster", + "Properties": { + "Engine": "aurora", + "DBSubnetGroupName": { + "Ref": "DatabaseSubnets56F17B9A" + }, + "MasterUsername": { + "Fn::Join": [ + "", + [ + "{{resolve:secretsmanager:", + { + "Ref": "DatabaseSecret3B817195" + }, + ":SecretString:username::}}" + ] + ] + }, + "MasterUserPassword": { + "Fn::Join": [ + "", + [ + "{{resolve:secretsmanager:", + { + "Ref": "DatabaseSecret3B817195" + }, + ":SecretString:password::}}" + ] + ] + }, + "StorageEncrypted": false, + "VpcSecurityGroupIds": [ + { + "Fn::GetAtt": [ + "DatabaseSecurityGroup5C91FDCB", + "GroupId" + ] + } + ] + } + }, + "DatabaseInstance1844F58FD": { + "Type": "AWS::RDS::DBInstance", + "Properties": { + "DBInstanceClass": "db.t2.small", + "DBClusterIdentifier": { + "Ref": "DatabaseB269D8BB" + }, + "DBSubnetGroupName": { + "Ref": "DatabaseSubnets56F17B9A" + }, + "Engine": "aurora" + }, + "DependsOn": [ + "VPCPrivateSubnet1DefaultRouteAE1D6490", + "VPCPrivateSubnet2DefaultRouteF4F5CFD2", + "VPCPrivateSubnet3DefaultRoute27F311AE" + ] + }, + "DatabaseInstance2AA380DEE": { + "Type": "AWS::RDS::DBInstance", + "Properties": { + "DBInstanceClass": "db.t2.small", + "DBClusterIdentifier": { + "Ref": "DatabaseB269D8BB" + }, + "DBSubnetGroupName": { + "Ref": "DatabaseSubnets56F17B9A" + }, + "Engine": "aurora" + }, + "DependsOn": [ + "VPCPrivateSubnet1DefaultRouteAE1D6490", + "VPCPrivateSubnet2DefaultRouteF4F5CFD2", + "VPCPrivateSubnet3DefaultRoute27F311AE" + ] + }, + "DatabaseRotationSecurityGroup17736B63": { + "Type": "AWS::EC2::SecurityGroup", + "Properties": { + "GroupDescription": "aws-cdk-rds-cluster-rotation/Database/Rotation/SecurityGroup", + "SecurityGroupEgress": [ + { + "CidrIp": "0.0.0.0/0", + "Description": "Allow all outbound traffic by default", + "IpProtocol": "-1" + } + ], + "SecurityGroupIngress": [], + "VpcId": { + "Ref": "VPCB9E5F0B4" + } + } + }, + "DatabaseRotation6B6E1D86": { + "Type": "AWS::Serverless::Application", + "Properties": { + "Location": { + "ApplicationId": "arn:aws:serverlessrepo:us-east-1:297356227824:applications/SecretsManagerRDSMySQLRotationSingleUser", + "SemanticVersion": "1.0.74" + }, + "Parameters": { + "endpoint": { + "Fn::Join": [ + "", + [ + "https://secretsmanager.", + { + "Ref": "AWS::Region" + }, + ".", + { + "Ref": "AWS::URLSuffix" + } + ] + ] + }, + "functionName": "awscdkrdsclusterrotationDatabaseRotation30042AAE", + "vpcSecurityGroupIds": { + "Fn::GetAtt": [ + "DatabaseRotationSecurityGroup17736B63", + "GroupId" + ] + }, + "vpcSubnetIds": { + "Fn::Join": [ + "", + [ + { + "Ref": "VPCPrivateSubnet1Subnet8BCA10E0" + }, + ",", + { + "Ref": "VPCPrivateSubnet2SubnetCFCDAA7A" + }, + ",", + { + "Ref": "VPCPrivateSubnet3Subnet3EDCD457" + } + ] + ] + } + } + } + }, + "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 new file mode 100644 index 0000000000000..bcd41ecf298d4 --- /dev/null +++ b/packages/@aws-cdk/aws-rds/test/integ.cluster-rotation.lit.ts @@ -0,0 +1,25 @@ +import ec2 = require('@aws-cdk/aws-ec2'); +import cdk = require('@aws-cdk/cdk'); +import rds = require('../lib'); + +const app = new cdk.App(); +const stack = new cdk.Stack(app, 'aws-cdk-rds-cluster-rotation'); + +const vpc = new ec2.VpcNetwork(stack, 'VPC'); + +/// !show +const cluster = new rds.DatabaseCluster(stack, 'Database', { + engine: rds.DatabaseClusterEngine.Aurora, + masterUser: { + username: 'admin' + }, + instanceProps: { + instanceType: new ec2.InstanceTypePair(ec2.InstanceClass.Burstable2, ec2.InstanceSize.Small), + vpc + } +}); + +cluster.addRotationSingleUser('Rotation'); +/// !hide + +app.run(); diff --git a/packages/@aws-cdk/aws-rds/test/test.cluster.ts b/packages/@aws-cdk/aws-rds/test/test.cluster.ts index fb48ebec7817d..cda81e806128f 100644 --- a/packages/@aws-cdk/aws-rds/test/test.cluster.ts +++ b/packages/@aws-cdk/aws-rds/test/test.cluster.ts @@ -175,6 +175,63 @@ export = { // THEN test.deepEqual(stack.node.resolve(exported), { parameterGroupName: { 'Fn::ImportValue': 'Stack:ParamsParameterGroupNameA6B808D7' } }); test.deepEqual(stack.node.resolve(imported.parameterGroupName), { 'Fn::ImportValue': 'Stack:ParamsParameterGroupNameA6B808D7' }); + test.done(); + }, + + 'creates a secret when master credentials are not specified'(test: Test) { + // GIVEN + const stack = testStack(); + const vpc = new ec2.VpcNetwork(stack, 'VPC'); + + // WHEN + new DatabaseCluster(stack, 'Database', { + engine: DatabaseClusterEngine.AuroraMysql, + masterUser: { + username: 'admin' + }, + instanceProps: { + instanceType: new ec2.InstanceTypePair(ec2.InstanceClass.Burstable2, ec2.InstanceSize.Small), + vpc + } + }); + + // THEN + expect(stack).to(haveResource('AWS::RDS::DBCluster', { + MasterUsername: { + 'Fn::Join': [ + '', + [ + '{{resolve:secretsmanager:', + { + Ref: 'DatabaseSecret3B817195' + }, + ':SecretString:username::}}' + ] + ] + }, + MasterUserPassword: { + 'Fn::Join': [ + '', + [ + '{{resolve:secretsmanager:', + { + Ref: 'DatabaseSecret3B817195' + }, + ':SecretString:password::}}' + ] + ] + }, + })); + + expect(stack).to(haveResource('AWS::SecretsManager::Secret', { + GenerateSecretString: { + ExcludeCharacters: '\"@/\\', + GenerateStringKey: 'password', + PasswordLength: 30, + SecretStringTemplate: '{"username":"admin"}' + } + })); + test.done(); } }; diff --git a/packages/@aws-cdk/aws-rds/test/test.rotation-single-user.ts b/packages/@aws-cdk/aws-rds/test/test.rotation-single-user.ts new file mode 100644 index 0000000000000..078f94fb1cee8 --- /dev/null +++ b/packages/@aws-cdk/aws-rds/test/test.rotation-single-user.ts @@ -0,0 +1,231 @@ +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/cdk'); +import { Test } from 'nodeunit'; +import rds = require('../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.VpcNetwork(stack, 'VPC'); + const cluster = new rds.DatabaseCluster(stack, 'Database', { + engine: rds.DatabaseClusterEngine.AuroraMysql, + masterUser: { + username: 'admin' + }, + instanceProps: { + instanceType: new ec2.InstanceTypePair(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.74" + }, + "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" + }, + ",", + { + "Ref": "VPCPrivateSubnet3Subnet3EDCD457" + } + ] + ] + } + } + })); + + 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.VpcNetwork(stack, 'VPC'); + + // WHEN + const cluster = new rds.DatabaseCluster(stack, 'Database', { + engine: rds.DatabaseClusterEngine.AuroraMysql, + masterUser: { + username: 'admin', + password: 'tooshort' + }, + instanceProps: { + instanceType: new ec2.InstanceTypePair(ec2.InstanceClass.Burstable2, ec2.InstanceSize.Small), + vpc + } + }); + + // THEN + test.throws(() => cluster.addRotationSingleUser('Rotation'), /without secret/); + + test.done(); + }, + + 'throws when both application location and engine are not specified'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.VpcNetwork(stack, 'VPC'); + const securityGroup = new ec2.SecurityGroup(stack, 'SecurityGroup', { + vpc, + }); + const target = new ec2.Connections({ + defaultPortRange: new ec2.TcpPort(1521), + securityGroups: [securityGroup] + }); + const secret = new secretsmanager.Secret(stack, 'Secret'); + + // THEN + test.throws(() => new rds.RotationSingleUser(stack, 'Rotation', { + secret, + vpc, + target + }), /`serverlessApplicationLocation`.+`engine`/); + + test.done(); + }, + + 'throws when connections object has no default port range'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const vpc = new ec2.VpcNetwork(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.RotationSingleUser(stack, 'Rotation', { + secret, + engine: rds.DatabaseEngine.Mysql, + vpc, + target + }), /`target`.+default port range/); + + test.done(); + } +}; diff --git a/packages/@aws-cdk/aws-secretsmanager/README.md b/packages/@aws-cdk/aws-secretsmanager/README.md index a5c71b8a2b6f1..3acc6c5c1f8a4 100644 --- a/packages/@aws-cdk/aws-secretsmanager/README.md +++ b/packages/@aws-cdk/aws-secretsmanager/README.md @@ -28,4 +28,19 @@ const secret = Secret.import(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). \ No newline at end of file +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: +```ts +const fn = new lambda.Function(...); +const secret = new secretsManager.Secret(this, 'Secret'); + +secret.addRotationSchedule('RotationSchedule', { + rotationLambda: fn, + automaticallyAfterDays: 15 +}); +``` +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/awslabs/aws-cdk/blob/master/packages/%40aws-cdk/aws-rds/README.md). diff --git a/packages/@aws-cdk/aws-secretsmanager/lib/index.ts b/packages/@aws-cdk/aws-secretsmanager/lib/index.ts index 0f425ff7d53ed..ab6379c2d85a8 100644 --- a/packages/@aws-cdk/aws-secretsmanager/lib/index.ts +++ b/packages/@aws-cdk/aws-secretsmanager/lib/index.ts @@ -1,5 +1,6 @@ export * from './secret'; export * from './secret-string'; +export * from './rotation-schedule'; // AWS::SecretsManager CloudFormation Resources: export * from './secretsmanager.generated'; diff --git a/packages/@aws-cdk/aws-secretsmanager/lib/rotation-schedule.ts b/packages/@aws-cdk/aws-secretsmanager/lib/rotation-schedule.ts new file mode 100644 index 0000000000000..5f3e77d63e634 --- /dev/null +++ b/packages/@aws-cdk/aws-secretsmanager/lib/rotation-schedule.ts @@ -0,0 +1,49 @@ +import lambda = require('@aws-cdk/aws-lambda'); +import cdk = require('@aws-cdk/cdk'); +import { ISecret } from './secret'; +import { CfnRotationSchedule } from './secretsmanager.generated'; + +/** + * Options to add a rotation schedule to a secret. + */ +export interface RotationScheduleOptions { + /** + * THe Lambda function that can rotate the secret. + */ + rotationLambda: lambda.IFunction; + + /** + * Specifies the number of days after the previous rotation before + * Secrets Manager triggers the next automatic rotation. + * + * @default 30 + */ + automaticallyAfterDays?: number; +} + +/** + * Construction properties for a RotationSchedule. + */ +export interface RotationScheduleProps extends RotationScheduleOptions { + /** + * The secret to rotate. + */ + secret: ISecret; +} + +/** + * A rotation schedule. + */ +export class RotationSchedule extends cdk.Construct { + constructor(scope: cdk.Construct, id: string, props: RotationScheduleProps) { + super(scope, id); + + new CfnRotationSchedule(this, 'Resource', { + secretId: props.secret.secretArn, + rotationLambdaArn: props.rotationLambda.functionArn, + rotationRules: { + automaticallyAfterDays: props.automaticallyAfterDays || 30 + } + }); + } +} diff --git a/packages/@aws-cdk/aws-secretsmanager/lib/secret.ts b/packages/@aws-cdk/aws-secretsmanager/lib/secret.ts index 142592b8a6a29..ec75ffe873089 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 cdk = require('@aws-cdk/cdk'); +import { RotationSchedule, RotationScheduleOptions } from './rotation-schedule'; import { SecretString } from './secret-string'; import secretsmanager = require('./secretsmanager.generated'); @@ -51,6 +52,11 @@ export interface ISecret extends cdk.IConstruct { * stages is applied. */ grantRead(grantee: iam.IPrincipal, versionStages?: string[]): void; + + /** + * Adds a rotation schedule to the secret. + */ + addRotationSchedule(id: string, options: RotationScheduleOptions): RotationSchedule; } /** @@ -150,6 +156,13 @@ export abstract class SecretBase extends cdk.Construct implements ISecret { public jsonFieldValue(key: string): string { return this.secretString.jsonFieldValue(key); } + + public addRotationSchedule(id: string, options: RotationScheduleOptions): RotationSchedule { + return new RotationSchedule(this, id, { + secret: this, + ...options + }); + } } /** @@ -184,6 +197,108 @@ export class Secret extends SecretBase { this.secretArn = resource.secretArn; } + /** + * Adds a target attachment to the secret. + * + * @returns an AttachedSecret + */ + public addTargetAttachment(id: string, options: AttachedSecretOptions): AttachedSecret { + return new AttachedSecret(this, id, { + secret: this, + ...options + }); + } + + public export(): SecretImportProps { + return { + encryptionKey: this.encryptionKey, + secretArn: this.secretArn, + }; + } +} + +/** + * A secret attachment target. + */ +export interface ISecretAttachmentTarget { + /** + * Renders the target specifications. + */ + asSecretAttachmentTarget(): SecretAttachmentTargetProps; +} + +/** + * The type of service or database that's being associated with the secret. + */ +export enum AttachmentTargetType { + /** + * A database instance + */ + Instance = 'AWS::RDS::DBInstance', + + /** + * A database cluster + */ + Cluster = 'AWS::RDS::DBCluster' +} + +/** + * Attachment target specifications. + */ +export interface SecretAttachmentTargetProps { + /** + * The id of the target to attach the secret to. + */ + targetId: string; + + /** + * The type of the target to attach the secret to. + */ + targetType: AttachmentTargetType; +} + +/** + * Options to add a secret attachment to a secret. + */ +export interface AttachedSecretOptions { + /** + * The target to attach the secret to. + */ + target: ISecretAttachmentTarget; +} + +/** + * Construction properties for an AttachedSecret. + */ +export interface AttachedSecretProps extends AttachedSecretOptions { + /** + * The secret to attach to the target. + */ + secret: ISecret; +} + +/** + * An attached secret. + */ +export class AttachedSecret extends SecretBase implements ISecret { + public readonly encryptionKey?: kms.IEncryptionKey; + public readonly secretArn: string; + + constructor(scope: cdk.Construct, id: string, props: AttachedSecretProps) { + super(scope, id); + + const attachment = new secretsmanager.CfnSecretTargetAttachment(this, 'Resource', { + secretId: props.secret.secretArn, + targetId: props.target.asSecretAttachmentTarget().targetId, + targetType: props.target.asSecretAttachmentTarget().targetType + }); + + this.encryptionKey = props.secret.encryptionKey; + + // This allows to reference the secret after attachment (dependency). + this.secretArn = attachment.secretTargetAttachmentSecretArn; + } + public export(): SecretImportProps { return { encryptionKey: this.encryptionKey, diff --git a/packages/@aws-cdk/aws-secretsmanager/package.json b/packages/@aws-cdk/aws-secretsmanager/package.json index b07fd8924e93d..c1b3debdc3600 100644 --- a/packages/@aws-cdk/aws-secretsmanager/package.json +++ b/packages/@aws-cdk/aws-secretsmanager/package.json @@ -63,15 +63,19 @@ }, "dependencies": { "@aws-cdk/aws-iam": "^0.26.0", + "@aws-cdk/aws-ec2": "^0.26.0", "@aws-cdk/aws-kms": "^0.26.0", + "@aws-cdk/aws-lambda": "^0.26.0", "@aws-cdk/cdk": "^0.26.0" }, "peerDependencies": { "@aws-cdk/aws-iam": "^0.26.0", + "@aws-cdk/aws-ec2": "^0.26.0", "@aws-cdk/aws-kms": "^0.26.0", + "@aws-cdk/aws-lambda": "^0.26.0", "@aws-cdk/cdk": "^0.26.0" }, "engines": { "node": ">= 8.10.0" } -} \ No newline at end of file +} diff --git a/packages/@aws-cdk/aws-secretsmanager/test/test.rotation-schedule.ts b/packages/@aws-cdk/aws-secretsmanager/test/test.rotation-schedule.ts new file mode 100644 index 0000000000000..c009d60e57722 --- /dev/null +++ b/packages/@aws-cdk/aws-secretsmanager/test/test.rotation-schedule.ts @@ -0,0 +1,42 @@ +import { expect, haveResource } from '@aws-cdk/assert'; +import lambda = require('@aws-cdk/aws-lambda'); +import cdk = require('@aws-cdk/cdk'); +import { Test } from 'nodeunit'; +import secretsmanager = require('../lib'); + +export = { + 'create a rotation schedule'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const secret = new secretsmanager.Secret(stack, 'Secret'); + const rotationLambda = new lambda.Function(stack, 'Lambda', { + runtime: lambda.Runtime.NodeJS810, + code: lambda.Code.inline('export.handler = event => event;'), + handler: 'index.handler' + }); + + // WHEN + new secretsmanager.RotationSchedule(stack, 'RotationSchedule', { + secret, + rotationLambda + }); + + // THEN + expect(stack).to(haveResource('AWS::SecretsManager::RotationSchedule', { + SecretId: { + Ref: 'SecretA720EF05' + }, + RotationLambdaARN: { + 'Fn::GetAtt': [ + 'LambdaD247545B', + 'Arn' + ] + }, + RotationRules: { + AutomaticallyAfterDays: 30 + } + })); + + 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 b45e670fec303..d95887d3f135f 100644 --- a/packages/@aws-cdk/aws-secretsmanager/test/test.secret.ts +++ b/packages/@aws-cdk/aws-secretsmanager/test/test.secret.ts @@ -1,6 +1,7 @@ import { expect, haveResource } from '@aws-cdk/assert'; import iam = require('@aws-cdk/aws-iam'); import kms = require('@aws-cdk/aws-kms'); +import lambda = require('@aws-cdk/aws-lambda'); import cdk = require('@aws-cdk/cdk'); import { Test } from 'nodeunit'; import secretsmanager = require('../lib'); @@ -255,6 +256,64 @@ export = { // THEN test.equals(secret.secretArn, secretArn); test.same(secret.encryptionKey, encryptionKey); + test.done(); + }, + + 'attached secret'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const secret = new secretsmanager.Secret(stack, 'Secret'); + const target: secretsmanager.ISecretAttachmentTarget = { + asSecretAttachmentTarget: () => ({ + targetId: 'instance', + targetType: secretsmanager.AttachmentTargetType.Instance + }) + }; + + // WHEN + secret.addTargetAttachment('AttachedSecret', { target }); + + // THEN + expect(stack).to(haveResource('AWS::SecretsManager::SecretTargetAttachment', { + SecretId: { + Ref: 'SecretA720EF05' + }, + TargetId: 'instance', + TargetType: 'AWS::RDS::DBInstance' + })); + + 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 = { + asSecretAttachmentTarget: () => ({ + targetId: 'cluster', + targetType: secretsmanager.AttachmentTargetType.Cluster + }) + }; + const attachedSecret = secret.addTargetAttachment('AttachedSecret', { target }); + const rotationLambda = new lambda.Function(stack, 'Lambda', { + runtime: lambda.Runtime.NodeJS810, + code: lambda.Code.inline('export.handler = event => event;'), + handler: 'index.handler' + }); + + // WHEN + attachedSecret.addRotationSchedule('RotationSchedule', { + rotationLambda + }); + + // THEN + expect(stack).to(haveResource('AWS::SecretsManager::RotationSchedule', { + SecretId: { + Ref: 'SecretAttachedSecret94145316' // The secret returned by the attachment, not the secret itself. + } + })); + test.done(); } };