From 5f0eff291b2cac6f2fbddfbe84d06f3a92f70c1d Mon Sep 17 00:00:00 2001 From: Cory Hall <43035978+corymhall@users.noreply.github.com> Date: Wed, 13 Jul 2022 13:30:05 -0400 Subject: [PATCH] feat(secretsmanager): create secret with secretObjectValue (#21091) A common use case is to create key/value secrets where the values could be either strings _or_ other secret values. Currently this is possible, but the user experience is not great. This PR introduces a new input prop `secretObjectValue` which is of type `{ [key: string]: SecretValue }`. For example, you can now create a JSON secret: ```ts new secretsmanager.Secret(stack, 'JSONSecret', { secretObjectValue: { username: SecretValue.unsafePlainText(user.userName), // intrinsic reference, not exposed as plaintext database: SecretValue.unsafePlainText('foo'), // rendered as plain text, but not a secret password: accessKey.secretAccessKey, // SecretValue }, }); ``` I've also updated the docs to better reflect what `unsafe` means given this new context. fixes #20461 ---- ### All Submissions: * [x] Have you followed the guidelines in our [Contributing guide?](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md) ### Adding new Unconventional Dependencies: * [ ] This PR adds new unconventional dependencies following the process described [here](https://github.com/aws/aws-cdk/blob/main/CONTRIBUTING.md/#adding-new-unconventional-dependencies) ### New Features * [x] Have you added the new feature to an [integration test](https://github.com/aws/aws-cdk/blob/main/INTEGRATION_TESTS.md)? * [x] Did you use `yarn integ` to deploy the infrastructure and generate the snapshot (i.e. `yarn integ` without `--dry-run`)? *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license* --- .../@aws-cdk/aws-secretsmanager/README.md | 33 ++++++++ .../@aws-cdk/aws-secretsmanager/lib/secret.ts | 58 ++++++++++++-- .../rosetta/default.ts-fixture | 4 +- .../test/integ.secret.lit.ts | 11 +++ .../Integ-SecretsManager-Secret.assets.json | 19 ----- .../Integ-SecretsManager-Secret.template.json | 26 ++++++ ...aultTestDeployAssert519F6A06.template.json | 1 + .../test/secret.lit.integ.snapshot/cdk.out | 2 +- .../test/secret.lit.integ.snapshot/integ.json | 11 +-- .../secret.lit.integ.snapshot/manifest.json | 17 +++- .../test/secret.lit.integ.snapshot/tree.json | 80 ++++++++++++++++++- .../aws-secretsmanager/test/secret.test.ts | 54 +++++++++++++ packages/@aws-cdk/core/lib/secret-value.ts | 15 +++- 13 files changed, 291 insertions(+), 40 deletions(-) delete mode 100644 packages/@aws-cdk/aws-secretsmanager/test/secret.lit.integ.snapshot/Integ-SecretsManager-Secret.assets.json create mode 100644 packages/@aws-cdk/aws-secretsmanager/test/secret.lit.integ.snapshot/SecretTestDefaultTestDeployAssert519F6A06.template.json diff --git a/packages/@aws-cdk/aws-secretsmanager/README.md b/packages/@aws-cdk/aws-secretsmanager/README.md index 780bdc8e022e6..3b99e340cf970 100644 --- a/packages/@aws-cdk/aws-secretsmanager/README.md +++ b/packages/@aws-cdk/aws-secretsmanager/README.md @@ -252,3 +252,36 @@ Alternatively, use `addReplicaRegion()`: const secret = new secretsmanager.Secret(this, 'Secret'); secret.addReplicaRegion('eu-west-1'); ``` + +## Creating JSON Secrets + +Sometimes it is necessary to create a secret in SecretsManager that contains a JSON object. +For example: + +```json +{ + "username": "myUsername", + "database": "foo", + "password": "mypassword" +} +``` + +In order to create this type of secret, use the `secretObjectValue` input prop. + +```ts +const user = new iam.User(stack, 'User'); +const accessKey = new iam.AccessKey(stack, 'AccessKey', { user }); +declare const stack: Stack; + +new secretsmanager.Secret(stack, 'Secret', { + secretObjectValue: { + username: SecretValue.unsafePlainText(user.userName), + database: SecretValue.unsafePlainText('foo'), + password: accessKey.secretAccessKey, + }, +}) +``` + +In this case both the `username` and `database` are not a `Secret` so `SecretValue.unsafePlainText` needs to be used. +This means that they will be rendered as plain text in the template, but in this case neither of those +are actual "secrets". diff --git a/packages/@aws-cdk/aws-secretsmanager/lib/secret.ts b/packages/@aws-cdk/aws-secretsmanager/lib/secret.ts index 7865b29c2c4bd..c7426329f61ce 100644 --- a/packages/@aws-cdk/aws-secretsmanager/lib/secret.ts +++ b/packages/@aws-cdk/aws-secretsmanager/lib/secret.ts @@ -161,14 +161,44 @@ export interface SecretProps { * to the CloudFormation template (via the AWS Console, SDKs, or CLI). * * Specifies text data that you want to encrypt and store in this new version of the secret. - * May be a simple string value, or a string representation of a JSON structure. + * May be a simple string value. To provide a string representation of JSON structure, use {@link SecretProps.secretObjectValue} instead. * - * Only one of `secretStringBeta1`, `secretStringValue`, and `generateSecretString` can be provided. + * Only one of `secretStringBeta1`, `secretStringValue`, 'secretObjectValue', and `generateSecretString` can be provided. * * @default - SecretsManager generates a new secret value. */ readonly secretStringValue?: SecretValue; + /** + * Initial value for a JSON secret + * + * **NOTE:** *It is **highly** encouraged to leave this field undefined and allow SecretsManager to create the secret value. + * The secret object -- if provided -- will be included in the output of the cdk as part of synthesis, + * and will appear in the CloudFormation template in the console. This can be secure(-ish) if that value is merely reference to + * another resource (or one of its attributes), but if the value is a plaintext string, it will be visible to anyone with access + * to the CloudFormation template (via the AWS Console, SDKs, or CLI). + * + * Specifies a JSON object that you want to encrypt and store in this new version of the secret. + * To specify a simple string value instead, use {@link SecretProps.secretStringValue} + * + * Only one of `secretStringBeta1`, `secretStringValue`, 'secretObjectValue', and `generateSecretString` can be provided. + * + * @example + * declare const user: iam.User; + * declare const accessKey: iam.AccessKey; + * declare const stack: Stack; + * new secretsmanager.Secret(stack, 'JSONSecret', { + * secretObjectValue: { + * username: SecretValue.unsafePlainText(user.userName), // intrinsic reference, not exposed as plaintext + * database: SecretValue.unsafePlainText('foo'), // rendered as plain text, but not a secret + * password: accessKey.secretAccessKey, // SecretValue + * }, + * }); + * + * @default - SecretsManager generates a new secret value. + */ + readonly secretObjectValue?: { [key: string]: SecretValue }; + /** * Policy to apply when the secret is removed from this stack. * @@ -233,7 +263,7 @@ export class SecretStringValueBeta1 { * // Creates a new IAM user, access and secret keys, and stores the secret access key in a Secret. * const user = new iam.User(this, 'User'); * const accessKey = new iam.AccessKey(this, 'AccessKey', { user }); - * const secret = new secrets.Secret(this, 'Secret', { + * const secret = new secretsmanager.Secret(this, 'Secret', { * secretStringValue: accessKey.secretAccessKey, * }); * ``` @@ -582,11 +612,17 @@ export class Secret extends SecretBase { throw new Error('`secretStringTemplate` and `generateStringKey` must be specified together.'); } - if ((props.generateSecretString ? 1 : 0) + (props.secretStringBeta1 ? 1 : 0) + (props.secretStringValue ? 1 : 0) > 1) { - throw new Error('Cannot specify more than one of `generateSecretString`, `secretStringValue`, and `secretStringBeta1`.'); + if ((props.generateSecretString ? 1 : 0) + + (props.secretStringBeta1 ? 1 : 0) + + (props.secretStringValue ? 1 : 0) + + (props.secretObjectValue ? 1 : 0) + > 1) { + throw new Error('Cannot specify more than one of `generateSecretString`, `secretStringValue`, `secretObjectValue`, and `secretStringBeta1`.'); } - const secretString = props.secretStringValue?.unsafeUnwrap() ?? props.secretStringBeta1?.secretValue(); + const secretString = props.secretObjectValue + ? this.resolveSecretObjectValue(props.secretObjectValue) + : props.secretStringValue?.unsafeUnwrap() ?? props.secretStringBeta1?.secretValue(); const resource = new secretsmanager.CfnSecret(this, 'Resource', { description: props.description, @@ -627,6 +663,14 @@ export class Secret extends SecretBase { this.excludeCharacters = props.generateSecretString?.excludeCharacters; } + private resolveSecretObjectValue(secretObject: { [key: string]: SecretValue }): string { + const resolvedObject: { [key: string]: string } = {}; + for (const [key, value] of Object.entries(secretObject)) { + resolvedObject[key] = value.unsafeUnwrap(); + } + return JSON.stringify(resolvedObject); + } + /** * Adds a target attachment to the secret. * @@ -968,4 +1012,4 @@ function attachmentTargetTypeToString(x: AttachmentTargetType): string { case AttachmentTargetType.DOCDB_DB_CLUSTER: return 'AWS::DocDB::DBCluster'; } -} \ No newline at end of file +} diff --git a/packages/@aws-cdk/aws-secretsmanager/rosetta/default.ts-fixture b/packages/@aws-cdk/aws-secretsmanager/rosetta/default.ts-fixture index e05db08905105..64fc649d2d1c2 100644 --- a/packages/@aws-cdk/aws-secretsmanager/rosetta/default.ts-fixture +++ b/packages/@aws-cdk/aws-secretsmanager/rosetta/default.ts-fixture @@ -1,6 +1,6 @@ // Fixture with packages imported, but nothing else import { Construct } from 'constructs'; -import { Duration, Stack } from '@aws-cdk/core'; +import { Duration, Stack, SecretValue } from '@aws-cdk/core'; import * as secretsmanager from '@aws-cdk/aws-secretsmanager'; import * as kms from '@aws-cdk/aws-kms'; import * as iam from '@aws-cdk/aws-iam'; @@ -12,4 +12,4 @@ class Fixture extends Stack { /// here } -} \ 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 index 968a84337c800..3c511c03ee8ca 100644 --- a/packages/@aws-cdk/aws-secretsmanager/test/integ.secret.lit.ts +++ b/packages/@aws-cdk/aws-secretsmanager/test/integ.secret.lit.ts @@ -1,5 +1,6 @@ import * as iam from '@aws-cdk/aws-iam'; import * as cdk from '@aws-cdk/core'; +import { SecretValue } from '@aws-cdk/core'; import * as secretsmanager from '../lib'; class SecretsManagerStack extends cdk.Stack { @@ -37,10 +38,20 @@ class SecretsManagerStack extends cdk.Stack { new secretsmanager.Secret(this, 'PredefinedSecret', { secretStringValue: accessKey.secretAccessKey, }); + + // JSON secret + new secretsmanager.Secret(this, 'JSONSecret', { + secretObjectValue: { + username: SecretValue.unsafePlainText(user.userName), + database: SecretValue.unsafePlainText('foo'), + password: accessKey.secretAccessKey, + }, + }); /// !hide } } const app = new cdk.App(); new SecretsManagerStack(app, 'Integ-SecretsManager-Secret'); + app.synth(); diff --git a/packages/@aws-cdk/aws-secretsmanager/test/secret.lit.integ.snapshot/Integ-SecretsManager-Secret.assets.json b/packages/@aws-cdk/aws-secretsmanager/test/secret.lit.integ.snapshot/Integ-SecretsManager-Secret.assets.json deleted file mode 100644 index af60091dfed5f..0000000000000 --- a/packages/@aws-cdk/aws-secretsmanager/test/secret.lit.integ.snapshot/Integ-SecretsManager-Secret.assets.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "version": "17.0.0", - "files": { - "693c390ede695f635dd57c39306695df3b3030b9d0a594a87198632d063d477f": { - "source": { - "path": "Integ-SecretsManager-Secret.template.json", - "packaging": "file" - }, - "destinations": { - "current_account-current_region": { - "bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}", - "objectKey": "693c390ede695f635dd57c39306695df3b3030b9d0a594a87198632d063d477f.json", - "assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}" - } - } - } - }, - "dockerImages": {} -} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-secretsmanager/test/secret.lit.integ.snapshot/Integ-SecretsManager-Secret.template.json b/packages/@aws-cdk/aws-secretsmanager/test/secret.lit.integ.snapshot/Integ-SecretsManager-Secret.template.json index fad361e1fa06c..e349aad350f9e 100644 --- a/packages/@aws-cdk/aws-secretsmanager/test/secret.lit.integ.snapshot/Integ-SecretsManager-Secret.template.json +++ b/packages/@aws-cdk/aws-secretsmanager/test/secret.lit.integ.snapshot/Integ-SecretsManager-Secret.template.json @@ -147,6 +147,32 @@ }, "UpdateReplacePolicy": "Delete", "DeletionPolicy": "Delete" + }, + "JSONSecret6FE68AEF": { + "Type": "AWS::SecretsManager::Secret", + "Properties": { + "SecretString": { + "Fn::Join": [ + "", + [ + "{\"username\":\"", + { + "Ref": "User00B015A1" + }, + "\",\"database\":\"foo\",\"password\":\"", + { + "Fn::GetAtt": [ + "AccessKeyE6B25659", + "SecretAccessKey" + ] + }, + "\"}" + ] + ] + } + }, + "UpdateReplacePolicy": "Delete", + "DeletionPolicy": "Delete" } } } \ No newline at end of file diff --git a/packages/@aws-cdk/aws-secretsmanager/test/secret.lit.integ.snapshot/SecretTestDefaultTestDeployAssert519F6A06.template.json b/packages/@aws-cdk/aws-secretsmanager/test/secret.lit.integ.snapshot/SecretTestDefaultTestDeployAssert519F6A06.template.json new file mode 100644 index 0000000000000..9e26dfeeb6e64 --- /dev/null +++ b/packages/@aws-cdk/aws-secretsmanager/test/secret.lit.integ.snapshot/SecretTestDefaultTestDeployAssert519F6A06.template.json @@ -0,0 +1 @@ +{} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-secretsmanager/test/secret.lit.integ.snapshot/cdk.out b/packages/@aws-cdk/aws-secretsmanager/test/secret.lit.integ.snapshot/cdk.out index 90bef2e09ad39..588d7b269d34f 100644 --- a/packages/@aws-cdk/aws-secretsmanager/test/secret.lit.integ.snapshot/cdk.out +++ b/packages/@aws-cdk/aws-secretsmanager/test/secret.lit.integ.snapshot/cdk.out @@ -1 +1 @@ -{"version":"17.0.0"} \ No newline at end of file +{"version":"20.0.0"} \ No newline at end of file diff --git a/packages/@aws-cdk/aws-secretsmanager/test/secret.lit.integ.snapshot/integ.json b/packages/@aws-cdk/aws-secretsmanager/test/secret.lit.integ.snapshot/integ.json index 0acf5b3adbea4..acdf25c5c415c 100644 --- a/packages/@aws-cdk/aws-secretsmanager/test/secret.lit.integ.snapshot/integ.json +++ b/packages/@aws-cdk/aws-secretsmanager/test/secret.lit.integ.snapshot/integ.json @@ -1,14 +1,11 @@ { - "version": "18.0.0", + "version": "20.0.0", "testCases": { - "aws-secretsmanager/test/integ.secret.lit": { + "SecretTest/DefaultTest": { "stacks": [ "Integ-SecretsManager-Secret" ], - "diffAssets": false, - "stackUpdateWorkflow": true + "assertionStack": "SecretTestDefaultTestDeployAssert519F6A06" } - }, - "synthContext": {}, - "enableLookups": false + } } \ No newline at end of file diff --git a/packages/@aws-cdk/aws-secretsmanager/test/secret.lit.integ.snapshot/manifest.json b/packages/@aws-cdk/aws-secretsmanager/test/secret.lit.integ.snapshot/manifest.json index adce184e5bb6a..bf5a6b403c293 100644 --- a/packages/@aws-cdk/aws-secretsmanager/test/secret.lit.integ.snapshot/manifest.json +++ b/packages/@aws-cdk/aws-secretsmanager/test/secret.lit.integ.snapshot/manifest.json @@ -1,5 +1,5 @@ { - "version": "17.0.0", + "version": "20.0.0", "artifacts": { "Tree": { "type": "cdk:tree", @@ -62,9 +62,24 @@ "type": "aws:cdk:logicalId", "data": "PredefinedSecret660AF4EC" } + ], + "/Integ-SecretsManager-Secret/JSONSecret/Resource": [ + { + "type": "aws:cdk:logicalId", + "data": "JSONSecret6FE68AEF" + } ] }, "displayName": "Integ-SecretsManager-Secret" + }, + "SecretTestDefaultTestDeployAssert519F6A06": { + "type": "aws:cloudformation:stack", + "environment": "aws://unknown-account/unknown-region", + "properties": { + "templateFile": "SecretTestDefaultTestDeployAssert519F6A06.template.json", + "validateOnSynth": false + }, + "displayName": "SecretTest/DefaultTest/DeployAssert" } } } \ No newline at end of file diff --git a/packages/@aws-cdk/aws-secretsmanager/test/secret.lit.integ.snapshot/tree.json b/packages/@aws-cdk/aws-secretsmanager/test/secret.lit.integ.snapshot/tree.json index 7cd60f1c275c6..df648bb65be44 100644 --- a/packages/@aws-cdk/aws-secretsmanager/test/secret.lit.integ.snapshot/tree.json +++ b/packages/@aws-cdk/aws-secretsmanager/test/secret.lit.integ.snapshot/tree.json @@ -9,7 +9,7 @@ "path": "Tree", "constructInfo": { "fqn": "constructs.Construct", - "version": "10.0.9" + "version": "10.1.33" } }, "Integ-SecretsManager-Secret": { @@ -301,12 +301,90 @@ "fqn": "@aws-cdk/aws-secretsmanager.Secret", "version": "0.0.0" } + }, + "JSONSecret": { + "id": "JSONSecret", + "path": "Integ-SecretsManager-Secret/JSONSecret", + "children": { + "Resource": { + "id": "Resource", + "path": "Integ-SecretsManager-Secret/JSONSecret/Resource", + "attributes": { + "aws:cdk:cloudformation:type": "AWS::SecretsManager::Secret", + "aws:cdk:cloudformation:props": { + "secretString": { + "Fn::Join": [ + "", + [ + "{\"username\":\"", + { + "Ref": "User00B015A1" + }, + "\",\"database\":\"foo\",\"password\":\"", + { + "Fn::GetAtt": [ + "AccessKeyE6B25659", + "SecretAccessKey" + ] + }, + "\"}" + ] + ] + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-secretsmanager.CfnSecret", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/aws-secretsmanager.Secret", + "version": "0.0.0" + } } }, "constructInfo": { "fqn": "@aws-cdk/core.Stack", "version": "0.0.0" } + }, + "SecretTest": { + "id": "SecretTest", + "path": "SecretTest", + "children": { + "DefaultTest": { + "id": "DefaultTest", + "path": "SecretTest/DefaultTest", + "children": { + "Default": { + "id": "Default", + "path": "SecretTest/DefaultTest/Default", + "constructInfo": { + "fqn": "constructs.Construct", + "version": "10.1.33" + } + }, + "DeployAssert": { + "id": "DeployAssert", + "path": "SecretTest/DefaultTest/DeployAssert", + "constructInfo": { + "fqn": "@aws-cdk/core.Stack", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/integ-tests.IntegTestCase", + "version": "0.0.0" + } + } + }, + "constructInfo": { + "fqn": "@aws-cdk/integ-tests.IntegTest", + "version": "0.0.0" + } } }, "constructInfo": { diff --git a/packages/@aws-cdk/aws-secretsmanager/test/secret.test.ts b/packages/@aws-cdk/aws-secretsmanager/test/secret.test.ts index 6caa06421d04e..af98a771f7ff6 100644 --- a/packages/@aws-cdk/aws-secretsmanager/test/secret.test.ts +++ b/packages/@aws-cdk/aws-secretsmanager/test/secret.test.ts @@ -1335,3 +1335,57 @@ test('with replication regions', () => { ], }); }); + +describe('secretObjectValue', () => { + test('can be used with a mixture of plain text and SecretValue', () => { + const user = new iam.User(stack, 'User'); + const accessKey = new iam.AccessKey(stack, 'MyKey', { user }); + new secretsmanager.Secret(stack, 'Secret', { + secretObjectValue: { + username: cdk.SecretValue.unsafePlainText('username'), + password: accessKey.secretAccessKey, + }, + }); + + Template.fromStack(stack).hasResourceProperties('AWS::SecretsManager::Secret', { + GenerateSecretString: Match.absent(), + SecretString: { + 'Fn::Join': [ + '', + [ + '{"username":"username","password":"', + { 'Fn::GetAtt': ['MyKey6AB29FA6', 'SecretAccessKey'] }, + '"}', + ], + ], + }, + }); + }); + + test('can be used with a mixture of plain text and SecretValue, with feature flag', () => { + const featureStack = new cdk.Stack(); + featureStack.node.setContext('@aws-cdk/core:checkSecretUsage', true); + const user = new iam.User(featureStack, 'User'); + const accessKey = new iam.AccessKey(featureStack, 'MyKey', { user }); + new secretsmanager.Secret(featureStack, 'Secret', { + secretObjectValue: { + username: cdk.SecretValue.unsafePlainText('username'), + password: accessKey.secretAccessKey, + }, + }); + + Template.fromStack(featureStack).hasResourceProperties('AWS::SecretsManager::Secret', { + GenerateSecretString: Match.absent(), + SecretString: { + 'Fn::Join': [ + '', + [ + '{"username":"username","password":"', + { 'Fn::GetAtt': ['MyKey6AB29FA6', 'SecretAccessKey'] }, + '"}', + ], + ], + }, + }); + }); +}); diff --git a/packages/@aws-cdk/core/lib/secret-value.ts b/packages/@aws-cdk/core/lib/secret-value.ts index ebbf471896cba..12e747f2592eb 100644 --- a/packages/@aws-cdk/core/lib/secret-value.ts +++ b/packages/@aws-cdk/core/lib/secret-value.ts @@ -68,7 +68,18 @@ export class SecretValue extends Intrinsic { * will be visible to anyone who has access to the CloudFormation template * (via the AWS Console, SDKs, or CLI). * - * The only reasonable use case for using this method is when you are testing. + * The primary use case for using this method is when you are testing. + * + * The other use case where this is appropriate is when constructing a JSON secret. + * For example, a JSON secret might have multiple fields where only some are actual + * secret values. + * + * @example + * declare const secret: SecretValue; + * const jsonSecret = { + * username: SecretValue.unsafePlainText('myUsername'), + * password: secret, + * }; */ public static unsafePlainText(secret: string): SecretValue { return new SecretValue(secret); @@ -254,4 +265,4 @@ Object.defineProperty(SecretValue.prototype, SECRET_VALUE_SYM, { configurable: false, enumerable: false, writable: false, -}); \ No newline at end of file +});