From a8e8ed37379c5bbaeeb13a773d5438ea5e5b2fec Mon Sep 17 00:00:00 2001 From: Nick Lynch Date: Mon, 14 Sep 2020 11:04:32 +0100 Subject: [PATCH] feat(secretsmanager): import secrets by name (#10309) Adds the ability to import secrets by name, including without the SecretsManager assigned suffix. As long as a secret with the same name has been created in each region with the same name, this allows for the same `fromSecretName` usage in stacks across regions. Oddly enough, most CloudFormation templates that take references to secrets accept either the full-form ARN, including the suffix or just the base secret name (not in ARN format). The one place where a full ARN format is needed is in IAM policy statements, where the wildcard is necessary to account for the suffix. Tested this manually against an existing secret with a CodeBuild project; per the CloudFormation docs, this should work equally well with other SecretsManager-integrated services. fixes #7444 fixes #7949 fixes #7994 --- packages/@aws-cdk/aws-docdb/package.json | 5 + packages/@aws-cdk/aws-rds/package.json | 1 + packages/@aws-cdk/aws-redshift/package.json | 1 + .../@aws-cdk/aws-secretsmanager/README.md | 40 ++++++- .../@aws-cdk/aws-secretsmanager/lib/secret.ts | 76 +++++++++++-- .../@aws-cdk/aws-secretsmanager/package.json | 1 + .../aws-secretsmanager/test/test.secret.ts | 106 +++++++++++++++++- 7 files changed, 212 insertions(+), 18 deletions(-) diff --git a/packages/@aws-cdk/aws-docdb/package.json b/packages/@aws-cdk/aws-docdb/package.json index d9e171c65b471..2732be6d944da 100644 --- a/packages/@aws-cdk/aws-docdb/package.json +++ b/packages/@aws-cdk/aws-docdb/package.json @@ -91,6 +91,11 @@ "engines": { "node": ">= 10.13.0 <13 || >=13.7.0" }, + "awslint": { + "exclude": [ + "attribute-tag:@aws-cdk/aws-docdb.DatabaseSecret.secretName" + ] + }, "stability": "experimental", "maturity": "experimental", "awscdkio": { diff --git a/packages/@aws-cdk/aws-rds/package.json b/packages/@aws-cdk/aws-rds/package.json index 90a92079eb6e5..9ad9b40911c7c 100644 --- a/packages/@aws-cdk/aws-rds/package.json +++ b/packages/@aws-cdk/aws-rds/package.json @@ -103,6 +103,7 @@ }, "awslint": { "exclude": [ + "attribute-tag:@aws-cdk/aws-rds.DatabaseSecret.secretName", "props-physical-name:@aws-cdk/aws-rds.ParameterGroupProps", "props-physical-name:@aws-cdk/aws-rds.DatabaseClusterProps", "props-physical-name:@aws-cdk/aws-rds.DatabaseClusterFromSnapshotProps", diff --git a/packages/@aws-cdk/aws-redshift/package.json b/packages/@aws-cdk/aws-redshift/package.json index 160997e58866b..7c5bcf4a66b2a 100644 --- a/packages/@aws-cdk/aws-redshift/package.json +++ b/packages/@aws-cdk/aws-redshift/package.json @@ -94,6 +94,7 @@ }, "awslint": { "exclude": [ + "attribute-tag:@aws-cdk/aws-redshift.DatabaseSecret.secretName", "docs-public-apis:@aws-cdk/aws-redshift.ParameterGroupParameters.parameterName", "docs-public-apis:@aws-cdk/aws-redshift.ParameterGroupParameters.parameterValue", "props-physical-name:@aws-cdk/aws-redshift.ClusterParameterGroupProps", diff --git a/packages/@aws-cdk/aws-secretsmanager/README.md b/packages/@aws-cdk/aws-secretsmanager/README.md index e8a511ecef269..8b50ab77cf991 100644 --- a/packages/@aws-cdk/aws-secretsmanager/README.md +++ b/packages/@aws-cdk/aws-secretsmanager/README.md @@ -1,4 +1,5 @@ ## AWS Secrets Manager Construct Library + --- @@ -14,6 +15,7 @@ import * as secretsmanager from '@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: @@ -55,18 +57,22 @@ secret.grantWrite(role); ``` If, as in the following example, your secret was created with a KMS key: + ```ts const key = new kms.Key(stack, 'KMS'); const secret = new secretsmanager.Secret(stack, 'Secret', { encryptionKey: key }); secret.grantRead(role); secret.grantWrite(role); ``` + then `Secret.grantRead` and `Secret.grantWrite` will also grant the role the relevant encrypt and decrypt permissions to the KMS key through the SecretsManager service principal. ### Rotating a Secret with a custom Lambda function + A rotation schedule can be added to a Secret using a custom Lambda function: + ```ts const fn = new lambda.Function(...); const secret = new secretsmanager.Secret(this, 'Secret'); @@ -76,13 +82,16 @@ secret.addRotationSchedule('RotationSchedule', { automaticallyAfter: Duration.days(15) }); ``` + See [Overview of the Lambda Rotation Function](https://docs.aws.amazon.com/secretsmanager/latest/userguide/rotating-secrets-lambda-function-overview.html) on how to implement a Lambda Rotation Function. ### Rotating database credentials + Define a `SecretRotation` to rotate database credentials: + ```ts -new SecretRotation(this, 'SecretRotation', { - application: SecretRotationApplication.MYSQL_ROTATION_SINGLE_USER, // MySQL single user scheme +new secretsmanager.SecretRotation(this, 'SecretRotation', { + application: secretsmanager.SecretRotationApplication.MYSQL_ROTATION_SINGLE_USER, // MySQL single user scheme secret: mySecret, target: myDatabase, // a Connectable vpc: myVpc, // The VPC where the secret rotation application will be deployed @@ -91,6 +100,7 @@ new SecretRotation(this, 'SecretRotation', { ``` The secret must be a JSON string with the following format: + ```json { "engine": "", @@ -104,9 +114,10 @@ The secret must be a JSON string with the following format: ``` For the multi user scheme, a `masterSecret` must be specified: + ```ts -new SecretRotation(stack, 'SecretRotation', { - application: SecretRotationApplication.MYSQL_ROTATION_MULTI_USER, +new secretsmanager.SecretRotation(stack, 'SecretRotation', { + application: secretsmanager.SecretRotationApplication.MYSQL_ROTATION_MULTI_USER, secret: myUserSecret, // The secret that will be rotated masterSecret: myMasterSecret, // The secret used for the rotation target: myDatabase, @@ -116,3 +127,24 @@ new SecretRotation(stack, 'SecretRotation', { See also [aws-rds](https://github.com/aws/aws-cdk/blob/master/packages/%40aws-cdk/aws-rds/README.md) where credentials generation and rotation is integrated. + +### Importing Secrets + +Existing secrets can be imported by ARN, name, and other attributes (including the KMS key used to encrypt the secret). +Secrets imported by name can used the short-form of the name (without the SecretsManager-provided suffx); +the secret name must exist in the same account and region as the stack. +Importing by name makes it easier to reference secrets created in different regions, each with their own suffix and ARN. + +```ts +import * as kms from '@aws-cdk/aws-kms'; + +const secretArn = 'arn:aws:secretsmanager:eu-west-1:111111111111:secret:MySecret-f3gDy9'; +const encryptionKey = kms.Key.fromKeyArn(stack, 'MyEncKey', 'arn:aws:kms:eu-west-1:111111111111:key/21c4b39b-fde2-4273-9ac0-d9bb5c0d0030'); +const mySecretFromArn = secretsmanager.Secret.fromSecretArn(stack, 'SecretFromArn', secretArn); +const mySecretFromName = secretsmanager.Secret.fromSecretName(stack, 'SecretFromName', 'MySecret') // Note: the -f3gDy9 suffix is optional +const mySecretFromAttrs = secretsmanager.Secret.fromSecretAttributes(stack, 'SecretFromAttributes', { + secretArn, + encryptionKey, + secretName: 'MySecret', // Optional, will be calculated from the ARN +}); +``` diff --git a/packages/@aws-cdk/aws-secretsmanager/lib/secret.ts b/packages/@aws-cdk/aws-secretsmanager/lib/secret.ts index 1a256fb66aefd..6399a55ed5988 100644 --- a/packages/@aws-cdk/aws-secretsmanager/lib/secret.ts +++ b/packages/@aws-cdk/aws-secretsmanager/lib/secret.ts @@ -1,6 +1,6 @@ import * as iam from '@aws-cdk/aws-iam'; import * as kms from '@aws-cdk/aws-kms'; -import { Construct, IResource, RemovalPolicy, Resource, SecretValue, Stack } from '@aws-cdk/core'; +import { Construct, IConstruct, IResource, RemovalPolicy, Resource, SecretValue, Stack } from '@aws-cdk/core'; import { ResourcePolicy } from './policy'; import { RotationSchedule, RotationScheduleOptions } from './rotation-schedule'; import * as secretsmanager from './secretsmanager.generated'; @@ -21,6 +21,11 @@ export interface ISecret extends IResource { */ readonly secretArn: string; + /** + * The name of the secret + */ + readonly secretName: string; + /** * Retrieve the value of the stored secret as a `SecretValue`. * @attribute @@ -124,6 +129,13 @@ export interface SecretAttributes { * The ARN of the secret in SecretsManager. */ readonly secretArn: string; + + /** + * The name of the secret in SecretsManager. + * + * @default - the name is derived from the secretArn. + */ + readonly secretName?: string; } /** @@ -132,18 +144,19 @@ export interface SecretAttributes { abstract class SecretBase extends Resource implements ISecret { public abstract readonly encryptionKey?: kms.IKey; public abstract readonly secretArn: string; + public abstract readonly secretName: string; protected abstract readonly autoCreatePolicy: boolean; private policy?: ResourcePolicy; public grantRead(grantee: iam.IGrantable, versionStages?: string[]): iam.Grant { - // @see https://docs.aws.amazon.com/fr_fr/secretsmanager/latest/userguide/auth-and-access_identity-based-policies.html + // @see https://docs.aws.amazon.com/secretsmanager/latest/userguide/auth-and-access_identity-based-policies.html const result = iam.Grant.addToPrincipal({ grantee, actions: ['secretsmanager:GetSecretValue', 'secretsmanager:DescribeSecret'], - resourceArns: [this.secretArn], + resourceArns: [this.arnForPolicies], scope: this, }); if (versionStages != null && result.principalStatement) { @@ -153,7 +166,7 @@ abstract class SecretBase extends Resource implements ISecret { } if (this.encryptionKey) { - // @see https://docs.aws.amazon.com/fr_fr/kms/latest/developerguide/services-secrets-manager.html + // @see https://docs.aws.amazon.com/kms/latest/developerguide/services-secrets-manager.html this.encryptionKey.grantDecrypt( new kms.ViaServicePrincipal(`secretsmanager.${Stack.of(this).region}.amazonaws.com`, grantee.grantPrincipal), ); @@ -167,7 +180,7 @@ abstract class SecretBase extends Resource implements ISecret { const result = iam.Grant.addToPrincipal({ grantee, actions: ['secretsmanager:PutSecretValue', 'secretsmanager:UpdateSecret'], - resourceArns: [this.secretArn], + resourceArns: [this.arnForPolicies], scope: this, }); @@ -222,6 +235,12 @@ abstract class SecretBase extends Resource implements ISecret { principals: [new iam.AccountRootPrincipal()], })); } + + /** + * Provides an identifier for this secret for use in IAM policies. Typically, this is just the secret ARN. + * However, secrets imported by name require a different format. + */ + protected get arnForPolicies() { return this.secretArn; } } /** @@ -233,6 +252,29 @@ export class Secret extends SecretBase { return Secret.fromSecretAttributes(scope, id, { secretArn }); } + /** + * Imports a secret by secret name; the ARN of the Secret will be set to the secret name. + * A secret with this name must exist in the same account & region. + */ + public static fromSecretName(scope: Construct, id: string, secretName: string): ISecret { + return new class extends SecretBase { + public readonly encryptionKey = undefined; + public readonly secretArn = secretName; + public readonly secretName = secretName; + protected readonly autoCreatePolicy = false; + // Overrides the secretArn for grant* methods, where the secretArn must be in ARN format. + // Also adds a wildcard to the resource name to support the SecretsManager-provided suffix. + protected get arnForPolicies() { + return Stack.of(this).formatArn({ + service: 'secretsmanager', + resource: 'secret', + resourceName: this.secretName + '*', + sep: ':', + }); + } + }(scope, id); + } + /** * Import an existing secret into the Stack. * @@ -244,6 +286,7 @@ export class Secret extends SecretBase { class Import extends SecretBase { public readonly encryptionKey = attrs.encryptionKey; public readonly secretArn = attrs.secretArn; + public readonly secretName = parseSecretName(scope, attrs.secretArn, attrs.secretName); protected readonly autoCreatePolicy = false; } @@ -252,6 +295,7 @@ export class Secret extends SecretBase { public readonly encryptionKey?: kms.IKey; public readonly secretArn: string; + public readonly secretName: string; protected readonly autoCreatePolicy = true; @@ -285,12 +329,13 @@ export class Secret extends SecretBase { }); this.encryptionKey = props.encryptionKey; + this.secretName = this.physicalName; // @see https://docs.aws.amazon.com/kms/latest/developerguide/services-secrets-manager.html#asm-authz - const principle = + const principal = new kms.ViaServicePrincipal(`secretsmanager.${Stack.of(this).region}.amazonaws.com`, new iam.AccountPrincipal(Stack.of(this).account)); - this.encryptionKey?.grantEncryptDecrypt(principle); - this.encryptionKey?.grant(principle, 'kms:CreateGrant', 'kms:DescribeKey'); + this.encryptionKey?.grantEncryptDecrypt(principal); + this.encryptionKey?.grant(principal, 'kms:CreateGrant', 'kms:DescribeKey'); } /** @@ -443,6 +488,7 @@ export class SecretTargetAttachment extends SecretBase implements ISecretTargetA public encryptionKey?: kms.IKey | undefined; public secretArn = secretTargetAttachmentSecretArn; public secretTargetAttachmentSecretArn = secretTargetAttachmentSecretArn; + public secretName = parseSecretName(scope, secretTargetAttachmentSecretArn); protected readonly autoCreatePolicy = false; } @@ -451,6 +497,7 @@ export class SecretTargetAttachment extends SecretBase implements ISecretTargetA public readonly encryptionKey?: kms.IKey; public readonly secretArn: string; + public readonly secretName: string; /** * @attribute @@ -469,6 +516,7 @@ export class SecretTargetAttachment extends SecretBase implements ISecretTargetA }); this.encryptionKey = props.secret.encryptionKey; + this.secretName = props.secret.secretName; // This allows to reference the secret after attachment (dependency). this.secretArn = attachment.ref; @@ -552,3 +600,15 @@ export interface SecretStringGenerator { */ readonly generateStringKey?: string; } + +/** Returns the secret name if defined, otherwise attempts to parse it from the ARN. */ +export function parseSecretName(construct: IConstruct, secretArn: string, secretName?: string) { + if (secretName) { return secretName; } + const resourceName = Stack.of(construct).parseArn(secretArn).resourceName; + if (resourceName) { + // Secret resource names are in the format `${secretName}-${SecretsManager suffix}` + const secretNameFromArn = resourceName.substr(0, resourceName.lastIndexOf('-')); + if (secretNameFromArn) { return secretNameFromArn; } + } + throw new Error('invalid ARN format; no secret name provided'); +} diff --git a/packages/@aws-cdk/aws-secretsmanager/package.json b/packages/@aws-cdk/aws-secretsmanager/package.json index 9e20c99d815bc..2cb9dd1a7c84d 100644 --- a/packages/@aws-cdk/aws-secretsmanager/package.json +++ b/packages/@aws-cdk/aws-secretsmanager/package.json @@ -95,6 +95,7 @@ }, "awslint": { "exclude": [ + "attribute-tag:@aws-cdk/aws-secretsmanager.Secret.secretName", "from-signature:@aws-cdk/aws-secretsmanager.SecretTargetAttachment.fromSecretTargetAttachmentSecretArn", "from-attributes:fromSecretTargetAttachmentAttributes", "props-physical-name:@aws-cdk/aws-secretsmanager.RotationScheduleProps", diff --git a/packages/@aws-cdk/aws-secretsmanager/test/test.secret.ts b/packages/@aws-cdk/aws-secretsmanager/test/test.secret.ts index 0a295747e7e5f..1b7c1e3063ed9 100644 --- a/packages/@aws-cdk/aws-secretsmanager/test/test.secret.ts +++ b/packages/@aws-cdk/aws-secretsmanager/test/test.secret.ts @@ -453,11 +453,40 @@ export = { test.done(); }, - 'import'(test: Test) { + 'import by secretArn'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const secretArn = 'arn:aws:secretsmanager:eu-west-1:111111111111:secret:MySecret-f3gDy9'; + + // WHEN + const secret = secretsmanager.Secret.fromSecretArn(stack, 'Secret', secretArn); + + // THEN + test.equals(secret.secretArn, secretArn); + test.equals(secret.secretName, 'MySecret'); + test.same(secret.encryptionKey, undefined); + test.deepEqual(stack.resolve(secret.secretValue), `{{resolve:secretsmanager:${secretArn}:SecretString:::}}`); + test.deepEqual(stack.resolve(secret.secretValueFromJson('password')), `{{resolve:secretsmanager:${secretArn}:SecretString:password::}}`); + test.done(); + }, + + 'import by secretArn throws if ARN is malformed'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const arnWithoutResourceName = 'arn:aws:secretsmanager:eu-west-1:111111111111:secret'; + const arnWithoutSecretsManagerSuffix = 'arn:aws:secretsmanager:eu-west-1:111111111111:secret:MySecret'; + + // WHEN + test.throws(() => secretsmanager.Secret.fromSecretArn(stack, 'Secret1', arnWithoutResourceName), /invalid ARN format/); + test.throws(() => secretsmanager.Secret.fromSecretArn(stack, 'Secret2', arnWithoutSecretsManagerSuffix), /invalid ARN format/); + test.done(); + }, + + 'import by attributes'(test: Test) { // GIVEN const stack = new cdk.Stack(); const encryptionKey = new kms.Key(stack, 'KMS'); - const secretArn = 'arn::of::a::secret'; + const secretArn = 'arn:aws:secretsmanager:eu-west-1:111111111111:secret:MySecret-f3gDy9'; // WHEN const secret = secretsmanager.Secret.fromSecretAttributes(stack, 'Secret', { @@ -466,9 +495,73 @@ export = { // THEN test.equals(secret.secretArn, secretArn); + test.equals(secret.secretName, 'MySecret'); test.same(secret.encryptionKey, encryptionKey); - test.deepEqual(stack.resolve(secret.secretValue), '{{resolve:secretsmanager:arn::of::a::secret:SecretString:::}}'); - test.deepEqual(stack.resolve(secret.secretValueFromJson('password')), '{{resolve:secretsmanager:arn::of::a::secret:SecretString:password::}}'); + test.deepEqual(stack.resolve(secret.secretValue), `{{resolve:secretsmanager:${secretArn}:SecretString:::}}`); + test.deepEqual(stack.resolve(secret.secretValueFromJson('password')), `{{resolve:secretsmanager:${secretArn}:SecretString:password::}}`); + test.done(); + }, + + 'import by secret name'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const secretName = 'MySecret'; + + // WHEN + const secret = secretsmanager.Secret.fromSecretName(stack, 'Secret', secretName); + + // THEN + test.equals(secret.secretArn, secretName); + test.equals(secret.secretName, secretName); + test.deepEqual(stack.resolve(secret.secretValue), `{{resolve:secretsmanager:${secretName}:SecretString:::}}`); + test.deepEqual(stack.resolve(secret.secretValueFromJson('password')), `{{resolve:secretsmanager:${secretName}:SecretString:password::}}`); + test.done(); + }, + + 'import by secret name with grants'(test: Test) { + // GIVEN + const stack = new cdk.Stack(); + const role = new iam.Role(stack, 'Role', { assumedBy: new iam.AccountRootPrincipal() }); + const secret = secretsmanager.Secret.fromSecretName(stack, 'Secret', 'MySecret'); + + // WHEN + secret.grantRead(role); + secret.grantWrite(role); + + // THEN + const expectedSecretReference = { + 'Fn::Join': ['', [ + 'arn:', + { Ref: 'AWS::Partition' }, + ':secretsmanager:', + { Ref: 'AWS::Region' }, + ':', + { Ref: 'AWS::AccountId' }, + ':secret:MySecret*', + ]], + }; + expect(stack).to(haveResource('AWS::IAM::Policy', { + PolicyDocument: { + Version: '2012-10-17', + Statement: [{ + Action: [ + 'secretsmanager:GetSecretValue', + 'secretsmanager:DescribeSecret', + ], + Effect: 'Allow', + Resource: expectedSecretReference, + }, + { + Action: [ + 'secretsmanager:PutSecretValue', + 'secretsmanager:UpdateSecret', + ], + Effect: 'Allow', + Resource: expectedSecretReference, + }], + }, + })); + test.done(); }, @@ -577,10 +670,11 @@ export = { 'equivalence of SecretValue and Secret.fromSecretAttributes'(test: Test) { // GIVEN const stack = new cdk.Stack(); + const secretArn = 'arn:aws:secretsmanager:eu-west-1:111111111111:secret:MySecret-f3gDy9'; // WHEN - const imported = secretsmanager.Secret.fromSecretAttributes(stack, 'Imported', { secretArn: 'my-secret-arn' }).secretValueFromJson('password'); - const value = cdk.SecretValue.secretsManager('my-secret-arn', { jsonField: 'password' }); + const imported = secretsmanager.Secret.fromSecretAttributes(stack, 'Imported', { secretArn: secretArn }).secretValueFromJson('password'); + const value = cdk.SecretValue.secretsManager(secretArn, { jsonField: 'password' }); // THEN test.deepEqual(stack.resolve(imported), stack.resolve(value));