Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(secretsmanager/rds): support credential rotation #2052

Merged
merged 18 commits into from
Mar 20, 2019
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 29 additions & 2 deletions packages/@aws-cdk/aws-rds/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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.
Expand All @@ -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": "<required: database engine>",
"host": "<required: instance host name>",
"username": "<required: username>",
"password": "<required: password>",
"dbname": "<optional: database name>",
"port": "<optional: if not specified, default port will be used>"
}
```
3 changes: 2 additions & 1 deletion packages/@aws-cdk/aws-rds/lib/cluster-ref.ts
Original file line number Diff line number Diff line change
@@ -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
*/
Expand Down
141 changes: 134 additions & 7 deletions packages/@aws-cdk/aws-rds/lib/cluster.ts
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -91,16 +94,67 @@ 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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

<3

/**
* Import an existing DatabaseCluster from properties
*/
public static import(scope: cdk.Construct, id: string, props: DatabaseClusterImportProps): 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
*/
Expand Down Expand Up @@ -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) {
Expand All @@ -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,
Expand All @@ -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');
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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');
}
}
37 changes: 37 additions & 0 deletions packages/@aws-cdk/aws-rds/lib/database-secret.ts
Original file line number Diff line number Diff line change
@@ -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
});
}
}
2 changes: 2 additions & 0 deletions packages/@aws-cdk/aws-rds/lib/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
15 changes: 12 additions & 3 deletions packages/@aws-cdk/aws-rds/lib/props.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import ec2 = require('@aws-cdk/aws-ec2');
import kms = require('@aws-cdk/aws-kms');

/**
* The engine for the database cluster
Expand Down Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

<3

*/
password?: string;

/**
* KMS encryption key to encrypt the generated secret.
*
* @default default master key
*/
password: string;
kmsKey?: kms.IEncryptionKey;
}

/**
Expand Down
Loading