Skip to content

Commit

Permalink
feat(secretsmanager/rds): support credential rotation (#2052)
Browse files Browse the repository at this point in the history
Add construct for rotation schedule, secret target attachment and RDS rotation
single user.
  • Loading branch information
jogold authored and rix0rrr committed Mar 20, 2019
1 parent e6f3f48 commit bf79c82
Show file tree
Hide file tree
Showing 19 changed files with 1,816 additions and 16 deletions.
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 {
/**
* 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
*/
password?: string;

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

/**
Expand Down
Loading

0 comments on commit bf79c82

Please sign in to comment.