-
Notifications
You must be signed in to change notification settings - Fork 3.9k
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
Changes from 1 commit
0a39df7
1f1742b
bce6128
81f2c5b
6554f2f
2a99fb4
87cf249
d16eaa2
e381a81
e2b9f13
f014a07
9aa5fe7
f4b4403
304c1d5
ea1a32e
df59fca
fdb3236
312d551
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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'; |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,204 @@ | ||
import ec2 = require('@aws-cdk/aws-ec2'); | ||
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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think I'd rather you pass an There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You are right, this should have been an |
||
} | ||
|
||
/** | ||
* 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.`); | ||
} | ||
} |
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 { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
/** | ||
* 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', { | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I like this idea of the |
||
secretArn: attachment.secretTargetAttachmentSecretArn, | ||
encryptionKey: props.secret.encryptionKey | ||
}); | ||
} | ||
} |
There was a problem hiding this comment.
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.There was a problem hiding this comment.
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...
There was a problem hiding this comment.
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
.