diff --git a/packages/@aws-cdk/aws-rds/README.md b/packages/@aws-cdk/aws-rds/README.md index 6efbdb42c8ac1..4b5683d2dc37f 100644 --- a/packages/@aws-cdk/aws-rds/README.md +++ b/packages/@aws-cdk/aws-rds/README.md @@ -23,7 +23,7 @@ your instances will be launched privately or publicly: ```ts const cluster = new rds.DatabaseCluster(this, 'Database', { engine: rds.DatabaseClusterEngine.auroraMysql({ version: rds.AuroraMysqlEngineVersion.VER_2_08_1 }), - credentials: rds.Credentials.fromUsername('clusteradmin'), // Optional - will default to admin + credentials: rds.Credentials.fromGeneratedSecret('clusteradmin'), // Optional - will default to 'admin' username and generated password instanceProps: { // optional , defaults to t3.medium instanceType: ec2.InstanceType.of(ec2.InstanceClass.BURSTABLE2, ec2.InstanceSize.SMALL), @@ -70,7 +70,7 @@ const instance = new rds.DatabaseInstance(this, 'Instance', { engine: rds.DatabaseInstanceEngine.oracleSe2({ version: rds.OracleEngineVersion.VER_19_0_0_0_2020_04_R1 }), // optional, defaults to m5.large instanceType: ec2.InstanceType.of(ec2.InstanceClass.BURSTABLE3, ec2.InstanceSize.SMALL), - credentials: rds.Credentials.fromUsername('syscdk'), // Optional - will default to admin + credentials: rds.Credentials.fromGeneratedSecret('syscdk'), // Optional - will default to 'admin' username and generated password vpc, vpcSubnets: { subnetType: ec2.SubnetType.PRIVATE @@ -146,13 +146,13 @@ const engine = rds.DatabaseInstanceEngine.postgres({ version: rds.PostgresEngine new rds.DatabaseInstance(this, 'InstanceWithUsername', { engine, vpc, - credentials: rds.Credentials.fromUsername('postgres'), // Creates an admin user of postgres with a generated password + credentials: rds.Credentials.fromGeneratedSecret('postgres'), // Creates an admin user of postgres with a generated password }); new rds.DatabaseInstance(this, 'InstanceWithUsernameAndPassword', { engine, vpc, - credentials: rds.Credentials.fromUsername('postgres', { password: SecretValue.ssmSecure('/dbPassword', 1) }), // Use password from SSM + credentials: rds.Credentials.fromPassword('postgres', SecretValue.ssmSecure('/dbPassword', '1')), // Use password from SSM }); const mySecret = secretsmanager.Secret.fromSecretName(this, 'DBSecret', 'myDBLoginInfo'); diff --git a/packages/@aws-cdk/aws-rds/lib/cluster.ts b/packages/@aws-cdk/aws-rds/lib/cluster.ts index c66eba30cd223..90b6ce18b2fcd 100644 --- a/packages/@aws-cdk/aws-rds/lib/cluster.ts +++ b/packages/@aws-cdk/aws-rds/lib/cluster.ts @@ -8,10 +8,9 @@ import { Annotations, Duration, RemovalPolicy, Resource, Token } from '@aws-cdk/ import { Construct } from 'constructs'; import { IClusterEngine } from './cluster-engine'; import { DatabaseClusterAttributes, IDatabaseCluster } from './cluster-ref'; -import { DatabaseSecret } from './database-secret'; import { Endpoint } from './endpoint'; import { IParameterGroup } from './parameter-group'; -import { applyRemovalPolicy, DEFAULT_PASSWORD_EXCLUDE_CHARS, defaultDeletionProtection, setupS3ImportExport } from './private/util'; +import { applyRemovalPolicy, DEFAULT_PASSWORD_EXCLUDE_CHARS, defaultDeletionProtection, renderCredentials, setupS3ImportExport } from './private/util'; import { BackupProps, Credentials, InstanceProps, PerformanceInsightRetention, RotationSingleUserOptions, RotationMultiUserOptions } from './props'; import { DatabaseProxy, DatabaseProxyOptions, ProxyTarget } from './proxy'; import { CfnDBCluster, CfnDBClusterProps, CfnDBInstance } from './rds.generated'; @@ -489,14 +488,7 @@ export class DatabaseCluster extends DatabaseClusterNew { this.singleUserRotationApplication = props.engine.singleUserRotationApplication; this.multiUserRotationApplication = props.engine.multiUserRotationApplication; - let credentials = props.credentials ?? Credentials.fromUsername(props.engine.defaultUsername ?? 'admin'); - if (!credentials.secret && !credentials.password) { - credentials = Credentials.fromSecret(new DatabaseSecret(this, 'Secret', { - username: credentials.username, - encryptionKey: credentials.encryptionKey, - excludeCharacters: credentials.excludeCharacters, - })); - } + const credentials = renderCredentials(this, props.engine, props.credentials); const secret = credentials.secret; const cluster = new CfnDBCluster(this, 'Resource', { diff --git a/packages/@aws-cdk/aws-rds/lib/database-secret.ts b/packages/@aws-cdk/aws-rds/lib/database-secret.ts index ea19e2051e6d5..0df046424a420 100644 --- a/packages/@aws-cdk/aws-rds/lib/database-secret.ts +++ b/packages/@aws-cdk/aws-rds/lib/database-secret.ts @@ -1,6 +1,7 @@ +import * as crypto from 'crypto'; import * as kms from '@aws-cdk/aws-kms'; import * as secretsmanager from '@aws-cdk/aws-secretsmanager'; -import { Aws } from '@aws-cdk/core'; +import { Aws, Names } from '@aws-cdk/core'; import { Construct } from 'constructs'; import { DEFAULT_PASSWORD_EXCLUDE_CHARS } from './private/util'; @@ -33,6 +34,18 @@ export interface DatabaseSecretProps { * @default " %+~`#$&*()|[]{}:;<>?!'/@\"\\" */ readonly excludeCharacters?: string; + + /** + * Whether to replace this secret when the criteria for the password change. + * + * This is achieved by overriding the logical id of the AWS::SecretsManager::Secret + * with a hash of the options that influence the password generation. This + * way a new secret will be created when the password is regenerated and the + * cluster or instance consuming this secret will have its credentials updated. + * + * @default false + */ + readonly replaceOnPasswordCriteriaChanges?: boolean; } /** @@ -42,6 +55,8 @@ export interface DatabaseSecretProps { */ export class DatabaseSecret extends secretsmanager.Secret { constructor(scope: Construct, id: string, props: DatabaseSecretProps) { + const excludeCharacters = props.excludeCharacters ?? DEFAULT_PASSWORD_EXCLUDE_CHARS; + super(scope, id, { encryptionKey: props.encryptionKey, description: `Generated by the CDK for stack: ${Aws.STACK_NAME}`, @@ -52,8 +67,22 @@ export class DatabaseSecret extends secretsmanager.Secret { masterarn: props.masterSecret?.secretArn, }), generateStringKey: 'password', - excludeCharacters: props.excludeCharacters ?? DEFAULT_PASSWORD_EXCLUDE_CHARS, + excludeCharacters, }, }); + + if (props.replaceOnPasswordCriteriaChanges) { + const hash = crypto.createHash('md5'); + hash.update(JSON.stringify({ + // Use here the options that influence the password generation. + // If at some point we add other password customization options + // they sould be added here below (e.g. `passwordLength`). + excludeCharacters, + })); + const logicalId = `${Names.uniqueId(this)}${hash.digest('hex')}`; + + const secret = this.node.defaultChild as secretsmanager.CfnSecret; + secret.overrideLogicalId(logicalId.slice(-255)); // Take last 255 chars + } } } diff --git a/packages/@aws-cdk/aws-rds/lib/instance.ts b/packages/@aws-cdk/aws-rds/lib/instance.ts index fa3ecac082fab..392c75ef3564c 100644 --- a/packages/@aws-cdk/aws-rds/lib/instance.ts +++ b/packages/@aws-cdk/aws-rds/lib/instance.ts @@ -12,7 +12,7 @@ import { Endpoint } from './endpoint'; import { IInstanceEngine } from './instance-engine'; import { IOptionGroup } from './option-group'; import { IParameterGroup } from './parameter-group'; -import { applyRemovalPolicy, DEFAULT_PASSWORD_EXCLUDE_CHARS, defaultDeletionProtection, engineDescription, setupS3ImportExport } from './private/util'; +import { applyRemovalPolicy, DEFAULT_PASSWORD_EXCLUDE_CHARS, defaultDeletionProtection, engineDescription, renderCredentials, setupS3ImportExport } from './private/util'; import { Credentials, PerformanceInsightRetention, RotationMultiUserOptions, RotationSingleUserOptions, SnapshotCredentials } from './props'; import { DatabaseProxy, DatabaseProxyOptions, ProxyTarget } from './proxy'; import { CfnDBInstance, CfnDBInstanceProps } from './rds.generated'; @@ -947,14 +947,7 @@ export class DatabaseInstance extends DatabaseInstanceSource implements IDatabas constructor(scope: Construct, id: string, props: DatabaseInstanceProps) { super(scope, id, props); - let credentials = props.credentials ?? Credentials.fromUsername(props.engine.defaultUsername ?? 'admin'); - if (!credentials.secret && !credentials.password) { - credentials = Credentials.fromSecret(new DatabaseSecret(this, 'Secret', { - username: credentials.username, - encryptionKey: credentials.encryptionKey, - excludeCharacters: credentials.excludeCharacters, - })); - } + const credentials = renderCredentials(this, props.engine, props.credentials); const secret = credentials.secret; const instance = new CfnDBInstance(this, 'Resource', { @@ -1032,6 +1025,7 @@ export class DatabaseInstanceFromSnapshot extends DatabaseInstanceSource impleme username: credentials.username, encryptionKey: credentials.encryptionKey, excludeCharacters: credentials.excludeCharacters, + replaceOnPasswordCriteriaChanges: credentials.replaceOnPasswordCriteriaChanges, }); } diff --git a/packages/@aws-cdk/aws-rds/lib/private/util.ts b/packages/@aws-cdk/aws-rds/lib/private/util.ts index 361e0228c62e4..8cba1e4a1ee1e 100644 --- a/packages/@aws-cdk/aws-rds/lib/private/util.ts +++ b/packages/@aws-cdk/aws-rds/lib/private/util.ts @@ -1,7 +1,9 @@ import * as iam from '@aws-cdk/aws-iam'; import * as s3 from '@aws-cdk/aws-s3'; import { Construct, CfnDeletionPolicy, CfnResource, RemovalPolicy } from '@aws-cdk/core'; +import { DatabaseSecret } from '../database-secret'; import { IEngine } from '../engine'; +import { Credentials } from '../props'; /** * The default set of characters we exclude from generated passwords for database users. @@ -90,3 +92,27 @@ export function defaultDeletionProtection(deletionProtection?: boolean, removalP ? deletionProtection : (removalPolicy === RemovalPolicy.RETAIN ? true : undefined); } + +/** + * Renders the credentials for an instance or cluster + */ +export function renderCredentials(scope: Construct, engine: IEngine, credentials?: Credentials): Credentials { + let renderedCredentials = credentials ?? Credentials.fromUsername(engine.defaultUsername ?? 'admin'); // For backwards compatibilty + + if (!renderedCredentials.secret && !renderedCredentials.password) { + renderedCredentials = Credentials.fromSecret( + new DatabaseSecret(scope, 'Secret', { + username: renderedCredentials.username, + encryptionKey: renderedCredentials.encryptionKey, + excludeCharacters: renderedCredentials.excludeCharacters, + // if username must be referenced as a string we can safely replace the + // secret when customization options are changed without risking a replacement + replaceOnPasswordCriteriaChanges: credentials?.usernameAsString, + }), + // pass username if it must be referenced as a string + credentials?.usernameAsString ? renderedCredentials.username : undefined, + ); + } + + return renderedCredentials; +} diff --git a/packages/@aws-cdk/aws-rds/lib/props.ts b/packages/@aws-cdk/aws-rds/lib/props.ts index a54d70a5bf2ac..81725c7ffbb54 100644 --- a/packages/@aws-cdk/aws-rds/lib/props.ts +++ b/packages/@aws-cdk/aws-rds/lib/props.ts @@ -116,18 +116,9 @@ export interface BackupProps { } /** - * Options for creating a Login from a username. + * Base options for creating Credentials. */ -export interface CredentialsFromUsernameOptions { - /** - * Password - * - * Do not put passwords in your CDK code directly. - * - * @default - a Secrets Manager generated password - */ - readonly password?: SecretValue; - +export interface CredentialsBaseOptions { /** * KMS encryption key to encrypt the generated secret. * @@ -144,14 +135,55 @@ export interface CredentialsFromUsernameOptions { readonly excludeCharacters?: string; } +/** + * Options for creating Credentials from a username. + */ +export interface CredentialsFromUsernameOptions extends CredentialsBaseOptions { + /** + * Password + * + * Do not put passwords in your CDK code directly. + * + * @default - a Secrets Manager generated password + */ + readonly password?: SecretValue; +} + /** * Username and password combination */ export abstract class Credentials { + /** + * Creates Credentials with a password generated and stored in Secrets Manager. + */ + public static fromGeneratedSecret(username: string, options: CredentialsBaseOptions = {}): Credentials { + return { + ...options, + username, + usernameAsString: true, + }; + } + + /** + * Creates Credentials from a password + * + * Do not put passwords in your CDK code directly. + */ + public static fromPassword(username: string, password: SecretValue): Credentials { + return { + username, + password, + usernameAsString: true, + }; + } /** * Creates Credentials for the given username, and optional password and key. - * If no password is provided, one will be generated and stored in SecretsManager. + * If no password is provided, one will be generated and stored in Secrets Manager. + * + * @deprecated use `fromGeneratedSecret()` or `fromPassword()` for new Clusters and Instances. + * Note that switching from `fromUsername()` to `fromGeneratedSecret()` or `fromPassword()` for already deployed + * Clusters or Instances will result in their replacement! */ public static fromUsername(username: string, options: CredentialsFromUsernameOptions = {}): Credentials { return { @@ -161,7 +193,7 @@ export abstract class Credentials { } /** - * Creates Credentials from an existing SecretsManager ``Secret`` (or ``DatabaseSecret``) + * Creates Credentials from an existing Secrets Manager ``Secret`` (or ``DatabaseSecret``) * * The Secret must be a JSON string with a ``username`` and ``password`` field: * ``` @@ -171,10 +203,16 @@ export abstract class Credentials { * "password": , * } * ``` + * + * @param secret The secret where the credentials are stored + * @param username The username defined in the secret. If specified the username + * will be referenced as a string and not a dynamic reference to the username + * field in the secret. This allows to replace the secret without replacing the + * instance or cluster. */ - public static fromSecret(secret: secretsmanager.ISecret): Credentials { + public static fromSecret(secret: secretsmanager.ISecret, username?: string): Credentials { return { - username: secret.secretValueFromJson('username').toString(), + username: username ?? secret.secretValueFromJson('username').toString(), password: secret.secretValueFromJson('password'), encryptionKey: secret.encryptionKey, secret, @@ -186,6 +224,14 @@ export abstract class Credentials { */ public abstract readonly username: string; + /** + * Whether the username should be referenced as a string and not as a dynamic + * reference to the username in the secret. + * + * @default false + */ + public abstract readonly usernameAsString?: boolean; + /** * Password * @@ -241,10 +287,29 @@ export interface SnapshotCredentialsFromGeneratedPasswordOptions { * Credentials to update the password for a ``DatabaseInstanceFromSnapshot``. */ export abstract class SnapshotCredentials { + /** + * Generate a new password for the snapshot, using the existing username and an optional encryption key. + * The new credentials are stored in Secrets Manager. + * + * Note - The username must match the existing master username of the snapshot. + */ + public static fromGeneratedSecret(username: string, options: SnapshotCredentialsFromGeneratedPasswordOptions = {}): SnapshotCredentials { + return { + ...options, + generatePassword: true, + replaceOnPasswordCriteriaChanges: true, + username, + }; + } + /** * Generate a new password for the snapshot, using the existing username and an optional encryption key. * * Note - The username must match the existing master username of the snapshot. + * + * @deprecated use `fromGeneratedSecret()` for new Clusters and Instances. + * Note that switching from `fromGeneratedPassword()` to `fromGeneratedSecret()` for already deployed + * Clusters or Instances will update their master password. */ public static fromGeneratedPassword(username: string, options: SnapshotCredentialsFromGeneratedPasswordOptions = {}): SnapshotCredentials { return { @@ -295,6 +360,13 @@ export abstract class SnapshotCredentials { */ public abstract readonly generatePassword: boolean; + /** + * Whether to replace the generated secret when the criteria for the password change. + * + * @default false + */ + public abstract readonly replaceOnPasswordCriteriaChanges?: boolean; + /** * The master user password. * diff --git a/packages/@aws-cdk/aws-rds/lib/serverless-cluster.ts b/packages/@aws-cdk/aws-rds/lib/serverless-cluster.ts index c6b1d7edc514a..899607166c919 100644 --- a/packages/@aws-cdk/aws-rds/lib/serverless-cluster.ts +++ b/packages/@aws-cdk/aws-rds/lib/serverless-cluster.ts @@ -5,11 +5,10 @@ import * as secretsmanager from '@aws-cdk/aws-secretsmanager'; import { Resource, Duration, Token, Annotations, RemovalPolicy, IResource, Stack, Lazy } from '@aws-cdk/core'; import { Construct } from 'constructs'; import { IClusterEngine } from './cluster-engine'; -import { DatabaseSecret } from './database-secret'; import { Endpoint } from './endpoint'; import { IParameterGroup } from './parameter-group'; import { DATA_API_ACTIONS } from './perms'; -import { applyRemovalPolicy, defaultDeletionProtection, DEFAULT_PASSWORD_EXCLUDE_CHARS } from './private/util'; +import { applyRemovalPolicy, defaultDeletionProtection, DEFAULT_PASSWORD_EXCLUDE_CHARS, renderCredentials } from './private/util'; import { Credentials, RotationMultiUserOptions, RotationSingleUserOptions } from './props'; import { CfnDBCluster } from './rds.generated'; import { ISubnetGroup, SubnetGroup } from './subnet-group'; @@ -420,14 +419,7 @@ export class ServerlessCluster extends ServerlessClusterBase { } } - let credentials = props.credentials ?? Credentials.fromUsername(props.engine.defaultUsername ?? 'admin'); - if (!credentials.secret && !credentials.password) { - credentials = Credentials.fromSecret(new DatabaseSecret(this, 'Secret', { - username: credentials.username, - encryptionKey: credentials.encryptionKey, - excludeCharacters: credentials.excludeCharacters, - })); - } + const credentials = renderCredentials(this, props.engine, props.credentials); const secret = credentials.secret; // bind the engine to the Cluster diff --git a/packages/@aws-cdk/aws-rds/test/integ.instance-from-generated-password.expected.json b/packages/@aws-cdk/aws-rds/test/integ.instance-from-generated-password.expected.json new file mode 100644 index 0000000000000..a01587728dac5 --- /dev/null +++ b/packages/@aws-cdk/aws-rds/test/integ.instance-from-generated-password.expected.json @@ -0,0 +1,625 @@ +{ + "Resources": { + "Vpc8378EB38": { + "Type": "AWS::EC2::VPC", + "Properties": { + "CidrBlock": "10.0.0.0/16", + "EnableDnsHostnames": true, + "EnableDnsSupport": true, + "InstanceTenancy": "default", + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-rds-fixed-username/Vpc" + } + ] + } + }, + "VpcPublicSubnet1Subnet5C2D37C4": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.0.0/19", + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "AvailabilityZone": "test-region-1a", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Public" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Public" + }, + { + "Key": "Name", + "Value": "aws-cdk-rds-fixed-username/Vpc/PublicSubnet1" + } + ] + } + }, + "VpcPublicSubnet1RouteTable6C95E38E": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-rds-fixed-username/Vpc/PublicSubnet1" + } + ] + } + }, + "VpcPublicSubnet1RouteTableAssociation97140677": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VpcPublicSubnet1RouteTable6C95E38E" + }, + "SubnetId": { + "Ref": "VpcPublicSubnet1Subnet5C2D37C4" + } + } + }, + "VpcPublicSubnet1DefaultRoute3DA9E72A": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VpcPublicSubnet1RouteTable6C95E38E" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": { + "Ref": "VpcIGWD7BA715C" + } + }, + "DependsOn": [ + "VpcVPCGWBF912B6E" + ] + }, + "VpcPublicSubnet1EIPD7E02669": { + "Type": "AWS::EC2::EIP", + "Properties": { + "Domain": "vpc", + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-rds-fixed-username/Vpc/PublicSubnet1" + } + ] + } + }, + "VpcPublicSubnet1NATGateway4D7517AA": { + "Type": "AWS::EC2::NatGateway", + "Properties": { + "AllocationId": { + "Fn::GetAtt": [ + "VpcPublicSubnet1EIPD7E02669", + "AllocationId" + ] + }, + "SubnetId": { + "Ref": "VpcPublicSubnet1Subnet5C2D37C4" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-rds-fixed-username/Vpc/PublicSubnet1" + } + ] + } + }, + "VpcPublicSubnet2Subnet691E08A3": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.32.0/19", + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "AvailabilityZone": "test-region-1b", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Public" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Public" + }, + { + "Key": "Name", + "Value": "aws-cdk-rds-fixed-username/Vpc/PublicSubnet2" + } + ] + } + }, + "VpcPublicSubnet2RouteTable94F7E489": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-rds-fixed-username/Vpc/PublicSubnet2" + } + ] + } + }, + "VpcPublicSubnet2RouteTableAssociationDD5762D8": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VpcPublicSubnet2RouteTable94F7E489" + }, + "SubnetId": { + "Ref": "VpcPublicSubnet2Subnet691E08A3" + } + } + }, + "VpcPublicSubnet2DefaultRoute97F91067": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VpcPublicSubnet2RouteTable94F7E489" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": { + "Ref": "VpcIGWD7BA715C" + } + }, + "DependsOn": [ + "VpcVPCGWBF912B6E" + ] + }, + "VpcPublicSubnet2EIP3C605A87": { + "Type": "AWS::EC2::EIP", + "Properties": { + "Domain": "vpc", + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-rds-fixed-username/Vpc/PublicSubnet2" + } + ] + } + }, + "VpcPublicSubnet2NATGateway9182C01D": { + "Type": "AWS::EC2::NatGateway", + "Properties": { + "AllocationId": { + "Fn::GetAtt": [ + "VpcPublicSubnet2EIP3C605A87", + "AllocationId" + ] + }, + "SubnetId": { + "Ref": "VpcPublicSubnet2Subnet691E08A3" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-rds-fixed-username/Vpc/PublicSubnet2" + } + ] + } + }, + "VpcPublicSubnet3SubnetBE12F0B6": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.64.0/19", + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "AvailabilityZone": "test-region-1c", + "MapPublicIpOnLaunch": true, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Public" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Public" + }, + { + "Key": "Name", + "Value": "aws-cdk-rds-fixed-username/Vpc/PublicSubnet3" + } + ] + } + }, + "VpcPublicSubnet3RouteTable93458DBB": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-rds-fixed-username/Vpc/PublicSubnet3" + } + ] + } + }, + "VpcPublicSubnet3RouteTableAssociation1F1EDF02": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VpcPublicSubnet3RouteTable93458DBB" + }, + "SubnetId": { + "Ref": "VpcPublicSubnet3SubnetBE12F0B6" + } + } + }, + "VpcPublicSubnet3DefaultRoute4697774F": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VpcPublicSubnet3RouteTable93458DBB" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "GatewayId": { + "Ref": "VpcIGWD7BA715C" + } + }, + "DependsOn": [ + "VpcVPCGWBF912B6E" + ] + }, + "VpcPublicSubnet3EIP3A666A23": { + "Type": "AWS::EC2::EIP", + "Properties": { + "Domain": "vpc", + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-rds-fixed-username/Vpc/PublicSubnet3" + } + ] + } + }, + "VpcPublicSubnet3NATGateway7640CD1D": { + "Type": "AWS::EC2::NatGateway", + "Properties": { + "AllocationId": { + "Fn::GetAtt": [ + "VpcPublicSubnet3EIP3A666A23", + "AllocationId" + ] + }, + "SubnetId": { + "Ref": "VpcPublicSubnet3SubnetBE12F0B6" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-rds-fixed-username/Vpc/PublicSubnet3" + } + ] + } + }, + "VpcPrivateSubnet1Subnet536B997A": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.96.0/19", + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "AvailabilityZone": "test-region-1a", + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Private" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Private" + }, + { + "Key": "Name", + "Value": "aws-cdk-rds-fixed-username/Vpc/PrivateSubnet1" + } + ] + } + }, + "VpcPrivateSubnet1RouteTableB2C5B500": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-rds-fixed-username/Vpc/PrivateSubnet1" + } + ] + } + }, + "VpcPrivateSubnet1RouteTableAssociation70C59FA6": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VpcPrivateSubnet1RouteTableB2C5B500" + }, + "SubnetId": { + "Ref": "VpcPrivateSubnet1Subnet536B997A" + } + } + }, + "VpcPrivateSubnet1DefaultRouteBE02A9ED": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VpcPrivateSubnet1RouteTableB2C5B500" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "NatGatewayId": { + "Ref": "VpcPublicSubnet1NATGateway4D7517AA" + } + } + }, + "VpcPrivateSubnet2Subnet3788AAA1": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.128.0/19", + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "AvailabilityZone": "test-region-1b", + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Private" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Private" + }, + { + "Key": "Name", + "Value": "aws-cdk-rds-fixed-username/Vpc/PrivateSubnet2" + } + ] + } + }, + "VpcPrivateSubnet2RouteTableA678073B": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-rds-fixed-username/Vpc/PrivateSubnet2" + } + ] + } + }, + "VpcPrivateSubnet2RouteTableAssociationA89CAD56": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VpcPrivateSubnet2RouteTableA678073B" + }, + "SubnetId": { + "Ref": "VpcPrivateSubnet2Subnet3788AAA1" + } + } + }, + "VpcPrivateSubnet2DefaultRoute060D2087": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VpcPrivateSubnet2RouteTableA678073B" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "NatGatewayId": { + "Ref": "VpcPublicSubnet2NATGateway9182C01D" + } + } + }, + "VpcPrivateSubnet3SubnetF258B56E": { + "Type": "AWS::EC2::Subnet", + "Properties": { + "CidrBlock": "10.0.160.0/19", + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "AvailabilityZone": "test-region-1c", + "MapPublicIpOnLaunch": false, + "Tags": [ + { + "Key": "aws-cdk:subnet-name", + "Value": "Private" + }, + { + "Key": "aws-cdk:subnet-type", + "Value": "Private" + }, + { + "Key": "Name", + "Value": "aws-cdk-rds-fixed-username/Vpc/PrivateSubnet3" + } + ] + } + }, + "VpcPrivateSubnet3RouteTableD98824C7": { + "Type": "AWS::EC2::RouteTable", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-rds-fixed-username/Vpc/PrivateSubnet3" + } + ] + } + }, + "VpcPrivateSubnet3RouteTableAssociation16BDDC43": { + "Type": "AWS::EC2::SubnetRouteTableAssociation", + "Properties": { + "RouteTableId": { + "Ref": "VpcPrivateSubnet3RouteTableD98824C7" + }, + "SubnetId": { + "Ref": "VpcPrivateSubnet3SubnetF258B56E" + } + } + }, + "VpcPrivateSubnet3DefaultRoute94B74F0D": { + "Type": "AWS::EC2::Route", + "Properties": { + "RouteTableId": { + "Ref": "VpcPrivateSubnet3RouteTableD98824C7" + }, + "DestinationCidrBlock": "0.0.0.0/0", + "NatGatewayId": { + "Ref": "VpcPublicSubnet3NATGateway7640CD1D" + } + } + }, + "VpcIGWD7BA715C": { + "Type": "AWS::EC2::InternetGateway", + "Properties": { + "Tags": [ + { + "Key": "Name", + "Value": "aws-cdk-rds-fixed-username/Vpc" + } + ] + } + }, + "VpcVPCGWBF912B6E": { + "Type": "AWS::EC2::VPCGatewayAttachment", + "Properties": { + "VpcId": { + "Ref": "Vpc8378EB38" + }, + "InternetGatewayId": { + "Ref": "VpcIGWD7BA715C" + } + } + }, + "InstanceSubnetGroupF2CBA54F": { + "Type": "AWS::RDS::DBSubnetGroup", + "Properties": { + "DBSubnetGroupDescription": "Subnet group for Instance database", + "SubnetIds": [ + { + "Ref": "VpcPrivateSubnet1Subnet536B997A" + }, + { + "Ref": "VpcPrivateSubnet2Subnet3788AAA1" + }, + { + "Ref": "VpcPrivateSubnet3SubnetF258B56E" + } + ] + } + }, + "InstanceSecurityGroupB4E5FA83": { + "Type": "AWS::EC2::SecurityGroup", + "Properties": { + "GroupDescription": "Security group for Instance database", + "SecurityGroupEgress": [ + { + "CidrIp": "0.0.0.0/0", + "Description": "Allow all outbound traffic by default", + "IpProtocol": "-1" + } + ], + "VpcId": { + "Ref": "Vpc8378EB38" + } + } + }, + "awscdkrdsfixedusernameInstanceSecretADA7FA0A0ae21a5e1432db367b627106107972de": { + "Type": "AWS::SecretsManager::Secret", + "Properties": { + "Description": { + "Fn::Join": [ + "", + [ + "Generated by the CDK for stack: ", + { + "Ref": "AWS::StackName" + } + ] + ] + }, + "GenerateSecretString": { + "ExcludeCharacters": "!&*^#@()", + "GenerateStringKey": "password", + "PasswordLength": 30, + "SecretStringTemplate": "{\"username\":\"admin\"}" + } + } + }, + "InstanceSecretAttachment83BEE581": { + "Type": "AWS::SecretsManager::SecretTargetAttachment", + "Properties": { + "SecretId": { + "Ref": "awscdkrdsfixedusernameInstanceSecretADA7FA0A0ae21a5e1432db367b627106107972de" + }, + "TargetId": { + "Ref": "InstanceC1063A87" + }, + "TargetType": "AWS::RDS::DBInstance" + } + }, + "InstanceC1063A87": { + "Type": "AWS::RDS::DBInstance", + "Properties": { + "DBInstanceClass": "db.t3.small", + "AllocatedStorage": "100", + "BackupRetentionPeriod": 0, + "CopyTagsToSnapshot": true, + "DBName": "CDKDB", + "DBSubnetGroupName": { + "Ref": "InstanceSubnetGroupF2CBA54F" + }, + "DeleteAutomatedBackups": true, + "Engine": "mysql", + "EngineVersion": "8.0.21", + "MasterUsername": "admin", + "MasterUserPassword": { + "Fn::Join": [ + "", + [ + "{{resolve:secretsmanager:", + { + "Ref": "awscdkrdsfixedusernameInstanceSecretADA7FA0A0ae21a5e1432db367b627106107972de" + }, + ":SecretString:password::}}" + ] + ] + }, + "StorageEncrypted": true, + "StorageType": "gp2", + "VPCSecurityGroups": [ + { + "Fn::GetAtt": [ + "InstanceSecurityGroupB4E5FA83", + "GroupId" + ] + } + ] + }, + "UpdateReplacePolicy": "Snapshot" + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-rds/test/integ.instance-from-generated-password.ts b/packages/@aws-cdk/aws-rds/test/integ.instance-from-generated-password.ts new file mode 100644 index 0000000000000..04b9746aa409a --- /dev/null +++ b/packages/@aws-cdk/aws-rds/test/integ.instance-from-generated-password.ts @@ -0,0 +1,27 @@ +import * as ec2 from '@aws-cdk/aws-ec2'; +import * as cdk from '@aws-cdk/core'; +import * as rds from '../lib'; + +const app = new cdk.App(); + +class DatabaseInstanceStack extends cdk.Stack { + constructor(scope: cdk.App, id: string, props?: cdk.StackProps) { + super(scope, id, props); + + const vpc = new ec2.Vpc(this, 'Vpc'); + + new rds.DatabaseInstance(this, 'Instance', { + engine: rds.DatabaseInstanceEngine.mysql({ version: rds.MysqlEngineVersion.VER_8_0_21 }), + instanceType: ec2.InstanceType.of(ec2.InstanceClass.BURSTABLE3, ec2.InstanceSize.SMALL), + credentials: rds.Credentials.fromGeneratedSecret('admin', { excludeCharacters: '!&*^#@()' }), + vpc, + databaseName: 'CDKDB', + storageEncrypted: true, + backupRetention: cdk.Duration.days(0), + deleteAutomatedBackups: true, + }); + } +} + +new DatabaseInstanceStack(app, 'aws-cdk-rds-fixed-username'); +app.synth(); diff --git a/packages/@aws-cdk/aws-rds/test/test.cluster.ts b/packages/@aws-cdk/aws-rds/test/test.cluster.ts index 72c2e010b2092..db27106dbeeb8 100644 --- a/packages/@aws-cdk/aws-rds/test/test.cluster.ts +++ b/packages/@aws-cdk/aws-rds/test/test.cluster.ts @@ -7,8 +7,8 @@ import * as s3 from '@aws-cdk/aws-s3'; import * as cdk from '@aws-cdk/core'; import { Test } from 'nodeunit'; import { - AuroraEngineVersion, AuroraMysqlEngineVersion, AuroraPostgresEngineVersion, CfnDBCluster, DatabaseCluster, DatabaseClusterEngine, - DatabaseClusterFromSnapshot, ParameterGroup, PerformanceInsightRetention, SubnetGroup, + AuroraEngineVersion, AuroraMysqlEngineVersion, AuroraPostgresEngineVersion, CfnDBCluster, Credentials, DatabaseCluster, + DatabaseClusterEngine, DatabaseClusterFromSnapshot, ParameterGroup, PerformanceInsightRetention, SubnetGroup, } from '../lib'; export = { @@ -1730,6 +1730,40 @@ export = { test.done(); }, + + 'fromGeneratedSecret'(test: Test) { + // GIVEN + const stack = testStack(); + const vpc = new ec2.Vpc(stack, 'VPC'); + + // WHEN + new DatabaseCluster(stack, 'Database', { + engine: DatabaseClusterEngine.aurora({ version: AuroraEngineVersion.VER_1_22_2 }), + credentials: Credentials.fromGeneratedSecret('admin'), + instanceProps: { + vpc, + }, + }); + + // THEN + expect(stack).to(haveResource('AWS::RDS::DBCluster', { + MasterUsername: 'admin', // username is a string + MasterUserPassword: { + 'Fn::Join': [ + '', + [ + '{{resolve:secretsmanager:', + { + Ref: 'DatabaseSecretC9203AE33fdaad7efa858a3daf9490cf0a702aeb', // logical id is a hash + }, + ':SecretString:password::}}', + ], + ], + }, + })); + + test.done(); + }, }; function testStack() { diff --git a/packages/@aws-cdk/aws-rds/test/test.database-secret.ts b/packages/@aws-cdk/aws-rds/test/test.database-secret.ts index bc215c6da3487..6e4edb551d764 100644 --- a/packages/@aws-cdk/aws-rds/test/test.database-secret.ts +++ b/packages/@aws-cdk/aws-rds/test/test.database-secret.ts @@ -1,5 +1,5 @@ import { expect, haveResource } from '@aws-cdk/assert'; -import { Stack } from '@aws-cdk/core'; +import { CfnResource, Stack } from '@aws-cdk/core'; import { Test } from 'nodeunit'; import { DatabaseSecret } from '../lib'; import { DEFAULT_PASSWORD_EXCLUDE_CHARS } from '../lib/private/util'; @@ -10,7 +10,7 @@ export = { const stack = new Stack(); // WHEN - new DatabaseSecret(stack, 'Secret', { + const dbSecret = new DatabaseSecret(stack, 'Secret', { username: 'admin-username', }); @@ -35,6 +35,8 @@ export = { }, })); + test.equal(getSecretLogicalId(dbSecret, stack), 'SecretA720EF05'); + test.done(); }, @@ -75,4 +77,42 @@ export = { test.done(); }, + + 'replace on password critera change'(test: Test) { + // GIVEN + const stack = new Stack(); + + // WHEN + const dbSecret = new DatabaseSecret(stack, 'Secret', { + username: 'admin', + replaceOnPasswordCriteriaChanges: true, + }); + + // THEN + const dbSecretlogicalId = getSecretLogicalId(dbSecret, stack); + test.equal(dbSecretlogicalId, 'Secret3fdaad7efa858a3daf9490cf0a702aeb'); + + // same node path but other excluded characters + stack.node.tryRemoveChild('Secret'); + const otherSecret1 = new DatabaseSecret(stack, 'Secret', { + username: 'admin', + replaceOnPasswordCriteriaChanges: true, + excludeCharacters: '@!()[]', + }); + test.notEqual(dbSecretlogicalId, getSecretLogicalId(otherSecret1, stack)); + + // other node path but same excluded characters + const otherSecret2 = new DatabaseSecret(stack, 'Secret2', { + username: 'admin', + replaceOnPasswordCriteriaChanges: true, + }); + test.notEqual(dbSecretlogicalId, getSecretLogicalId(otherSecret2, stack)); + + test.done(); + }, }; + +function getSecretLogicalId(dbSecret: DatabaseSecret, stack: Stack): string { + const cfnSecret = dbSecret.node.defaultChild as CfnResource; + return stack.resolve(cfnSecret.logicalId); +} diff --git a/packages/@aws-cdk/aws-rds/test/test.instance.ts b/packages/@aws-cdk/aws-rds/test/test.instance.ts index 2af2afaf35d31..9cc8c02d9d5a9 100644 --- a/packages/@aws-cdk/aws-rds/test/test.instance.ts +++ b/packages/@aws-cdk/aws-rds/test/test.instance.ts @@ -319,6 +319,27 @@ export = { test.done(); }, + 'fromGeneratedSecret'(test: Test) { + new rds.DatabaseInstanceFromSnapshot(stack, 'Instance', { + snapshotIdentifier: 'my-snapshot', + engine: rds.DatabaseInstanceEngine.mysql({ version: rds.MysqlEngineVersion.VER_8_0_19 }), + vpc, + credentials: rds.SnapshotCredentials.fromGeneratedSecret('admin', { + excludeCharacters: '"@/\\', + }), + }); + + expect(stack).to(haveResourceLike('AWS::RDS::DBInstance', { + MasterUsername: ABSENT, + MasterUserPassword: { + // logical id of secret has a hash + 'Fn::Join': ['', ['{{resolve:secretsmanager:', { Ref: 'InstanceSecretB6DFA6BE8ee0a797cad8a68dbeb85f8698cdb5bb' }, ':SecretString:password::}}']], + }, + })); + + test.done(); + }, + 'throws if generating a new password without a username'(test: Test) { test.throws(() => new rds.DatabaseInstanceFromSnapshot(stack, 'Instance', { snapshotIdentifier: 'my-snapshot', @@ -1138,4 +1159,49 @@ export = { test.done(); }, }, + + 'fromGeneratedSecret'(test: Test) { + // WHEN + new rds.DatabaseInstance(stack, 'Database', { + engine: rds.DatabaseInstanceEngine.postgres({ version: rds.PostgresEngineVersion.VER_12_3 }), + vpc, + credentials: rds.Credentials.fromGeneratedSecret('postgres'), + }); + + // THEN + expect(stack).to(haveResource('AWS::RDS::DBInstance', { + MasterUsername: 'postgres', // username is a string + MasterUserPassword: { + 'Fn::Join': [ + '', + [ + '{{resolve:secretsmanager:', + { + Ref: 'DatabaseSecretC9203AE33fdaad7efa858a3daf9490cf0a702aeb', // logical id is a hash + }, + ':SecretString:password::}}', + ], + ], + }, + })); + + test.done(); + }, + + 'fromPassword'(test: Test) { + // WHEN + new rds.DatabaseInstance(stack, 'Database', { + engine: rds.DatabaseInstanceEngine.postgres({ version: rds.PostgresEngineVersion.VER_12_3 }), + vpc, + credentials: rds.Credentials.fromPassword('postgres', cdk.SecretValue.ssmSecure('/dbPassword', '1')), + }); + + // THEN + expect(stack).to(haveResource('AWS::RDS::DBInstance', { + MasterUsername: 'postgres', // username is a string + MasterUserPassword: '{{resolve:ssm-secure:/dbPassword:1}}', // reference to SSM + })); + + test.done(); + }, };