diff --git a/packages/@aws-cdk/aws-secretsmanager/README.md b/packages/@aws-cdk/aws-secretsmanager/README.md index e3381fc7fa43d..872c7965a5dc9 100644 --- a/packages/@aws-cdk/aws-secretsmanager/README.md +++ b/packages/@aws-cdk/aws-secretsmanager/README.md @@ -3,3 +3,22 @@ ```ts 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 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', { + 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/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..5d9ade85f9193 --- /dev/null +++ b/packages/@aws-cdk/aws-secretsmanager/lib/secret.ts @@ -0,0 +1,270 @@ +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'); + +/** + * 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 not specified, 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; + + /** + * 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). + */ + toSecretString(): SecretString; + + /** + * Exports this secret. + * + * @return import props that can be passed back to ``Secret.import``. + */ + export(): SecretImportProps; + + /** + * 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[]): void; +} + +/** + * 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; + + /** + * Configuration for how to generate a secret value. + * + * @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; + + /** + * 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; + + private secretString?: SecretString; + + 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') + .addResource(this.secretArn); + if (versionStages != null) { + statement.addCondition('ForAnyValue:StringEquals', { + 'secretsmanager:VersionStage': versionStages + }); + } + 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) + .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; + } +} + +/** + * 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); + + const resource = new secretsmanager.CfnSecret(this, 'Resource', { + description: props.description, + kmsKeyId: props.encryptionKey && props.encryptionKey.keyArn, + generateSecretString: props.generateSecretString || {}, + name: props.name, + }); + + this.encryptionKey = props.encryptionKey; + this.secretArn = resource.secretArn; + } + + public export(): SecretImportProps { + return { + encryptionKey: this.encryptionKey, + secretArn: this.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, 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/package.json b/packages/@aws-cdk/aws-secretsmanager/package.json index 6119309f10f85..11e47aa28b592 100644 --- a/packages/@aws-cdk/aws-secretsmanager/package.json +++ b/packages/@aws-cdk/aws-secretsmanager/package.json @@ -58,15 +58,20 @@ "@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", + "@aws-cdk/aws-iam": "^0.23.0", "@aws-cdk/cdk": "^0.23.0" }, "peerDependencies": { + "@aws-cdk/aws-kms": "^0.23.0", + "@aws-cdk/aws-iam": "^0.23.0", "@aws-cdk/cdk": "^0.23.0" }, "engines": { "node": ">= 8.10.0" } -} \ No newline at end of file +} 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..6000fd94003c5 --- /dev/null +++ b/packages/@aws-cdk/aws-secretsmanager/test/integ.secret.lit.ts @@ -0,0 +1,14 @@ +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'); +secret.grantRead(role); +/// !hide + +app.run(); 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..5e7695295845c --- /dev/null +++ b/packages/@aws-cdk/aws-secretsmanager/test/test.secret.ts @@ -0,0 +1,260 @@ +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'); +import { Test } from 'nodeunit'; +import secretsmanager = require('../lib'); + +export = { + 'default secret'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + + // WHEN + new secretsmanager.Secret(stack, 'Secret'); + + // THEN + expect(stack).to(haveResource('AWS::SecretsManager::Secret', { + GenerateSecretString: {} + })); + + 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 }); + const role = new iam.Role(stack, 'Role', { assumedBy: new iam.AccountRootPrincipal() }); + + // WHEN + secret.grantRead(role); + + // THEN + 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(); + }, + + '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 }); + const role = new iam.Role(stack, 'Role', { assumedBy: new iam.AccountRootPrincipal() }); + + // WHEN + secret.grantRead(role, ['FOO', 'bar']); + + // THEN + 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" + }, + ":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(); + }, + + 'toSecretString'(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.toSecretString().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,