Skip to content

Commit dd90b8e

Browse files
authored
feat(secretsmanager): create secrets with specified values (#18098)
Enables customers to supply their own secret value in the cases where an auto- generated value is not viable. The secret value is typed to highlight the inheret lack of safety with creating secret values via CloudFormation; if a plaintext secret is provided, this secret will be visible anywhere the CloudFormation template is, including the AWS Console, SDKs, and CLIs. An unsafe `fromUnsafePlaintext` method and slightly safer `fromToken` method are exposed to highlight the potential risks and hopefully encourage safe usage. The latter is intended to be used directly with a Ref or GetAtt call from another (Custom) Resource, such as storing the value of a User SecretAccessKey or storing a password generated from a custom resource. As an implementation detail, this API has been created using the new standard for experimental APIs, via suffixing with `Beta1`. This allow us to make breaking changes by deprecating the `Beta1` version and creating an improved `Beta2` version. I've chosen to do this in this case because this has been a relatively controversial feature to decide to implement, and the criteria for what makes a secret "safe" may evolve over time. I am open to feedback on whether this is necessitated. fixes #5810 ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 2290681 commit dd90b8e

File tree

5 files changed

+221
-9
lines changed

5 files changed

+221
-9
lines changed

packages/@aws-cdk/aws-secretsmanager/README.md

+20-6
Original file line numberDiff line numberDiff line change
@@ -21,12 +21,26 @@ import * as secretsmanager from '@aws-cdk/aws-secretsmanager';
2121
In order to have SecretsManager generate a new secret value automatically,
2222
you can get started with the following:
2323

24-
[example of creating a secret](test/integ.secret.lit.ts)
25-
26-
The `Secret` construct does not allow specifying the `SecretString` property
27-
of the `AWS::SecretsManager::Secret` resource (as this will almost always
28-
lead to the secret being surfaced in plain text and possibly committed to
29-
your source control).
24+
```ts
25+
// Default secret
26+
const secret = new secretsmanager.Secret(this, 'Secret');
27+
// Using the default secret
28+
new iam.User(this, 'User', {
29+
password: secret.secretValue,
30+
});
31+
// Templated secret
32+
const templatedSecret = new secretsmanager.Secret(this, 'TemplatedSecret', {
33+
generateSecretString: {
34+
secretStringTemplate: JSON.stringify({ username: 'user' }),
35+
generateStringKey: 'password',
36+
},
37+
});
38+
// Using the templated secret
39+
new iam.User(this, 'OtherUser', {
40+
userName: templatedSecret.secretValueFromJson('username').toString(),
41+
password: templatedSecret.secretValueFromJson('password'),
42+
});
43+
```
3044

3145
If you need to use a pre-existing secret, the recommended way is to manually
3246
provision the secret in *AWS SecretsManager* and use the `Secret.fromSecretArn`

packages/@aws-cdk/aws-secretsmanager/lib/secret.ts

+84-1
Original file line numberDiff line numberDiff line change
@@ -115,6 +115,8 @@ export interface SecretProps {
115115
/**
116116
* Configuration for how to generate a secret value.
117117
*
118+
* Only one of `secretString` and `generateSecretString` can be provided.
119+
*
118120
* @default - 32 characters with upper-case letters, lower-case letters, punctuation and numbers (at least one from each
119121
* category), per the default values of ``SecretStringGenerator``.
120122
*/
@@ -128,6 +130,24 @@ export interface SecretProps {
128130
*/
129131
readonly secretName?: string;
130132

133+
/**
134+
* Initial value for the secret
135+
*
136+
* **NOTE:** *It is **highly** encouraged to leave this field undefined and allow SecretsManager to create the secret value.
137+
* The secret string -- if provided -- will be included in the output of the cdk as part of synthesis,
138+
* and will appear in the CloudFormation template in the console. This can be secure(-ish) if that value is merely reference to
139+
* another resource (or one of its attributes), but if the value is a plaintext string, it will be visible to anyone with access
140+
* to the CloudFormation template (via the AWS Console, SDKs, or CLI).
141+
*
142+
* Specifies text data that you want to encrypt and store in this new version of the secret.
143+
* May be a simple string value, or a string representation of a JSON structure.
144+
*
145+
* Only one of `secretString` and `generateSecretString` can be provided.
146+
*
147+
* @default - SecretsManager generates a new secret value.
148+
*/
149+
readonly secretStringBeta1?: SecretStringValueBeta1;
150+
131151
/**
132152
* Policy to apply when the secret is removed from this stack.
133153
*
@@ -160,6 +180,64 @@ export interface ReplicaRegion {
160180
readonly encryptionKey?: kms.IKey;
161181
}
162182

183+
/**
184+
* An experimental class used to specify an initial secret value for a Secret.
185+
* The class wraps a simple string (or JSON representation) in order to provide some safety checks and warnings
186+
* about the dangers of using plaintext strings as initial secret seed values via CDK/CloudFormation.
187+
*/
188+
export class SecretStringValueBeta1 {
189+
190+
/**
191+
* Creates a `SecretStringValueBeta1` from a plaintext value.
192+
* This approach is inherently unsafe, as the secret value may be visible in your source control repository
193+
* and will also appear in plaintext in the resulting CloudFormation template, including in the AWS Console or APIs.
194+
* Usage of this method is discouraged, especially for production workloads.
195+
*/
196+
public static fromUnsafePlaintext(secretValue: string) { return new SecretStringValueBeta1(secretValue); }
197+
198+
/**
199+
* Creates a `SecretValueValueBeta1` from a string value coming from a Token.
200+
* The intent is to enable creating secrets from references (e.g., `Ref`, `Fn::GetAtt`) from other resources.
201+
* This might be the direct output of another Construct, or the output of a Custom Resource.
202+
* This method throws if it determines the input is an unsafe plaintext string.
203+
*
204+
* For example:
205+
* ```ts
206+
* // Creates a new IAM user, access and secret keys, and stores the secret access key in a Secret.
207+
* const user = new iam.User(this, 'User');
208+
* const accessKey = new iam.CfnAccessKey(this, 'AccessKey', { userName: user.userName });
209+
* const secretValue = secretsmanager.SecretStringValueBeta1.fromToken(accessKey.attrSecretAccessKey);
210+
* new secretsmanager.Secret(this, 'Secret', {
211+
* secretStringBeta1: secretValue,
212+
* });
213+
* ```
214+
*
215+
* The secret may also be embedded in a string representation of a JSON structure:
216+
* const secretValue = secretsmanager.SecretStringValueBeta1.fromToken(JSON.stringify({
217+
* username: user.userName,
218+
* database: 'foo',
219+
* password: accessKey.attrSecretAccessKey
220+
* }));
221+
*
222+
* Note that the value being a Token does *not* guarantee safety. For example, a Lazy-evaluated string
223+
* (e.g., `Lazy.string({ produce: () => 'myInsecurePassword' }))`) is a Token, but as the output is
224+
* ultimately a plaintext string, and so insecure.
225+
*
226+
* @param secretValueFromToken a secret value coming from a Construct attribute or Custom Resource output
227+
*/
228+
public static fromToken(secretValueFromToken: string) {
229+
if (!Token.isUnresolved(secretValueFromToken)) {
230+
throw new Error('SecretStringValueBeta1 appears to be plaintext (unsafe) string (or resolved Token); use fromUnsafePlaintext if this is intentional');
231+
}
232+
return new SecretStringValueBeta1(secretValueFromToken);
233+
}
234+
235+
private constructor(private readonly _secretValue: string) { }
236+
237+
/** Returns the secret value */
238+
public secretValue(): string { return this._secretValue; }
239+
}
240+
163241
/**
164242
* Attributes required to import an existing secret into the Stack.
165243
* One ARN format (`secretArn`, `secretCompleteArn`, `secretPartialArn`) must be provided.
@@ -459,10 +537,15 @@ export class Secret extends SecretBase {
459537
throw new Error('`secretStringTemplate` and `generateStringKey` must be specified together.');
460538
}
461539

540+
if (props.generateSecretString && props.secretStringBeta1) {
541+
throw new Error('Cannot specify both `generateSecretString` and `secretStringBeta1`.');
542+
}
543+
462544
const resource = new secretsmanager.CfnSecret(this, 'Resource', {
463545
description: props.description,
464546
kmsKeyId: props.encryptionKey && props.encryptionKey.keyArn,
465-
generateSecretString: props.generateSecretString || {},
547+
generateSecretString: props.generateSecretString ?? (props.secretStringBeta1 ? undefined : {}),
548+
secretString: props.secretStringBeta1?.secretValue(),
466549
name: this.physicalName,
467550
replicaRegions: Lazy.any({ produce: () => this.replicaRegions }, { omitEmptyArray: true }),
468551
});

packages/@aws-cdk/aws-secretsmanager/test/integ.secret.lit.expected.json

+21
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,27 @@
126126
]
127127
}
128128
}
129+
},
130+
"AccessKey": {
131+
"Type": "AWS::IAM::AccessKey",
132+
"Properties": {
133+
"UserName": {
134+
"Ref": "User00B015A1"
135+
}
136+
}
137+
},
138+
"PredefinedSecret660AF4EC": {
139+
"Type": "AWS::SecretsManager::Secret",
140+
"Properties": {
141+
"SecretString": {
142+
"Fn::GetAtt": [
143+
"AccessKey",
144+
"SecretAccessKey"
145+
]
146+
}
147+
},
148+
"UpdateReplacePolicy": "Delete",
149+
"DeletionPolicy": "Delete"
129150
}
130151
}
131152
}

packages/@aws-cdk/aws-secretsmanager/test/integ.secret.lit.ts

+7-1
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ class SecretsManagerStack extends cdk.Stack {
1313
const secret = new secretsmanager.Secret(this, 'Secret');
1414
secret.grantRead(role);
1515

16-
new iam.User(this, 'User', {
16+
const user = new iam.User(this, 'User', {
1717
password: secret.secretValue,
1818
});
1919

@@ -29,6 +29,12 @@ class SecretsManagerStack extends cdk.Stack {
2929
userName: templatedSecret.secretValueFromJson('username').toString(),
3030
password: templatedSecret.secretValueFromJson('password'),
3131
});
32+
33+
// Secret with predefined value
34+
const accessKey = new iam.CfnAccessKey(this, 'AccessKey', { userName: user.userName });
35+
new secretsmanager.Secret(this, 'PredefinedSecret', {
36+
secretStringBeta1: secretsmanager.SecretStringValueBeta1.fromToken(accessKey.attrSecretAccessKey),
37+
});
3238
/// !hide
3339
}
3440
}

packages/@aws-cdk/aws-secretsmanager/test/secret.test.ts

+89-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import '@aws-cdk/assert-internal/jest';
2-
import { expect as assertExpect, ResourcePart } from '@aws-cdk/assert-internal';
2+
import { ABSENT, expect as assertExpect, ResourcePart } from '@aws-cdk/assert-internal';
33
import * as iam from '@aws-cdk/aws-iam';
44
import * as kms from '@aws-cdk/aws-kms';
55
import * as lambda from '@aws-cdk/aws-lambda';
@@ -178,6 +178,94 @@ test('templated secret string', () => {
178178
});
179179
});
180180

181+
describe('secretStringBeta1', () => {
182+
let user: iam.User;
183+
let accessKey: iam.CfnAccessKey;
184+
185+
beforeEach(() => {
186+
user = new iam.User(stack, 'User');
187+
accessKey = new iam.CfnAccessKey(stack, 'MyKey', { userName: user.userName });
188+
});
189+
190+
test('fromUnsafePlaintext allows specifying a plaintext string', () => {
191+
new secretsmanager.Secret(stack, 'Secret', {
192+
secretStringBeta1: secretsmanager.SecretStringValueBeta1.fromUnsafePlaintext('unsafeP@$$'),
193+
});
194+
195+
expect(stack).toHaveResource('AWS::SecretsManager::Secret', {
196+
GenerateSecretString: ABSENT,
197+
SecretString: 'unsafeP@$$',
198+
});
199+
});
200+
201+
test('toToken throws when provided an unsafe plaintext string', () => {
202+
expect(() => new secretsmanager.Secret(stack, 'Secret', {
203+
secretStringBeta1: secretsmanager.SecretStringValueBeta1.fromToken('unsafeP@$$'),
204+
})).toThrow(/appears to be plaintext/);
205+
});
206+
207+
test('toToken allows referencing a construct attribute', () => {
208+
new secretsmanager.Secret(stack, 'Secret', {
209+
secretStringBeta1: secretsmanager.SecretStringValueBeta1.fromToken(accessKey.attrSecretAccessKey),
210+
});
211+
212+
expect(stack).toHaveResource('AWS::SecretsManager::Secret', {
213+
GenerateSecretString: ABSENT,
214+
SecretString: { 'Fn::GetAtt': ['MyKey', 'SecretAccessKey'] },
215+
});
216+
});
217+
218+
test('toToken allows referencing a construct attribute in nested JSON', () => {
219+
const secretString = secretsmanager.SecretStringValueBeta1.fromToken(JSON.stringify({
220+
key: accessKey.attrSecretAccessKey,
221+
username: 'myUser',
222+
}));
223+
new secretsmanager.Secret(stack, 'Secret', {
224+
secretStringBeta1: secretString,
225+
});
226+
227+
expect(stack).toHaveResource('AWS::SecretsManager::Secret', {
228+
GenerateSecretString: ABSENT,
229+
SecretString: {
230+
'Fn::Join': [
231+
'',
232+
[
233+
'{"key":"',
234+
{
235+
'Fn::GetAtt': [
236+
'MyKey',
237+
'SecretAccessKey',
238+
],
239+
},
240+
'","username":"myUser"}',
241+
],
242+
],
243+
},
244+
});
245+
});
246+
247+
test('toToken throws if provided a resolved token', () => {
248+
// NOTE - This is actually not desired behavior, but the simple `!Token.isUnresolved`
249+
// check is the simplest and most consistent to implement. Covering this edge case of
250+
// a resolved Token representing a Ref/Fn::GetAtt is out of scope for this initial pass.
251+
const secretKey = stack.resolve(accessKey.attrSecretAccessKey);
252+
expect(() => new secretsmanager.Secret(stack, 'Secret', {
253+
secretStringBeta1: secretsmanager.SecretStringValueBeta1.fromToken(secretKey),
254+
})).toThrow(/appears to be plaintext/);
255+
});
256+
257+
test('throws if both generateSecretString and secretStringBeta1 are provided', () => {
258+
expect(() => new secretsmanager.Secret(stack, 'Secret', {
259+
generateSecretString: {
260+
generateStringKey: 'username',
261+
secretStringTemplate: JSON.stringify({ username: 'username' }),
262+
},
263+
secretStringBeta1: secretsmanager.SecretStringValueBeta1.fromToken(accessKey.attrSecretAccessKey),
264+
})).toThrow(/Cannot specify both `generateSecretString` and `secretStringBeta1`./);
265+
});
266+
267+
});
268+
181269
test('grantRead', () => {
182270
// GIVEN
183271
const key = new kms.Key(stack, 'KMS');

0 commit comments

Comments
 (0)