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 1 commit
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
3 changes: 3 additions & 0 deletions packages/@aws-cdk/aws-secretsmanager/lib/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
export * from './secret';
export * from './secret-string';
export * from './secret-target-attachment';
export * from './rotation-schedule';
export * from './rds-rotation-single-user';

// AWS::SecretsManager CloudFormation Resources:
export * from './secretsmanager.generated';
204 changes: 204 additions & 0 deletions packages/@aws-cdk/aws-secretsmanager/lib/rds-rotation-single-user.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,204 @@
import ec2 = require('@aws-cdk/aws-ec2');
Copy link
Contributor

Choose a reason for hiding this comment

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

Feels like this whole file should live in the aws-rds package.

Copy link
Contributor Author

@jogold jogold Mar 19, 2019

Choose a reason for hiding this comment

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

OK to move the RDS rotation, in the same PR then? It breaks the conventional commit + squash convention here...

Copy link
Contributor

Choose a reason for hiding this comment

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

Not sure what you mean. The only thing it might break is the scope, if the scope were a single package.

I would feel comfortable with a feat(secretsmanager/rds): support credential rotation.

import lambda = require('@aws-cdk/aws-lambda');
import serverless = require('@aws-cdk/aws-serverless');
import cdk = require('@aws-cdk/cdk');
import { ISecret } from './secret';

/**
* 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 RdsDatabaseEngine {
/**
* MariaDB
*/
MariaDb = 'mariadb',

/**
* MySQL
*/
Mysql = 'mysql',

/**
* Oracle
*/
Oracle = 'oracle',

/**
* PostgreSQL
*/
Postgres = 'postgres',

/**
* SQL Server
*/
SqlServer = 'sqlserver'
}

/**
* Options to add single user rotation to a RDS instance or cluster.
*/
export interface RdsRotationSingleUserOptions {
/**
* 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 RdsRotationSingleUser.
*/
export interface RdsRotationSingleUserProps extends RdsRotationSingleUserOptions {
/**
* The secret to rotate. It must be a JSON string with the following format:
* {
* '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>
* }
*
* 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: ISecret;

/**
* The database engine. Either `serverlessApplicationLocation` or `engine` must be specified.
*
* @default no engine specified
*/
engine?: RdsDatabaseEngine;

/**
* 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 connections object of the RDS database instance or cluster.
*/
connections: ec2.Connections;
Copy link
Contributor

Choose a reason for hiding this comment

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

I think I'd rather you pass an IConnectable here. Any reason that wouldn't work?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

You are right, this should have been an IConnectable.

}

/**
* Single user secret rotation for a RDS database instance or cluster.
*/
export class RdsRotationSingleUser extends cdk.Construct {
constructor(scope: cdk.Construct, id: string, props: RdsRotationSingleUserProps) {
super(scope, id);

if (!props.serverlessApplicationLocation && !props.engine) {
throw new Error('Either `serverlessApplicationLocation` or `engine` must be specified.');
}

if (!props.connections.defaultPortRange) {
throw new Error('The `connections` object 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.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 RdsDatabaseEngine.MariaDb:
return ServerlessApplicationLocation.MariaDbRotationSingleUser;
case RdsDatabaseEngine.Mysql:
return ServerlessApplicationLocation.MysqlRotationSingleUser;
case RdsDatabaseEngine.Oracle:
return ServerlessApplicationLocation.OracleRotationSingleUser;
case RdsDatabaseEngine.Postgres:
return ServerlessApplicationLocation.PostgresRotationSingleUser;
case RdsDatabaseEngine.SqlServer:
return ServerlessApplicationLocation.SqlServerRotationSingleUser;
default:
throw new Error(`Engine ${engine} not supported for single user rotation.`);
}
}
49 changes: 49 additions & 0 deletions packages/@aws-cdk/aws-secretsmanager/lib/rotation-schedule.ts
Original file line number Diff line number Diff line change
@@ -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
}
});
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import cdk = require('@aws-cdk/cdk');
import { ISecret, Secret } from './secret';
import { CfnSecretTargetAttachment } from './secretsmanager.generated';

/**
* 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'
}

/**
* Options to add a secret attachement to a secret.
*/
export interface SecretTargetAttachmentOptions {
Copy link
Contributor

Choose a reason for hiding this comment

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

Feel like this should be an interface like:

export interface ISecretAttachmentTarget {
  asSecretAttachmentTarget(): SecretAttachmentTargetProps;
}

export interface SecretAttachmentTargetProps {
   targetId: string;
   targetType: AttachmentTargetType;
}

export interface SecretTargetAttachmentOptions {
  target: ISecretAttachmentTarget;
}

And then have the DBInstance and DBCluster classes implement ISecretAttachmentTarget.

/**
* The id of the target to attach the secret to.
*/
targetId: string;

/**
* The type of the target to attach the secret to.
*/
targetType: AttachmentTargetType;
}

/**
* Construction properties for a SecretAttachement.
*/
export interface SecretTargetAttachmentProps extends SecretTargetAttachmentOptions {
/**
* The secret to attach to the target.
*/
secret: ISecret;
}

/**
* A secret target attachment.
*/
export class SecretTargetAttachment extends cdk.Construct {
/**
* The secret attached to the target.
*/
public readonly secret: ISecret;

constructor(scope: cdk.Construct, id: string, props: SecretTargetAttachmentProps) {
super(scope, id);

const attachment = new CfnSecretTargetAttachment(this, 'Resource', {
secretId: props.secret.secretArn,
targetId: props.targetId,
targetType: props.targetType
});

// This allows to reference the secret after attachment (dependency). When
// creating a secret for a RDS cluster or instance this is the secret that
// will be used as the input for the rotation.
this.secret = Secret.import(this, 'Secret', {
Copy link
Contributor

Choose a reason for hiding this comment

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

It feels like this is something that users might forget, especially since these docs are in implementation notes.

Another idea (not necessarily saying you must do this), how about changing this into:

class AttachedSecret implements ISecret {
  ...
}

So that an AttachedSecret can be used anywhere a secret can be used? And if not that, then at least put these notes in a public comment somewhere, seems very relevant to this construct's usage.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

I like this idea of the AttachedSecret

secretArn: attachment.secretTargetAttachmentSecretArn,
encryptionKey: props.secret.encryptionKey
});
}
}
26 changes: 26 additions & 0 deletions packages/@aws-cdk/aws-secretsmanager/lib/secret.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
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 { SecretTargetAttachment, SecretTargetAttachmentOptions } from './secret-target-attachment';
import secretsmanager = require('./secretsmanager.generated');

/**
Expand Down Expand Up @@ -51,6 +53,16 @@ export interface ISecret extends cdk.IConstruct {
* stages is applied.
*/
grantRead(grantee: iam.IPrincipal, versionStages?: string[]): void;

/**
* Adds a target attachment to the secret.
*/
addTargetAttachment(id: string, options: SecretTargetAttachmentOptions): SecretTargetAttachment;

/**
* Adds a rotation schedule to the secret.
*/
addRotationSchedule(id: string, options: RotationScheduleOptions): RotationSchedule;
}

/**
Expand Down Expand Up @@ -150,6 +162,20 @@ export abstract class SecretBase extends cdk.Construct implements ISecret {
public jsonFieldValue(key: string): string {
return this.secretString.jsonFieldValue(key);
}

public addTargetAttachment(id: string, options: SecretTargetAttachmentOptions): SecretTargetAttachment {
return new SecretTargetAttachment(this, id, {
secret: this,
...options
});
}

public addRotationSchedule(id: string, options: RotationScheduleOptions): RotationSchedule {
return new RotationSchedule(this, id, {
secret: this,
...options
});
}
}

/**
Expand Down
Loading