From fe8ec67b6077cc362f304c1116e4cd8f1dbc829d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=91=A8=F0=9F=8F=BC=E2=80=8D=F0=9F=92=BB=20Romain=20M?= =?UTF-8?q?arcadier-Muller?= Date: Mon, 14 Jan 2019 14:46:16 -0800 Subject: [PATCH 1/8] SecretsManager WIP --- .../@aws-cdk/aws-secretsmanager/lib/index.ts | 1 + .../@aws-cdk/aws-secretsmanager/lib/secret.ts | 246 ++++++++++++++++++ .../@aws-cdk/aws-secretsmanager/package.json | 4 + .../aws-secretsmanager/test/test.secret.ts | 225 ++++++++++++++++ 4 files changed, 476 insertions(+) create mode 100644 packages/@aws-cdk/aws-secretsmanager/lib/secret.ts create mode 100644 packages/@aws-cdk/aws-secretsmanager/test/test.secret.ts diff --git a/packages/@aws-cdk/aws-secretsmanager/lib/index.ts b/packages/@aws-cdk/aws-secretsmanager/lib/index.ts index 8e1843fcc32d4..0f425ff7d53ed 100644 --- a/packages/@aws-cdk/aws-secretsmanager/lib/index.ts +++ b/packages/@aws-cdk/aws-secretsmanager/lib/index.ts @@ -1,3 +1,4 @@ +export * from './secret'; export * from './secret-string'; // AWS::SecretsManager CloudFormation Resources: diff --git a/packages/@aws-cdk/aws-secretsmanager/lib/secret.ts b/packages/@aws-cdk/aws-secretsmanager/lib/secret.ts new file mode 100644 index 0000000000000..8c6c35c92bb29 --- /dev/null +++ b/packages/@aws-cdk/aws-secretsmanager/lib/secret.ts @@ -0,0 +1,246 @@ +import iam = require('@aws-cdk/aws-iam'); +import kms = require('@aws-cdk/aws-kms'); +import cdk = require('@aws-cdk/cdk'); +import secretsmanager = require('./secretsmanager.generated'); + +/** + * A secret in AWS Secrets Manager. + */ +export interface ISecret extends cdk.IConstruct { + /** + * The customer-managed encryption key that is used to encrypt this secret, if any. When ``unknown``, the default KMS + * key for the account and region is being used. + */ + readonly encryptionKey?: kms.IEncryptionKey; + + /** + * The ARN of the secret in AWS Secrets Manager. + */ + readonly secretArn: string; + + /** + * Grants reading the secret value to some role. + * + * @param grantee the principal being granted permission. + * @param versionStages the version stages the grant is limited to. If not specified, no restriction on the version + * stages is applied. + */ + grantRead(grantee: iam.IPrincipal, versionStages?: string[]): ISecret; +} + +/** + * The properties required to create a new secret in AWS Secrets Manager. + */ +export interface SecretProps { + /** + * An optional, human-friendly description of the secret. + */ + description?: string; + + /** + * The customer-managed encryption key to use for encrypting the secret value. + * + * @default a default KMS key for the account and region is used. + */ + encryptionKey?: kms.IEncryptionKey; + + /** + * The value of the secret. Either this or ``generateSecretString`` must be specified, but not both. + * + * @default a secret is generated using ``generateSecretString`` configuration. + */ + secretString?: string; + + /** + * Configuration for how to generate a secret value. Either this or ``secretString`` must be specified, but not both. + * + * @default the secret value specified in ``secretString`` is used. + */ + generateSecretString?: SecretStringGenerator; + + /** + * A name for the secret. Note that deleting secrets from SecretsManager does not happen immediately, but after a 7 to + * 30 days blackout period. During that period, it is not possible to create another secret that shares the same name. + * + * @default a name is generated by CloudFormation. + */ + name?: string; +} + +/** + * Attributes required to import an existing secret into the Stack. + */ +export interface SecretImportProps { + /** + * The encryption key that is used to encrypt the secret, unless the default SecretsManager key is used. + */ + encryptionKey?: kms.IEncryptionKey; + + /** + * The ARN of the secret in SecretsManager. + */ + secretArn: string; +} + +/** + * The common behavior of Secrets. Users should not use this class directly, and instead use ``Secret``. + */ +export abstract class SecretBase extends cdk.Construct implements ISecret { + public abstract readonly encryptionKey?: kms.IEncryptionKey; + public abstract readonly secretArn: string; + + public grantRead(grantee: iam.IPrincipal, versionStages?: string[]): ISecret { + const statement = new iam.PolicyStatement() + .allow() + .addAction('secretsmanager:GetSecretValue') + .addResource(this.secretArn); + if (versionStages != null) { + statement.addCondition('ForAnyValue:StringEquals', { + 'secretsmanager:VersionStage': versionStages + }); + } + grantee.addToPolicy(statement); + + if (this.encryptionKey) { + this.encryptionKey.addToResourcePolicy(new iam.PolicyStatement() + .allow() + .addPrincipal(grantee.principal) + .addAction('kms:Decrypt') + .addAllResources() + .addCondition('StringEquals', { + 'kms:ViaService': `secretsmanager.${cdk.Stack.find(this).region}.amazonaws.com` + })); + } + return this; + } +} + +/** + * Creates a new secret in AWS SecretsManager. + */ +export class Secret extends SecretBase { + /** + * Import an existing secret into the Stack. + * + * @param scope the scope of the import. + * @param id the ID of the imported Secret in the construct tree. + * @param props the attributes of the imported secret. + */ + public static import(scope: cdk.Construct, id: string, props: SecretImportProps): ISecret { + return new ImportedSecret(scope, id, props); + } + + public readonly encryptionKey?: kms.IEncryptionKey; + public readonly secretArn: string; + + constructor(scope: cdk.Construct, id: string, props: SecretProps) { + super(scope, id); + + if ((props.secretString == null) === (props.generateSecretString == null)) { + throw new Error('Either secretString or generateSecretString must be specified, but not both.'); + } + + const resource = new secretsmanager.CfnSecret(this, 'Resource', { + description: props.description, + kmsKeyId: props.encryptionKey && props.encryptionKey.keyArn, + secretString: props.secretString, + generateSecretString: props.generateSecretString, + name: props.name, + }); + + this.encryptionKey = props.encryptionKey; + this.secretArn = resource.secretArn; + } +} + +/** + * Configuration to generate secrets such as passwords automatically. + */ +export interface SecretStringGenerator { + /** + * Specifies that the generated password shouldn't include uppercase letters. + * + * @default false + */ + excludeUppercase?: boolean; + + /** + * Specifies whether the generated password must include at least one of every allowed character type. + * + * @default true + */ + requireEachIncludedType?: boolean; + + /** + * Specifies that the generated password can include the space character. + * + * @default false + */ + includeSpace?: boolean; + + /** + * A string that includes characters that shouldn't be included in the generated password. The string can be a minimum + * of ``0`` and a maximum of ``4096`` characters long. + * + * @default no exclusions + */ + excludeCharacters?: string; + + /** + * The desired length of the generated password. + * + * @default 32 + */ + passwordLength?: number; + + /** + * Specifies that the generated password shouldn't include punctuation characters. + * + * @default false + */ + excludePunctuation?: boolean; + + /** + * Specifies that the generated password shouldn't include lowercase letters. + * + * @default false + */ + excludeLowercase?: boolean; + + /** + * Specifies that the generated password shouldn't include digits. + * + * @default false + */ + excludeNumbers?: boolean; +} + +/** + * Configuration to generate secrets such as passwords automatically, and include them in a JSON object template. + */ +export interface TemplatedSecretStringGenerator extends SecretStringGenerator { + /** + * The JSON key name that's used to add the generated password to the JSON structure specified by the + * ``secretStringTemplate`` parameter. + */ + generateStringKey: string; + + /** + * A properly structured JSON string that the generated password can be added to. The ``generateStringKey`` is + * combined with the generated random string and inserted into the JSON structure that's specified by this parameter. + * The merged JSON string is returned as the completed SecretString of the secret. + */ + secretStringTemplate: string; +} + +class ImportedSecret extends SecretBase { + public readonly encryptionKey?: kms.IEncryptionKey; + public readonly secretArn: string; + + constructor(scope: cdk.Construct, id: string, props: SecretImportProps) { + super(scope, id); + + this.encryptionKey = props.encryptionKey; + this.secretArn = props.secretArn; + } +} diff --git a/packages/@aws-cdk/aws-secretsmanager/package.json b/packages/@aws-cdk/aws-secretsmanager/package.json index 1559be54f2c5c..069254f711af3 100644 --- a/packages/@aws-cdk/aws-secretsmanager/package.json +++ b/packages/@aws-cdk/aws-secretsmanager/package.json @@ -61,9 +61,13 @@ "pkglint": "^0.21.0" }, "dependencies": { + "@aws-cdk/aws-kms": "^0.21.0", + "@aws-cdk/aws-iam": "^0.21.0", "@aws-cdk/cdk": "^0.21.0" }, "peerDependencies": { + "@aws-cdk/aws-kms": "^0.21.0", + "@aws-cdk/aws-iam": "^0.21.0", "@aws-cdk/cdk": "^0.21.0" }, "engines": { diff --git a/packages/@aws-cdk/aws-secretsmanager/test/test.secret.ts b/packages/@aws-cdk/aws-secretsmanager/test/test.secret.ts new file mode 100644 index 0000000000000..fa4dd758fd378 --- /dev/null +++ b/packages/@aws-cdk/aws-secretsmanager/test/test.secret.ts @@ -0,0 +1,225 @@ +import { expect, haveResource, matchTemplate } from '@aws-cdk/assert'; +import iam = require('@aws-cdk/aws-iam'); +import kms = require('@aws-cdk/aws-kms'); +import cdk = require('@aws-cdk/cdk'); +import { Test } from 'nodeunit'; +import secretsmanager = require('../lib'); + +export = { + 'create inline secret string'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + new secretsmanager.Secret(stack, 'Secret', { + secretString: 'Shhh-secret!' + }); + + // THEN + expect(stack).to(haveResource('AWS::SecretsManager::Secret', { + SecretString: 'Shhh-secret!' + })); + + test.done(); + }, + + 'create generated secret string'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + new secretsmanager.Secret(stack, 'Secret', { + generateSecretString: {} + }); + + // THEN + expect(stack).to(haveResource('AWS::SecretsManager::Secret', { + GenerateSecretString: {} + })); + + test.done(); + }, + + 'cannot create a secret without either generateSecretString or secretString'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + // THEN + test.throws(() => new secretsmanager.Secret(stack, 'Secret', {}), + /Either secretString or generateSecretString must be specified, but not both./); + test.done(); + }, + + 'cannot create a secret with both generateSecretString or secretString'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + // THEN + test.throws(() => new secretsmanager.Secret(stack, 'Secret', { secretString: '', generateSecretString: {} }), + /Either secretString or generateSecretString must be specified, but not both./); + test.done(); + }, + + 'grantRead'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const key = new kms.EncryptionKey(stack, 'KMS'); + const secret = new secretsmanager.Secret(stack, 'Secret', { encryptionKey: key, secretString: 'Shhh!' }); + const role = new iam.Role(stack, 'Role', { assumedBy: new iam.AccountRootPrincipal() }); + + // WHEN + secret.grantRead(role); + + // THEN + // tslint:disable:object-literal-key-quotes + expect(stack).to(matchTemplate({ + "Resources": { + "KMS6B14D45A": { + "Type": "AWS::KMS::Key", + "Properties": { + "KeyPolicy": { + "Statement": [ + { + "Action": [ + "kms:Create*", + "kms:Describe*", + "kms:Enable*", + "kms:List*", + "kms:Put*", + "kms:Update*", + "kms:Revoke*", + "kms:Disable*", + "kms:Get*", + "kms:Delete*", + "kms:ScheduleKeyDeletion", + "kms:CancelKeyDeletion" + ], + "Effect": "Allow", + "Principal": { + "AWS": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::", + { + "Ref": "AWS::AccountId" + }, + ":root" + ] + ] + } + }, + "Resource": "*" + }, + { + "Action": "kms:Decrypt", + "Condition": { + "StringEquals": { + "kms:ViaService": { + "Fn::Join": [ + "", + [ + "secretsmanager.", + { + "Ref": "AWS::Region" + }, + ".amazonaws.com" + ] + ] + } + } + }, + "Effect": "Allow", + "Principal": { + "AWS": { + "Fn::GetAtt": [ + "Role1ABCC5F0", + "Arn" + ] + } + }, + "Resource": "*" + } + ], + "Version": "2012-10-17" + } + }, + "DeletionPolicy": "Retain" + }, + "SecretA720EF05": { + "Type": "AWS::SecretsManager::Secret", + "Properties": { + "KmsKeyId": { + "Fn::GetAtt": [ + "KMS6B14D45A", + "Arn" + ] + }, + "SecretString": "Shhh!" + } + }, + "Role1ABCC5F0": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "AWS": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::", + { + "Ref": "AWS::AccountId" + }, + ":root" + ] + ] + } + } + } + ], + "Version": "2012-10-17" + } + } + }, + "RoleDefaultPolicy5FFB7DAB": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "secretsmanager:GetSecretValue", + "Effect": "Allow", + "Resource": { + "Ref": "SecretA720EF05" + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "RoleDefaultPolicy5FFB7DAB", + "Roles": [ + { + "Ref": "Role1ABCC5F0" + } + ] + } + } + } + })); + // tslint:enable:object-literal-key-quotes + test.done(); + }, +}; From fa99b269006406207bd48937284f1a9b62737ceb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=91=A8=F0=9F=8F=BC=E2=80=8D=F0=9F=92=BB=20Romain=20M?= =?UTF-8?q?arcadier-Muller?= Date: Wed, 6 Feb 2019 11:13:43 +0100 Subject: [PATCH 2/8] Add export method --- packages/@aws-cdk/aws-secretsmanager/lib/secret.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/packages/@aws-cdk/aws-secretsmanager/lib/secret.ts b/packages/@aws-cdk/aws-secretsmanager/lib/secret.ts index 8c6c35c92bb29..555e981bd078a 100644 --- a/packages/@aws-cdk/aws-secretsmanager/lib/secret.ts +++ b/packages/@aws-cdk/aws-secretsmanager/lib/secret.ts @@ -18,6 +18,13 @@ export interface ISecret extends cdk.IConstruct { */ readonly secretArn: string; + /** + * Exports this secret. + * + * @return import props that can be passed back to ``Secret.import``. + */ + export(): SecretImportProps; + /** * Grants reading the secret value to some role. * @@ -89,6 +96,13 @@ export abstract class SecretBase extends cdk.Construct implements ISecret { public abstract readonly encryptionKey?: kms.IEncryptionKey; public abstract readonly secretArn: string; + public export(): SecretImportProps { + return { + encryptionKey: this.encryptionKey, + secretArn: this.secretArn, + }; + } + public grantRead(grantee: iam.IPrincipal, versionStages?: string[]): ISecret { const statement = new iam.PolicyStatement() .allow() From ede5fe070931618c8158293df3c6f9dafa5100ae Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=91=A8=F0=9F=8F=BC=E2=80=8D=F0=9F=92=BB=20Romain=20M?= =?UTF-8?q?arcadier-Muller?= Date: Wed, 6 Feb 2019 11:31:20 +0100 Subject: [PATCH 3/8] Add integration tests --- .../@aws-cdk/aws-secretsmanager/README.md | 4 ++ .../@aws-cdk/aws-secretsmanager/package.json | 3 +- .../test/integ.secret.lit.expected.json | 65 +++++++++++++++++++ .../test/integ.secret.lit.ts | 16 +++++ 4 files changed, 87 insertions(+), 1 deletion(-) create mode 100644 packages/@aws-cdk/aws-secretsmanager/test/integ.secret.lit.expected.json create mode 100644 packages/@aws-cdk/aws-secretsmanager/test/integ.secret.lit.ts diff --git a/packages/@aws-cdk/aws-secretsmanager/README.md b/packages/@aws-cdk/aws-secretsmanager/README.md index e3381fc7fa43d..d35e14c139e86 100644 --- a/packages/@aws-cdk/aws-secretsmanager/README.md +++ b/packages/@aws-cdk/aws-secretsmanager/README.md @@ -3,3 +3,7 @@ ```ts const secretsmanager = require('@aws-cdk/aws-secretsmanager'); ``` + +### Create a new Secret in a Stack + +[example of creating a secret](test/integ.secret.lit.ts) diff --git a/packages/@aws-cdk/aws-secretsmanager/package.json b/packages/@aws-cdk/aws-secretsmanager/package.json index a7f11779e0203..11e47aa28b592 100644 --- a/packages/@aws-cdk/aws-secretsmanager/package.json +++ b/packages/@aws-cdk/aws-secretsmanager/package.json @@ -58,7 +58,8 @@ "@aws-cdk/assert": "^0.23.0", "cdk-build-tools": "^0.23.0", "cfn2ts": "^0.23.0", - "pkglint": "^0.23.0" + "pkglint": "^0.23.0", + "cdk-integ-tools": "^0.23.0" }, "dependencies": { "@aws-cdk/aws-kms": "^0.23.0", diff --git a/packages/@aws-cdk/aws-secretsmanager/test/integ.secret.lit.expected.json b/packages/@aws-cdk/aws-secretsmanager/test/integ.secret.lit.expected.json new file mode 100644 index 0000000000000..ee909bc0f6940 --- /dev/null +++ b/packages/@aws-cdk/aws-secretsmanager/test/integ.secret.lit.expected.json @@ -0,0 +1,65 @@ +{ + "Resources": { + "TestRole6C9272DF": { + "Type": "AWS::IAM::Role", + "Properties": { + "AssumeRolePolicyDocument": { + "Statement": [ + { + "Action": "sts:AssumeRole", + "Effect": "Allow", + "Principal": { + "AWS": { + "Fn::Join": [ + "", + [ + "arn:", + { + "Ref": "AWS::Partition" + }, + ":iam::", + { + "Ref": "AWS::AccountId" + }, + ":root" + ] + ] + } + } + } + ], + "Version": "2012-10-17" + } + } + }, + "TestRoleDefaultPolicyD1C92014": { + "Type": "AWS::IAM::Policy", + "Properties": { + "PolicyDocument": { + "Statement": [ + { + "Action": "secretsmanager:GetSecretValue", + "Effect": "Allow", + "Resource": { + "Ref": "SecretA720EF05" + } + } + ], + "Version": "2012-10-17" + }, + "PolicyName": "TestRoleDefaultPolicyD1C92014", + "Roles": [ + { + "Ref": "TestRole6C9272DF" + } + ] + } + }, + "SecretA720EF05": { + "Type": "AWS::SecretsManager::Secret", + "Properties": { + "GenerateSecretString": {} + } + } + } +} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-secretsmanager/test/integ.secret.lit.ts b/packages/@aws-cdk/aws-secretsmanager/test/integ.secret.lit.ts new file mode 100644 index 0000000000000..cb1bc7f7c6e2e --- /dev/null +++ b/packages/@aws-cdk/aws-secretsmanager/test/integ.secret.lit.ts @@ -0,0 +1,16 @@ +import iam = require('@aws-cdk/aws-iam'); +import cdk = require('@aws-cdk/cdk'); +import secretsManager = require('../lib'); + +const app = new cdk.App(); +const stack = new cdk.Stack(app, 'Integ-SecretsManager-Secret'); +const role = new iam.Role(stack, 'TestRole', { assumedBy: new iam.AccountRootPrincipal() }); + +/// !show +const secret = new secretsManager.Secret(stack, 'Secret', { + generateSecretString: {} +}); +secret.grantRead(role); +/// !hide + +app.run(); From 838e63410ea325bca63ecb667f324a2afbfdfb2c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=91=A8=F0=9F=8F=BC=E2=80=8D=F0=9F=92=BB=20Romain=20M?= =?UTF-8?q?arcadier-Muller?= Date: Wed, 6 Feb 2019 11:33:26 +0100 Subject: [PATCH 4/8] No fluent API! --- packages/@aws-cdk/aws-secretsmanager/lib/secret.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/@aws-cdk/aws-secretsmanager/lib/secret.ts b/packages/@aws-cdk/aws-secretsmanager/lib/secret.ts index 555e981bd078a..9b4de59c41c72 100644 --- a/packages/@aws-cdk/aws-secretsmanager/lib/secret.ts +++ b/packages/@aws-cdk/aws-secretsmanager/lib/secret.ts @@ -32,7 +32,7 @@ export interface ISecret extends cdk.IConstruct { * @param versionStages the version stages the grant is limited to. If not specified, no restriction on the version * stages is applied. */ - grantRead(grantee: iam.IPrincipal, versionStages?: string[]): ISecret; + grantRead(grantee: iam.IPrincipal, versionStages?: string[]): void; } /** @@ -103,7 +103,7 @@ export abstract class SecretBase extends cdk.Construct implements ISecret { }; } - public grantRead(grantee: iam.IPrincipal, versionStages?: string[]): ISecret { + public grantRead(grantee: iam.IPrincipal, versionStages?: string[]): void { const statement = new iam.PolicyStatement() .allow() .addAction('secretsmanager:GetSecretValue') @@ -125,7 +125,6 @@ export abstract class SecretBase extends cdk.Construct implements ISecret { 'kms:ViaService': `secretsmanager.${cdk.Stack.find(this).region}.amazonaws.com` })); } - return this; } } From 6780a307e448b233b06147905f2388fab6200531 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=91=A8=F0=9F=8F=BC=E2=80=8D=F0=9F=92=BB=20Romain=20M?= =?UTF-8?q?arcadier-Muller?= Date: Wed, 6 Feb 2019 14:20:44 +0100 Subject: [PATCH 5/8] Some PR feedback --- .../@aws-cdk/aws-secretsmanager/README.md | 15 +++++++ .../@aws-cdk/aws-secretsmanager/lib/secret.ts | 45 +++++++++---------- .../test/integ.secret.lit.ts | 4 +- 3 files changed, 37 insertions(+), 27 deletions(-) diff --git a/packages/@aws-cdk/aws-secretsmanager/README.md b/packages/@aws-cdk/aws-secretsmanager/README.md index d35e14c139e86..e1347e938bacf 100644 --- a/packages/@aws-cdk/aws-secretsmanager/README.md +++ b/packages/@aws-cdk/aws-secretsmanager/README.md @@ -6,4 +6,19 @@ const secretsmanager = require('@aws-cdk/aws-secretsmanager'); ### Create a new Secret in a Stack +In order to have SecretsManager generate a new secret value automatically, you can get started with the following: + [example of creating a secret](test/integ.secret.lit.ts) + +The `Secret` construct does not allow specifying the `SecretString` property of the `AWS::SecretsManager::Secret` +resource as this will almost always lead to the secret being surfaced in plain text. If you need to use a pre-existing +secret, the recommended way to proceed is to manually provision the secret in *AWS SecretsManager* and use the +`Secret.import` method to make it available in your CDK Application: + +```ts +const secret = Secret.import(scope, 'ImportedSecret', { + secretArn: 'arn:aws:secretsmanager:::secret:-', + // If the secret is encrypted using a KMS-hosted CMK, either import or reference that key: + encryptionKey, +}); +``` diff --git a/packages/@aws-cdk/aws-secretsmanager/lib/secret.ts b/packages/@aws-cdk/aws-secretsmanager/lib/secret.ts index 9b4de59c41c72..95ecb6329e7b9 100644 --- a/packages/@aws-cdk/aws-secretsmanager/lib/secret.ts +++ b/packages/@aws-cdk/aws-secretsmanager/lib/secret.ts @@ -8,8 +8,8 @@ import secretsmanager = require('./secretsmanager.generated'); */ export interface ISecret extends cdk.IConstruct { /** - * The customer-managed encryption key that is used to encrypt this secret, if any. When ``unknown``, the default KMS - * key for the account and region is being used. + * The customer-managed encryption key that is used to encrypt this secret, if any. When not specified, the default + * KMS key for the account and region is being used. */ readonly encryptionKey?: kms.IEncryptionKey; @@ -52,16 +52,10 @@ export interface SecretProps { encryptionKey?: kms.IEncryptionKey; /** - * The value of the secret. Either this or ``generateSecretString`` must be specified, but not both. + * Configuration for how to generate a secret value. * - * @default a secret is generated using ``generateSecretString`` configuration. - */ - secretString?: string; - - /** - * Configuration for how to generate a secret value. Either this or ``secretString`` must be specified, but not both. - * - * @default the secret value specified in ``secretString`` is used. + * @default 32 characters with upper-case letters, lower-case letters, punctuation and numbers (at least one from each + * category), per the default values of ``SecretStringGenerator``. */ generateSecretString?: SecretStringGenerator; @@ -96,14 +90,10 @@ export abstract class SecretBase extends cdk.Construct implements ISecret { public abstract readonly encryptionKey?: kms.IEncryptionKey; public abstract readonly secretArn: string; - public export(): SecretImportProps { - return { - encryptionKey: this.encryptionKey, - secretArn: this.secretArn, - }; - } + public abstract export(): SecretImportProps; public grantRead(grantee: iam.IPrincipal, versionStages?: string[]): void { + // @see https://docs.aws.amazon.com/fr_fr/secretsmanager/latest/userguide/auth-and-access_identity-based-policies.html const statement = new iam.PolicyStatement() .allow() .addAction('secretsmanager:GetSecretValue') @@ -116,6 +106,7 @@ export abstract class SecretBase extends cdk.Construct implements ISecret { grantee.addToPolicy(statement); if (this.encryptionKey) { + // @see https://docs.aws.amazon.com/fr_fr/kms/latest/developerguide/services-secrets-manager.html this.encryptionKey.addToResourcePolicy(new iam.PolicyStatement() .allow() .addPrincipal(grantee.principal) @@ -146,17 +137,12 @@ export class Secret extends SecretBase { public readonly encryptionKey?: kms.IEncryptionKey; public readonly secretArn: string; - constructor(scope: cdk.Construct, id: string, props: SecretProps) { + constructor(scope: cdk.Construct, id: string, props: SecretProps = {}) { super(scope, id); - if ((props.secretString == null) === (props.generateSecretString == null)) { - throw new Error('Either secretString or generateSecretString must be specified, but not both.'); - } - const resource = new secretsmanager.CfnSecret(this, 'Resource', { description: props.description, kmsKeyId: props.encryptionKey && props.encryptionKey.keyArn, - secretString: props.secretString, generateSecretString: props.generateSecretString, name: props.name, }); @@ -164,6 +150,13 @@ export class Secret extends SecretBase { this.encryptionKey = props.encryptionKey; this.secretArn = resource.secretArn; } + + public export(): SecretImportProps { + return { + encryptionKey: this.encryptionKey, + secretArn: this.secretArn, + }; + } } /** @@ -250,10 +243,14 @@ class ImportedSecret extends SecretBase { public readonly encryptionKey?: kms.IEncryptionKey; public readonly secretArn: string; - constructor(scope: cdk.Construct, id: string, props: SecretImportProps) { + constructor(scope: cdk.Construct, id: string, private readonly props: SecretImportProps) { super(scope, id); this.encryptionKey = props.encryptionKey; this.secretArn = props.secretArn; } + + public export() { + return this.props; + } } diff --git a/packages/@aws-cdk/aws-secretsmanager/test/integ.secret.lit.ts b/packages/@aws-cdk/aws-secretsmanager/test/integ.secret.lit.ts index cb1bc7f7c6e2e..6000fd94003c5 100644 --- a/packages/@aws-cdk/aws-secretsmanager/test/integ.secret.lit.ts +++ b/packages/@aws-cdk/aws-secretsmanager/test/integ.secret.lit.ts @@ -7,9 +7,7 @@ const stack = new cdk.Stack(app, 'Integ-SecretsManager-Secret'); const role = new iam.Role(stack, 'TestRole', { assumedBy: new iam.AccountRootPrincipal() }); /// !show -const secret = new secretsManager.Secret(stack, 'Secret', { - generateSecretString: {} -}); +const secret = new secretsManager.Secret(stack, 'Secret'); secret.grantRead(role); /// !hide From 454bdb0033a7095382585006253b6766424ae174 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=91=A8=F0=9F=8F=BC=E2=80=8D=F0=9F=92=BB=20Romain=20M?= =?UTF-8?q?arcadier-Muller?= Date: Wed, 6 Feb 2019 14:51:53 +0100 Subject: [PATCH 6/8] Remove secretString support & imprve tests --- .../@aws-cdk/aws-secretsmanager/lib/secret.ts | 16 +- .../aws-secretsmanager/test/test.secret.ts | 383 ++++++++++-------- .../test/test.secretsmanager.ts | 9 - tools/cdk-build-tools/config/nycrc | 1 + 4 files changed, 225 insertions(+), 184 deletions(-) delete mode 100644 packages/@aws-cdk/aws-secretsmanager/test/test.secretsmanager.ts diff --git a/packages/@aws-cdk/aws-secretsmanager/lib/secret.ts b/packages/@aws-cdk/aws-secretsmanager/lib/secret.ts index 95ecb6329e7b9..f8f2cf1833103 100644 --- a/packages/@aws-cdk/aws-secretsmanager/lib/secret.ts +++ b/packages/@aws-cdk/aws-secretsmanager/lib/secret.ts @@ -1,6 +1,7 @@ import iam = require('@aws-cdk/aws-iam'); import kms = require('@aws-cdk/aws-kms'); import cdk = require('@aws-cdk/cdk'); +import { SecretString } from './secret-string'; import secretsmanager = require('./secretsmanager.generated'); /** @@ -18,6 +19,12 @@ export interface ISecret extends cdk.IConstruct { */ readonly secretArn: string; + /** + * Returns a SecretString corresponding to this secret, so that the secret value can be referred to from other parts + * of the application (such as an RDS instance's master user password property). + */ + asSecretString(): SecretString; + /** * Exports this secret. * @@ -90,8 +97,15 @@ export abstract class SecretBase extends cdk.Construct implements ISecret { public abstract readonly encryptionKey?: kms.IEncryptionKey; public abstract readonly secretArn: string; + private secretString?: SecretString; + public abstract export(): SecretImportProps; + public asSecretString() { + this.secretString = this.secretString || new SecretString(this, 'SecretString', { secretId: this.secretArn }); + return this.secretString; + } + public grantRead(grantee: iam.IPrincipal, versionStages?: string[]): void { // @see https://docs.aws.amazon.com/fr_fr/secretsmanager/latest/userguide/auth-and-access_identity-based-policies.html const statement = new iam.PolicyStatement() @@ -143,7 +157,7 @@ export class Secret extends SecretBase { const resource = new secretsmanager.CfnSecret(this, 'Resource', { description: props.description, kmsKeyId: props.encryptionKey && props.encryptionKey.keyArn, - generateSecretString: props.generateSecretString, + generateSecretString: props.generateSecretString || {}, name: props.name, }); diff --git a/packages/@aws-cdk/aws-secretsmanager/test/test.secret.ts b/packages/@aws-cdk/aws-secretsmanager/test/test.secret.ts index fa4dd758fd378..91127ec15ea11 100644 --- a/packages/@aws-cdk/aws-secretsmanager/test/test.secret.ts +++ b/packages/@aws-cdk/aws-secretsmanager/test/test.secret.ts @@ -1,4 +1,4 @@ -import { expect, haveResource, matchTemplate } from '@aws-cdk/assert'; +import { expect, haveResource } from '@aws-cdk/assert'; import iam = require('@aws-cdk/aws-iam'); import kms = require('@aws-cdk/aws-kms'); import cdk = require('@aws-cdk/cdk'); @@ -6,220 +6,255 @@ import { Test } from 'nodeunit'; import secretsmanager = require('../lib'); export = { - 'create inline secret string'(test: Test) { + 'default secret'(test: Test) { // GIVEN const stack = new cdk.Stack(); // WHEN - new secretsmanager.Secret(stack, 'Secret', { - secretString: 'Shhh-secret!' - }); + new secretsmanager.Secret(stack, 'Secret'); // THEN expect(stack).to(haveResource('AWS::SecretsManager::Secret', { - SecretString: 'Shhh-secret!' + GenerateSecretString: {} })); test.done(); }, - 'create generated secret string'(test: Test) { + 'grantRead'(test: Test) { // GIVEN const stack = new cdk.Stack(); + const key = new kms.EncryptionKey(stack, 'KMS'); + const secret = new secretsmanager.Secret(stack, 'Secret', { encryptionKey: key }); + const role = new iam.Role(stack, 'Role', { assumedBy: new iam.AccountRootPrincipal() }); // WHEN - new secretsmanager.Secret(stack, 'Secret', { - generateSecretString: {} - }); + secret.grantRead(role); // THEN - expect(stack).to(haveResource('AWS::SecretsManager::Secret', { - GenerateSecretString: {} + expect(stack).to(haveResource('AWS::IAM::Policy', { + PolicyDocument: { + Version: '2012-10-17', + Statement: [{ + Action: 'secretsmanager:GetSecretValue', + Effect: 'Allow', + Resource: { Ref: 'SecretA720EF05' }, + }] + } + })); + expect(stack).to(haveResource('AWS::KMS::Key', { + KeyPolicy: { + Statement: [{ + Action: [ + "kms:Create*", + "kms:Describe*", + "kms:Enable*", + "kms:List*", + "kms:Put*", + "kms:Update*", + "kms:Revoke*", + "kms:Disable*", + "kms:Get*", + "kms:Delete*", + "kms:ScheduleKeyDeletion", + "kms:CancelKeyDeletion" + ], + Effect: "Allow", + Principal: { + AWS: { + "Fn::Join": [ + "", + [ + "arn:", + { + Ref: "AWS::Partition" + }, + ":iam::", + { + Ref: "AWS::AccountId" + }, + ":root" + ] + ] + } + }, + Resource: "*" + }, { + Action: "kms:Decrypt", + Condition: { + StringEquals: { + "kms:ViaService": { + "Fn::Join": [ + "", + [ + "secretsmanager.", + { + Ref: "AWS::Region" + }, + ".amazonaws.com" + ] + ] + } + } + }, + Effect: "Allow", + Principal: { + AWS: { + "Fn::GetAtt": [ + "Role1ABCC5F0", + "Arn" + ] + } + }, + Resource: "*" + } + ], + Version: "2012-10-17" + } })); - - test.done(); - }, - - 'cannot create a secret without either generateSecretString or secretString'(test: Test) { - // GIVEN - const stack = new cdk.Stack(); - - // THEN - test.throws(() => new secretsmanager.Secret(stack, 'Secret', {}), - /Either secretString or generateSecretString must be specified, but not both./); - test.done(); - }, - - 'cannot create a secret with both generateSecretString or secretString'(test: Test) { - // GIVEN - const stack = new cdk.Stack(); - - // THEN - test.throws(() => new secretsmanager.Secret(stack, 'Secret', { secretString: '', generateSecretString: {} }), - /Either secretString or generateSecretString must be specified, but not both./); test.done(); }, - 'grantRead'(test: Test) { + 'grantRead with version label constraint'(test: Test) { // GIVEN const stack = new cdk.Stack(); const key = new kms.EncryptionKey(stack, 'KMS'); - const secret = new secretsmanager.Secret(stack, 'Secret', { encryptionKey: key, secretString: 'Shhh!' }); + const secret = new secretsmanager.Secret(stack, 'Secret', { encryptionKey: key }); const role = new iam.Role(stack, 'Role', { assumedBy: new iam.AccountRootPrincipal() }); // WHEN - secret.grantRead(role); + secret.grantRead(role, ['FOO', 'bar']); // THEN - // tslint:disable:object-literal-key-quotes - expect(stack).to(matchTemplate({ - "Resources": { - "KMS6B14D45A": { - "Type": "AWS::KMS::Key", - "Properties": { - "KeyPolicy": { - "Statement": [ - { - "Action": [ - "kms:Create*", - "kms:Describe*", - "kms:Enable*", - "kms:List*", - "kms:Put*", - "kms:Update*", - "kms:Revoke*", - "kms:Disable*", - "kms:Get*", - "kms:Delete*", - "kms:ScheduleKeyDeletion", - "kms:CancelKeyDeletion" - ], - "Effect": "Allow", - "Principal": { - "AWS": { - "Fn::Join": [ - "", - [ - "arn:", - { - "Ref": "AWS::Partition" - }, - ":iam::", - { - "Ref": "AWS::AccountId" - }, - ":root" - ] - ] - } - }, - "Resource": "*" - }, - { - "Action": "kms:Decrypt", - "Condition": { - "StringEquals": { - "kms:ViaService": { - "Fn::Join": [ - "", - [ - "secretsmanager.", - { - "Ref": "AWS::Region" - }, - ".amazonaws.com" - ] - ] - } - } + expect(stack).to(haveResource('AWS::IAM::Policy', { + PolicyDocument: { + Version: '2012-10-17', + Statement: [{ + Action: 'secretsmanager:GetSecretValue', + Effect: 'Allow', + Resource: { Ref: 'SecretA720EF05' }, + Condition: { + 'ForAnyValue:StringEquals': { + 'secretsmanager:VersionStage': ['FOO', 'bar'], + }, + }, + }] + } + })); + expect(stack).to(haveResource('AWS::KMS::Key', { + KeyPolicy: { + Statement: [{ + Action: [ + "kms:Create*", + "kms:Describe*", + "kms:Enable*", + "kms:List*", + "kms:Put*", + "kms:Update*", + "kms:Revoke*", + "kms:Disable*", + "kms:Get*", + "kms:Delete*", + "kms:ScheduleKeyDeletion", + "kms:CancelKeyDeletion" + ], + Effect: "Allow", + Principal: { + AWS: { + "Fn::Join": [ + "", + [ + "arn:", + { + Ref: "AWS::Partition" }, - "Effect": "Allow", - "Principal": { - "AWS": { - "Fn::GetAtt": [ - "Role1ABCC5F0", - "Arn" - ] - } + ":iam::", + { + Ref: "AWS::AccountId" }, - "Resource": "*" - } - ], - "Version": "2012-10-17" + ":root" + ] + ] + } + }, + Resource: "*" + }, { + Action: "kms:Decrypt", + Condition: { + StringEquals: { + "kms:ViaService": { + "Fn::Join": [ + "", + [ + "secretsmanager.", + { + Ref: "AWS::Region" + }, + ".amazonaws.com" + ] + ] + } } }, - "DeletionPolicy": "Retain" - }, - "SecretA720EF05": { - "Type": "AWS::SecretsManager::Secret", - "Properties": { - "KmsKeyId": { + Effect: "Allow", + Principal: { + AWS: { "Fn::GetAtt": [ - "KMS6B14D45A", + "Role1ABCC5F0", "Arn" ] - }, - "SecretString": "Shhh!" - } - }, - "Role1ABCC5F0": { - "Type": "AWS::IAM::Role", - "Properties": { - "AssumeRolePolicyDocument": { - "Statement": [ - { - "Action": "sts:AssumeRole", - "Effect": "Allow", - "Principal": { - "AWS": { - "Fn::Join": [ - "", - [ - "arn:", - { - "Ref": "AWS::Partition" - }, - ":iam::", - { - "Ref": "AWS::AccountId" - }, - ":root" - ] - ] - } - } - } - ], - "Version": "2012-10-17" } - } - }, - "RoleDefaultPolicy5FFB7DAB": { - "Type": "AWS::IAM::Policy", - "Properties": { - "PolicyDocument": { - "Statement": [ - { - "Action": "secretsmanager:GetSecretValue", - "Effect": "Allow", - "Resource": { - "Ref": "SecretA720EF05" - } - } - ], - "Version": "2012-10-17" - }, - "PolicyName": "RoleDefaultPolicy5FFB7DAB", - "Roles": [ - { - "Ref": "Role1ABCC5F0" - } - ] - } + }, + Resource: "*" } + ], + Version: "2012-10-17" } })); - // tslint:enable:object-literal-key-quotes test.done(); }, + + 'asSecretString'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const key = new kms.EncryptionKey(stack, 'KMS'); + const secret = new secretsmanager.Secret(stack, 'Secret', { encryptionKey: key }); + + // WHEN + new cdk.Resource(stack, 'FakeResource', { + type: 'CDK::Phony::Resource', + properties: { + value: secret.asSecretString().value + } + }); + + // THEN + expect(stack).to(haveResource('CDK::Phony::Resource', { + value: { + 'Fn::Join': ['', [ + '{{resolve:secretsmanager:', + { Ref: 'SecretA720EF05' }, + ':SecretString:::}}' + ]] + } + })); + test.done(); + }, + + 'import'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const encryptionKey = new kms.EncryptionKey(stack, 'KMS'); + const secretArn = 'arn::of::a::secret'; + + // WHEN + const secret = secretsmanager.Secret.import(stack, 'Secret', { + secretArn, encryptionKey + }); + + // THEN + test.equals(secret.secretArn, secretArn); + test.same(secret.encryptionKey, encryptionKey); + test.done(); + } }; diff --git a/packages/@aws-cdk/aws-secretsmanager/test/test.secretsmanager.ts b/packages/@aws-cdk/aws-secretsmanager/test/test.secretsmanager.ts deleted file mode 100644 index 51db772aeb78f..0000000000000 --- a/packages/@aws-cdk/aws-secretsmanager/test/test.secretsmanager.ts +++ /dev/null @@ -1,9 +0,0 @@ -import { Test, testCase } from 'nodeunit'; -import {} from '../lib'; - -export = testCase({ - notTested(test: Test) { - test.ok(true, 'No tests are specified for this package.'); - test.done(); - } -}); diff --git a/tools/cdk-build-tools/config/nycrc b/tools/cdk-build-tools/config/nycrc index 5a7a6baf55b70..645e3ba4a9256 100644 --- a/tools/cdk-build-tools/config/nycrc +++ b/tools/cdk-build-tools/config/nycrc @@ -6,6 +6,7 @@ "branches": 50, "reporter": [ "html", + "lcov", "text-summary" ], "cache": true, From 4b3bb4b436ff94e8b70556bab15e2686fa94fce5 Mon Sep 17 00:00:00 2001 From: Elad Ben-Israel Date: Wed, 6 Feb 2019 15:54:39 +0200 Subject: [PATCH 7/8] minor readme edits --- packages/@aws-cdk/aws-secretsmanager/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/@aws-cdk/aws-secretsmanager/README.md b/packages/@aws-cdk/aws-secretsmanager/README.md index e1347e938bacf..872c7965a5dc9 100644 --- a/packages/@aws-cdk/aws-secretsmanager/README.md +++ b/packages/@aws-cdk/aws-secretsmanager/README.md @@ -11,9 +11,9 @@ In order to have SecretsManager generate a new secret value automatically, you c [example of creating a secret](test/integ.secret.lit.ts) The `Secret` construct does not allow specifying the `SecretString` property of the `AWS::SecretsManager::Secret` -resource as this will almost always lead to the secret being surfaced in plain text. If you need to use a pre-existing -secret, the recommended way to proceed is to manually provision the secret in *AWS SecretsManager* and use the -`Secret.import` method to make it available in your CDK Application: +resource as this will almost always lead to the secret being surfaced in plain text and possibly committed to your +source control. If you need to use a pre-existing secret, the recommended way is to manually provision +the secret in *AWS SecretsManager* and use the `Secret.import` method to make it available in your CDK Application: ```ts const secret = Secret.import(scope, 'ImportedSecret', { From 707ca418a8a1502932177f85a99d35d8aff04b29 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=F0=9F=91=A8=F0=9F=8F=BC=E2=80=8D=F0=9F=92=BB=20Romain=20M?= =?UTF-8?q?arcadier-Muller?= Date: Wed, 6 Feb 2019 15:03:04 +0100 Subject: [PATCH 8/8] Rename asSecretString to toSecretString --- .../@aws-cdk/aws-secretsmanager/lib/secret.ts | 32 +++++++++---------- .../aws-secretsmanager/test/test.secret.ts | 4 +-- 2 files changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/@aws-cdk/aws-secretsmanager/lib/secret.ts b/packages/@aws-cdk/aws-secretsmanager/lib/secret.ts index f8f2cf1833103..5d9ade85f9193 100644 --- a/packages/@aws-cdk/aws-secretsmanager/lib/secret.ts +++ b/packages/@aws-cdk/aws-secretsmanager/lib/secret.ts @@ -23,7 +23,7 @@ export interface ISecret extends cdk.IConstruct { * Returns a SecretString corresponding to this secret, so that the secret value can be referred to from other parts * of the application (such as an RDS instance's master user password property). */ - asSecretString(): SecretString; + toSecretString(): SecretString; /** * Exports this secret. @@ -101,17 +101,12 @@ export abstract class SecretBase extends cdk.Construct implements ISecret { public abstract export(): SecretImportProps; - public asSecretString() { - this.secretString = this.secretString || new SecretString(this, 'SecretString', { secretId: this.secretArn }); - return this.secretString; - } - public grantRead(grantee: iam.IPrincipal, versionStages?: string[]): void { // @see https://docs.aws.amazon.com/fr_fr/secretsmanager/latest/userguide/auth-and-access_identity-based-policies.html const statement = new iam.PolicyStatement() - .allow() - .addAction('secretsmanager:GetSecretValue') - .addResource(this.secretArn); + .allow() + .addAction('secretsmanager:GetSecretValue') + .addResource(this.secretArn); if (versionStages != null) { statement.addCondition('ForAnyValue:StringEquals', { 'secretsmanager:VersionStage': versionStages @@ -122,15 +117,20 @@ export abstract class SecretBase extends cdk.Construct implements ISecret { if (this.encryptionKey) { // @see https://docs.aws.amazon.com/fr_fr/kms/latest/developerguide/services-secrets-manager.html this.encryptionKey.addToResourcePolicy(new iam.PolicyStatement() - .allow() - .addPrincipal(grantee.principal) - .addAction('kms:Decrypt') - .addAllResources() - .addCondition('StringEquals', { - 'kms:ViaService': `secretsmanager.${cdk.Stack.find(this).region}.amazonaws.com` - })); + .allow() + .addPrincipal(grantee.principal) + .addAction('kms:Decrypt') + .addAllResources() + .addCondition('StringEquals', { + 'kms:ViaService': `secretsmanager.${cdk.Stack.find(this).region}.amazonaws.com` + })); } } + + public toSecretString() { + this.secretString = this.secretString || new SecretString(this, 'SecretString', { secretId: this.secretArn }); + return this.secretString; + } } /** diff --git a/packages/@aws-cdk/aws-secretsmanager/test/test.secret.ts b/packages/@aws-cdk/aws-secretsmanager/test/test.secret.ts index 91127ec15ea11..5e7695295845c 100644 --- a/packages/@aws-cdk/aws-secretsmanager/test/test.secret.ts +++ b/packages/@aws-cdk/aws-secretsmanager/test/test.secret.ts @@ -214,7 +214,7 @@ export = { test.done(); }, - 'asSecretString'(test: Test) { + 'toSecretString'(test: Test) { // GIVEN const stack = new cdk.Stack(); const key = new kms.EncryptionKey(stack, 'KMS'); @@ -224,7 +224,7 @@ export = { new cdk.Resource(stack, 'FakeResource', { type: 'CDK::Phony::Resource', properties: { - value: secret.asSecretString().value + value: secret.toSecretString().value } });