diff --git a/packages/parameters/src/ssm/SSMProvider.ts b/packages/parameters/src/ssm/SSMProvider.ts index 39cc0ee569..dd45070162 100644 --- a/packages/parameters/src/ssm/SSMProvider.ts +++ b/packages/parameters/src/ssm/SSMProvider.ts @@ -144,14 +144,11 @@ class SSMProvider extends BaseProvider { options?: SSMGetOptionsInterface ): Promise { const sdkOptions: GetParameterCommandInput = { + ...(options?.sdkOptions || {}), Name: name, }; - if (options) { - if (options.hasOwnProperty('decrypt')) sdkOptions.WithDecryption = options.decrypt; - if (options.hasOwnProperty('sdkOptions')) { - Object.assign(sdkOptions, options.sdkOptions); - } - } + sdkOptions.WithDecryption = options?.decrypt !== undefined ? + options.decrypt : sdkOptions.WithDecryption; const result = await this.client.send(new GetParameterCommand(sdkOptions)); return result.Parameter?.Value; @@ -162,21 +159,18 @@ class SSMProvider extends BaseProvider { options?: SSMGetMultipleOptionsInterface ): Promise> { const sdkOptions: GetParametersByPathCommandInput = { + ...(options?.sdkOptions || {}), Path: path, }; const paginationOptions: PaginationConfiguration = { client: this.client }; - if (options) { - if (options.hasOwnProperty('decrypt')) sdkOptions.WithDecryption = options.decrypt; - if (options.hasOwnProperty('recursive')) sdkOptions.Recursive = options.recursive; - if (options.hasOwnProperty('sdkOptions')) { - Object.assign(sdkOptions, options.sdkOptions); - if (sdkOptions.MaxResults) { - paginationOptions.pageSize = sdkOptions.MaxResults; - } - } - } + sdkOptions.WithDecryption = options?.decrypt !== undefined ? + options.decrypt : sdkOptions.WithDecryption; + sdkOptions.Recursive = options?.recursive !== undefined ? + options.recursive : sdkOptions.Recursive; + paginationOptions.pageSize = sdkOptions.MaxResults !== undefined ? + sdkOptions.MaxResults : undefined; const parameters: Record = {}; for await (const page of paginateGetParametersByPath(paginationOptions, sdkOptions)) { @@ -389,13 +383,11 @@ class SSMProvider extends BaseProvider { const overrides = parameterOptions; overrides.transform = overrides.transform || configs.transform; - if (!overrides.hasOwnProperty('decrypt')) { - overrides.decrypt = configs.decrypt; - } - if (!overrides.hasOwnProperty('maxAge')) { - overrides.maxAge = configs.maxAge; - } - + overrides.decrypt = overrides.decrypt !== undefined ? + overrides.decrypt : configs.decrypt; + overrides.maxAge = overrides.maxAge !== undefined ? + overrides.maxAge : configs.maxAge; + if (overrides.decrypt) { parametersToDecrypt[parameterName] = overrides; } else { diff --git a/packages/parameters/src/ssm/index.ts b/packages/parameters/src/ssm/index.ts index 329d3d3f3b..e275be6d78 100644 --- a/packages/parameters/src/ssm/index.ts +++ b/packages/parameters/src/ssm/index.ts @@ -1,5 +1,4 @@ export * from './SSMProvider'; export * from './getParameter'; export * from './getParameters'; -export * from './getParametersByName'; -export * from '../types/SSMProvider'; \ No newline at end of file +export * from './getParametersByName'; \ No newline at end of file diff --git a/packages/parameters/tests/e2e/dynamoDBProvider.class.test.ts b/packages/parameters/tests/e2e/dynamoDBProvider.class.test.ts index ce8f7f50d4..dc8d782e3a 100644 --- a/packages/parameters/tests/e2e/dynamoDBProvider.class.test.ts +++ b/packages/parameters/tests/e2e/dynamoDBProvider.class.test.ts @@ -4,7 +4,6 @@ * @group e2e/parameters/dynamodb/class */ import path from 'path'; -import { Tracing } from 'aws-cdk-lib/aws-lambda'; import { AttributeType } from 'aws-cdk-lib/aws-dynamodb'; import { App, Stack, Aspects } from 'aws-cdk-lib'; import { DynamoDBClient, PutItemCommand } from '@aws-sdk/client-dynamodb'; @@ -138,10 +137,9 @@ describe(`parameters E2E tests (dynamoDBProvider) for runtime: ${runtime}`, () = // Create a stack with a Lambda function stack = createStackWithLambdaFunction({ app: integTestApp, - stackName: stackName, - functionName: functionName, + stackName, + functionName, functionEntry: path.join(__dirname, lambdaFunctionCodeFile), - tracing: Tracing.ACTIVE, environment: { UUID: uuid, @@ -154,7 +152,7 @@ describe(`parameters E2E tests (dynamoDBProvider) for runtime: ${runtime}`, () = SORT_ATTR: sortAttr, VALUE_ATTR: valueAttr, }, - runtime: runtime, + runtime, }); // Create the DynamoDB tables diff --git a/packages/parameters/tests/e2e/ssmProvider.class.test.functionCode.ts b/packages/parameters/tests/e2e/ssmProvider.class.test.functionCode.ts new file mode 100644 index 0000000000..4f0280f0a8 --- /dev/null +++ b/packages/parameters/tests/e2e/ssmProvider.class.test.functionCode.ts @@ -0,0 +1,183 @@ +import { Context } from 'aws-lambda'; +import { + SSMProvider, +} from '../../src/ssm'; +import { + SSMGetOptionsInterface, + SSMGetMultipleOptionsInterface, + SSMGetParametersByNameOptionsInterface +} from '../../src/types'; +import { TinyLogger } from '../helpers/tinyLogger'; +import { middleware } from '../helpers/sdkMiddlewareRequestCounter'; +import { SSMClient } from '@aws-sdk/client-ssm'; + +// We use a custom logger to log pure JSON objects to stdout +const logger = new TinyLogger(); + +const defaultProvider = new SSMProvider(); +// Provider test 8, 9 +const customClient = new SSMClient({}); +customClient.middlewareStack.use(middleware); +const providerWithMiddleware = new SSMProvider({ + awsSdkV3Client: customClient +}); + +const paramA = process.env.PARAM_A ?? 'my-param'; +const paramB = process.env.PARAM_B ?? 'my-param'; +const paramEncryptedA = process.env.PARAM_ENCRYPTED_A ?? 'my-encrypted-param'; +const paramEncryptedB = process.env.PARAM_ENCRYPTED_B ?? 'my-encrypted-param'; + +// Use provider specified, or default to main one & return it with cache cleared +const resolveProvider = (provider?: SSMProvider): SSMProvider => { + const resolvedProvider = provider ? provider : defaultProvider; + resolvedProvider.clearCache(); + + return resolvedProvider; +}; + +// Helper function to call get() and log the result +const _call_get = async ( + paramName: string, + testName: string, + options?: SSMGetOptionsInterface, + provider?: SSMProvider +): Promise => { + try { + const currentProvider = resolveProvider(provider); + + const parameterValue = await currentProvider.get(paramName, options); + logger.log({ + test: testName, + value: parameterValue + }); + } catch (err) { + logger.log({ + test: testName, + error: err.message + }); + } +}; + +// Helper function to call getMultiple() and log the result +const _call_get_multiple = async ( + paramPath: string, + testName: string, + options?: SSMGetMultipleOptionsInterface, + provider?: SSMProvider +): Promise => { + try { + const currentProvider = resolveProvider(provider); + + const parameterValues = await currentProvider.getMultiple( + paramPath, + options + ); + logger.log({ + test: testName, + value: parameterValues + }); + } catch (err) { + logger.log({ + test: testName, + error: err.message + }); + } +}; + +// Helper function to call getParametersByName() and log the result +const _call_get_parameters_by_name = async ( + params: Record, + testName: string, + options?: SSMGetParametersByNameOptionsInterface, + provider?: SSMProvider +): Promise => { + try { + const currentProvider = resolveProvider(provider); + + const parameterValues = await currentProvider.getParametersByName(params, options); + logger.log({ + test: testName, + value: parameterValues + }); + } catch (err) { + logger.log({ + test: testName, + error: err.message + }); + } +}; + +export const handler = async (_event: unknown, _context: Context): Promise => { + // Test 1 - get a single parameter by name with default options + await _call_get(paramA, 'get'); + + // Test 2 - get a single parameter by name with decrypt + await _call_get(paramEncryptedA, 'get-decrypt', { decrypt: true }); + + // Test 3 - get multiple parameters by path with default options + // Get path (/param/get) + const parameterPath = paramA.substring(0, paramA.lastIndexOf('/')); + await _call_get_multiple(parameterPath, 'get-multiple'); + + // Test 4 - get multiple parameters by path recursively (aka. get all parameters under a path recursively) + // Get parameters root (i.e. from /param/get/a & /param/get/b to /param) + const parameterRoot = paramA.substring( + 0, + paramA.substring(1, paramA.length).indexOf('/') + 1 + ); + await _call_get_multiple(parameterRoot, 'get-multiple-recursive', { recursive: true }); + + // Test 5 - get multiple parameters by path with decrypt + // Get parameters path (i.e. from /param/get/a & /param/get/b to /param/get) + const parameterPathDecrypt = paramEncryptedA.substring(0, paramEncryptedA.lastIndexOf('/')); + await _call_get_multiple(parameterPathDecrypt, 'get-multiple-decrypt', { decrypt: true }); + + // Test 6 - get multiple parameters by name with default options + await _call_get_parameters_by_name({ + [paramA]: {}, + [paramB]: {}, + }, 'get-multiple-by-name'); + + // Test 7 - get multiple parameters by name, some of them encrypted and some not + await _call_get_parameters_by_name({ + [paramA]: {}, + [paramEncryptedA]: { decrypt: true }, + [paramEncryptedB]: { decrypt: true }, + }, 'get-multiple-by-name-mixed-decrypt'); + + // Test 8 + // get parameter twice with middleware, which counts the number of requests, we check later if we only called SSM API once + try { + providerWithMiddleware.clearCache(); + middleware.counter = 0; + await providerWithMiddleware.get(paramA); + await providerWithMiddleware.get(paramA); + logger.log({ + test: 'get-cached', + value: middleware.counter // should be 1 + }); + } catch (err) { + logger.log({ + test: 'get-cached', + error: err.message + }); + } + + // Test 9 + // get parameter twice, but force fetch 2nd time, we count number of SDK requests and check that we made two API calls + try { + providerWithMiddleware.clearCache(); + middleware.counter = 0; + await providerWithMiddleware.get(paramA); + await providerWithMiddleware.get(paramA, { forceFetch: true }); + logger.log({ + test: 'get-forced', + value: middleware.counter // should be 2 + }); + } catch (err) { + logger.log({ + test: 'get-forced', + error: err.message + }); + } +}; \ No newline at end of file diff --git a/packages/parameters/tests/e2e/ssmProvider.class.test.ts b/packages/parameters/tests/e2e/ssmProvider.class.test.ts new file mode 100644 index 0000000000..51701649e5 --- /dev/null +++ b/packages/parameters/tests/e2e/ssmProvider.class.test.ts @@ -0,0 +1,335 @@ +/** + * Test SSMProvider class + * + * @group e2e/parameters/ssm/class + */ +import path from 'path'; +import { App, Stack, Aspects } from 'aws-cdk-lib'; +import { StringParameter } from 'aws-cdk-lib/aws-ssm'; +import { v4 } from 'uuid'; +import { + generateUniqueName, + isValidRuntimeKey, + createStackWithLambdaFunction, + invokeFunction, +} from '../../../commons/tests/utils/e2eUtils'; +import { InvocationLogs } from '../../../commons/tests/utils/InvocationLogs'; +import { deployStack, destroyStack } from '../../../commons/tests/utils/cdk-cli'; +import { ResourceAccessGranter } from '../helpers/cdkAspectGrantAccess'; +import { + RESOURCE_NAME_PREFIX, + SETUP_TIMEOUT, + TEARDOWN_TIMEOUT, + TEST_CASE_TIMEOUT +} from './constants'; +import { + createSecureStringProvider, + createSSMSecureString +} from '../helpers/parametersUtils'; + +const runtime: string = process.env.RUNTIME || 'nodejs18x'; + +if (!isValidRuntimeKey(runtime)) { + throw new Error(`Invalid runtime key value: ${runtime}`); +} + +const uuid = v4(); +const stackName = generateUniqueName(RESOURCE_NAME_PREFIX, uuid, runtime, 'ssmProvider'); +const functionName = generateUniqueName(RESOURCE_NAME_PREFIX, uuid, runtime, 'ssmProvider'); +const lambdaFunctionCodeFile = 'ssmProvider.class.test.functionCode.ts'; + +const invocationCount = 1; + +// Parameter names to be used by Parameters in the Lambda function +const paramA = generateUniqueName(`/${RESOURCE_NAME_PREFIX}`, uuid, runtime, 'param/a'); +const paramB = generateUniqueName(`/${RESOURCE_NAME_PREFIX}`, uuid, runtime, 'param/b'); +const paramEncryptedA = generateUniqueName(`/${RESOURCE_NAME_PREFIX}`, uuid, runtime, 'param-encrypted/a'); +const paramEncryptedB = generateUniqueName(`/${RESOURCE_NAME_PREFIX}`, uuid, runtime, 'param-encrypted/b'); + +// Parameters values +const paramAValue = 'foo'; +const paramBValue = 'bar'; +const paramEncryptedAValue = 'foo-encrypted'; +const paramEncryptedBValue = 'bar-encrypted'; + +const integTestApp = new App(); +let stack: Stack; + +/** + * This test suite deploys a CDK stack with a Lambda function and a number of SSM parameters. + * The function code uses the Parameters utility to retrieve the SSM parameters. + * It then logs the values to CloudWatch Logs as JSON objects. + * + * Once the stack is deployed, the Lambda function is invoked and the CloudWatch Logs are retrieved. + * The logs are then parsed and the values are checked against the expected values for each test case. + * + * The parameters created are: + * - Name: param/a - Value: foo + * - Name: param/b - Value: bar + * - Name: param-encrypted/a - Value: foo-encrypted + * - Name: param-encrypted/b - Value: bar-encrypted + * + * These parameters allow to retrieve one or more parameters both by name and by path, as well as + * mixing encrypted and unencrypted parameters. + * + * The tests are: + * + * Test 1 + * get a single parameter by name with default options + * + * Test 2 + * get a single parameter by name with decrypt + * + * Test 3 + * get multiple parameters by path with default options + * + * Test 4 + * get multiple parameters by path recursively (aka. get all parameters under a path recursively) + * i.e. given /param, retrieve /param/get/a and /param/get/b (note path depth) + * + * Test 5 + * get multiple parameters by path with decrypt + * + * Test 6 + * get multiple parameters by name with default options + * + * Test 7 + * get multiple parameters by name, some of them encrypted and some not + * + * Test 8 + * get parameter twice with middleware, which counts the number of requests, + * we check later if we only called SSM API once + * + * Test 9 + * get parameter twice, but force fetch 2nd time, we count number of SDK requests and + * check that we made two API calls + */ +describe(`parameters E2E tests (ssmProvider) for runtime: ${runtime}`, () => { + + let invocationLogs: InvocationLogs[]; + + beforeAll(async () => { + // Create a stack with a Lambda function + stack = createStackWithLambdaFunction({ + app: integTestApp, + stackName, + functionName, + functionEntry: path.join(__dirname, lambdaFunctionCodeFile), + environment: { + UUID: uuid, + + // Values(s) to be used by Parameters in the Lambda function + PARAM_A: paramA, + PARAM_B: paramB, + PARAM_ENCRYPTED_A: paramEncryptedA, + PARAM_ENCRYPTED_B: paramEncryptedB, + }, + runtime, + }); + + // Create Custom Resource provider: + // will be used to create some SSM parameters not supported by CDK + const provider = createSecureStringProvider({ + stack, + parametersPrefix: `${RESOURCE_NAME_PREFIX}-${runtime}-${uuid.substring(0,5)}` + }); + + // Create SSM parameters + const parameterGetA = new StringParameter(stack, 'Param-a', { + parameterName: paramA, + stringValue: paramAValue, + }); + const parameterGetB = new StringParameter(stack, 'Param-b', { + parameterName: paramB, + stringValue: paramBValue, + }); + + const parameterEncryptedA = createSSMSecureString({ + stack, + provider, + id: 'Param-encrypted-a', + name: paramEncryptedA, + value: paramEncryptedAValue, + }); + + const parameterEncryptedB = createSSMSecureString({ + stack, + provider, + id: 'Param-encrypted-b', + name: paramEncryptedB, + value: paramEncryptedBValue, + }); + + // Give the Lambda function access to the SSM parameters + Aspects.of(stack).add(new ResourceAccessGranter([ + parameterGetA, + parameterGetB, + parameterEncryptedA, + parameterEncryptedB, + ])); + + // Deploy the stack + await deployStack(integTestApp, stack); + + // and invoke the Lambda function + invocationLogs = await invokeFunction(functionName, invocationCount, 'SEQUENTIAL'); + + }, SETUP_TIMEOUT); + + describe('SSMProvider usage', () => { + + // Test 1 - get a single parameter by name with default options + it('should retrieve a single parameter', async () => { + + const logs = invocationLogs[0].getFunctionLogs(); + const testLog = InvocationLogs.parseFunctionLog(logs[0]); + + expect(testLog).toStrictEqual({ + test: 'get', + value: paramAValue + }); + + }, TEST_CASE_TIMEOUT); + + // Test 2 - get a single parameter by name with decrypt + it('should retrieve a single parameter with decryption', async () => { + + const logs = invocationLogs[0].getFunctionLogs(); + const testLog = InvocationLogs.parseFunctionLog(logs[1]); + + expect(testLog).toStrictEqual({ + test: 'get-decrypt', + value: paramEncryptedAValue + }); + + }, TEST_CASE_TIMEOUT); + + // Test 3 - get multiple parameters by path with default options + it('should retrieve multiple parameters', async () => { + + const logs = invocationLogs[0].getFunctionLogs(); + const testLog = InvocationLogs.parseFunctionLog(logs[2]); + const expectedParameterNameA = paramA.substring(paramA.lastIndexOf('/') + 1); + const expectedParameterNameB = paramB.substring(paramB.lastIndexOf('/') + 1); + + expect(testLog).toStrictEqual({ + test: 'get-multiple', + value: { + [expectedParameterNameA]: paramAValue, + [expectedParameterNameB]: paramBValue, + } + }); + + }, TEST_CASE_TIMEOUT); + + // Test 4 - get multiple parameters by path recursively + // (aka. get all parameters under a path recursively) i.e. + // given /param, retrieve /param/get/a and /param/get/b (note path depth) + it('should retrieve multiple parameters recursively', async () => { + + const logs = invocationLogs[0].getFunctionLogs(); + const testLog = InvocationLogs.parseFunctionLog(logs[3]); + const expectedParameterNameA = paramA.substring(paramA.lastIndexOf('/') + 1); + const expectedParameterNameB = paramB.substring(paramB.lastIndexOf('/') + 1); + + expect(testLog).toStrictEqual({ + test: 'get-multiple-recursive', + value: { + [expectedParameterNameA]: paramAValue, + [expectedParameterNameB]: paramBValue, + } + }); + + }, TEST_CASE_TIMEOUT); + + it('should retrieve multiple parameters with decryption', async () => { + + const logs = invocationLogs[0].getFunctionLogs(); + const testLog = InvocationLogs.parseFunctionLog(logs[4]); + const expectedParameterNameA = paramEncryptedA.substring( + paramEncryptedA.lastIndexOf('/') + 1 + ); + const expectedParameterNameB = paramEncryptedB.substring( + paramEncryptedB.lastIndexOf('/') + 1 + ); + + expect(testLog).toStrictEqual({ + test: 'get-multiple-decrypt', + value: { + [expectedParameterNameA]: paramEncryptedAValue, + [expectedParameterNameB]: paramEncryptedBValue, + } + }); + + }, TEST_CASE_TIMEOUT); + + // Test 6 - get multiple parameters by name with default options + it('should retrieve multiple parameters by name', async () => { + + const logs = invocationLogs[0].getFunctionLogs(); + const testLog = InvocationLogs.parseFunctionLog(logs[5]); + + expect(testLog).toStrictEqual({ + test: 'get-multiple-by-name', + value: { + [paramA]: paramAValue, + [paramB]: paramBValue, + } + }); + + }, TEST_CASE_TIMEOUT); + + // Test 7 - get multiple parameters by name, some of them encrypted and some not + it('should retrieve multiple parameters by name with mixed decryption', async () => { + + const logs = invocationLogs[0].getFunctionLogs(); + const testLog = InvocationLogs.parseFunctionLog(logs[6]); + + expect(testLog).toStrictEqual({ + test: 'get-multiple-by-name-mixed-decrypt', + value: { + [paramEncryptedA]: paramEncryptedAValue, + [paramEncryptedB]: paramEncryptedBValue, + [paramA]: paramAValue, + } + }); + + }, TEST_CASE_TIMEOUT); + + // Test 8 - get parameter twice with middleware, which counts the number + // of requests, we check later if we only called SSM API once + it('should retrieve single parameter cached', async () => { + + const logs = invocationLogs[0].getFunctionLogs(); + const testLog = InvocationLogs.parseFunctionLog(logs[7]); + + expect(testLog).toStrictEqual({ + test: 'get-cached', + value: 1 + }); + + }, TEST_CASE_TIMEOUT); + + // Test 9 - get parameter twice, but force fetch 2nd time, + // we count number of SDK requests and check that we made two API calls + it('should retrieve single parameter twice without caching', async () => { + + const logs = invocationLogs[0].getFunctionLogs(); + const testLog = InvocationLogs.parseFunctionLog(logs[8]); + + expect(testLog).toStrictEqual({ + test: 'get-forced', + value: 2 + }); + + }, TEST_CASE_TIMEOUT); + + }); + + afterAll(async () => { + if (!process.env.DISABLE_TEARDOWN) { + await destroyStack(integTestApp, stack); + } + }, TEARDOWN_TIMEOUT); + +}); \ No newline at end of file diff --git a/packages/parameters/tests/helpers/cdkAspectGrantAccess.ts b/packages/parameters/tests/helpers/cdkAspectGrantAccess.ts index 57d99a393b..f75848acd5 100644 --- a/packages/parameters/tests/helpers/cdkAspectGrantAccess.ts +++ b/packages/parameters/tests/helpers/cdkAspectGrantAccess.ts @@ -2,7 +2,12 @@ import { IAspect } from 'aws-cdk-lib'; import { IConstruct } from 'constructs'; import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'; import { Table } from 'aws-cdk-lib/aws-dynamodb'; +import { PolicyStatement, Effect } from 'aws-cdk-lib/aws-iam'; import { Secret } from 'aws-cdk-lib/aws-secretsmanager'; +import { StringParameter, IStringParameter } from 'aws-cdk-lib/aws-ssm'; + +const isStringParameterGeneric = (parameter: IConstruct): parameter is StringParameter | IStringParameter => + parameter.hasOwnProperty('parameterArn'); /** * An aspect that grants access to resources to a Lambda function. @@ -15,13 +20,13 @@ import { Secret } from 'aws-cdk-lib/aws-secretsmanager'; * This aspect allows us to grant access to specific resources to all Lambda functions in a stack * after the stack tree has been generated and before the stack is deployed. This aspect is * used to grant access to different resource types (DynamoDB tables, SSM parameters, etc.). - * - * @see {@link https://docs.aws.amazon.com/cdk/v2/guide/aspects.html|CDK Docs - Aspects} + * + * @see {@link https://docs.aws.amazon.com/cdk/v2/guide/aspects.html CDK Docs - Aspects} */ export class ResourceAccessGranter implements IAspect { - private readonly resources: Table[] | Secret[]; + private readonly resources: Table[] | Secret[] | StringParameter[] | IStringParameter[]; - public constructor(resources: Table[] | Secret[]) { + public constructor(resources: Table[] | Secret[] | StringParameter[] | IStringParameter[]) { this.resources = resources; } @@ -30,12 +35,28 @@ export class ResourceAccessGranter implements IAspect { if (node instanceof NodejsFunction) { // Grant access to the resources - this.resources.forEach((resource: Table | Secret) => { + this.resources.forEach((resource: Table | Secret | StringParameter | IStringParameter) => { if (resource instanceof Table) { resource.grantReadData(node); - } else if (resource instanceof Secret) { + } else if ( + resource instanceof Secret + ) { + resource.grantRead(node); + } else if (isStringParameterGeneric(resource)) { resource.grantRead(node); + // Grant access also to the path of the parameter + node.addToRolePolicy( + new PolicyStatement({ + effect: Effect.ALLOW, + actions: [ + 'ssm:GetParametersByPath', + ], + resources: [ + resource.parameterArn.split(':').slice(0, -1).join(':'), + ], + }), + ); } }); diff --git a/packages/parameters/tests/helpers/parametersUtils.ts b/packages/parameters/tests/helpers/parametersUtils.ts index 3768a1fe53..e503fddd2b 100644 --- a/packages/parameters/tests/helpers/parametersUtils.ts +++ b/packages/parameters/tests/helpers/parametersUtils.ts @@ -1,4 +1,10 @@ -import { Stack, RemovalPolicy } from 'aws-cdk-lib'; +import { Stack, RemovalPolicy, CustomResource, Duration } from 'aws-cdk-lib'; +import { Provider } from 'aws-cdk-lib/custom-resources'; +import { RetentionDays } from 'aws-cdk-lib/aws-logs'; +import { NodejsFunction } from 'aws-cdk-lib/aws-lambda-nodejs'; +import { Runtime } from 'aws-cdk-lib/aws-lambda'; +import { PolicyStatement } from 'aws-cdk-lib/aws-iam'; +import { StringParameter, IStringParameter } from 'aws-cdk-lib/aws-ssm'; import { Table, TableProps, BillingMode } from 'aws-cdk-lib/aws-dynamodb'; export type CreateDynamoDBTableOptions = { @@ -17,6 +23,76 @@ const createDynamoDBTable = (options: CreateDynamoDBTableOptions): Table => { return new Table(stack, id, props); }; +export type CreateSecureStringProviderOptions = { + stack: Stack + parametersPrefix: string +}; + +const createSecureStringProvider = (options: CreateSecureStringProviderOptions): Provider => { + const { stack, parametersPrefix } = options; + + const ssmSecureStringHandlerFn = new NodejsFunction( + stack, + 'ssm-securestring-handler', + { + entry: 'tests/helpers/ssmSecureStringCdk.ts', + handler: 'handler', + bundling: { + minify: true, + sourceMap: true, + target: 'es2020', + externalModules: [], + }, + runtime: Runtime.NODEJS_18_X, + timeout: Duration.seconds(15), + }); + ssmSecureStringHandlerFn.addToRolePolicy( + new PolicyStatement({ + actions: [ + 'ssm:PutParameter', + 'ssm:DeleteParameter', + ], + resources: [ + `arn:aws:ssm:${stack.region}:${stack.account}:parameter/${parametersPrefix}*`, + ], + }), + ); + + return new Provider(stack, 'ssm-secure-string-provider', { + onEventHandler: ssmSecureStringHandlerFn, + logRetention: RetentionDays.ONE_DAY, + }); +}; + +export type CreateSSMSecureStringOptions = { + stack: Stack + provider: Provider + id: string + name: string + value: string +}; + +const createSSMSecureString = (options: CreateSSMSecureStringOptions): IStringParameter => { + const { stack, provider, id, name, value } = options; + + new CustomResource(stack, `custom-${id}`, { + serviceToken: provider.serviceToken, + properties: { + Name: name, + Value: value, + }, + }); + + const param = StringParameter.fromSecureStringParameterAttributes(stack, id, { + parameterName: name, + }); + param.node.addDependency(provider); + + return param; +}; + export { - createDynamoDBTable + createDynamoDBTable, + createSSMSecureString, + createSecureStringProvider, }; \ No newline at end of file diff --git a/packages/parameters/tests/helpers/ssmSecureStringCdk.ts b/packages/parameters/tests/helpers/ssmSecureStringCdk.ts new file mode 100644 index 0000000000..2076c25c47 --- /dev/null +++ b/packages/parameters/tests/helpers/ssmSecureStringCdk.ts @@ -0,0 +1,54 @@ +import { + Context, + CloudFormationCustomResourceEvent +} from 'aws-lambda'; +import { + SSMClient, + PutParameterCommand, + DeleteParameterCommand +} from '@aws-sdk/client-ssm'; + +const client = new SSMClient({}); + +/** + * Create a new SSM SecureString parameter. + */ +const createResource = async (event: CloudFormationCustomResourceEvent): Promise => { + const { ResourceProperties } = event; + const { Name, Value } = ResourceProperties; + + await client.send(new PutParameterCommand({ + Name, + Value, + Type: 'SecureString', + })); +}; + +/** + * Delete an existing SSM parameter. + */ +const deleteResource = async (event: CloudFormationCustomResourceEvent): Promise => { + const { ResourceProperties } = event; + const { Name } = ResourceProperties; + + await client.send(new DeleteParameterCommand({ + Name, + })); +}; + +/** + * Custom resource handler for creating and deleting SSM SecureString parameters. This is used by + * CDK to create and delete the SSM SecureString parameters that are used to test the SSMProvider. + * + * We need a custom resource because CDK does not support creating SSM SecureString parameters. + */ +export const handler = async (event: CloudFormationCustomResourceEvent, _context: Context): Promise => { + if (event.RequestType === 'Create') { + await createResource(event); + } else if (event.RequestType === 'Delete') { + await deleteResource(event); + } else { + console.error('Unknown or unsupported request type', event); + throw new Error('Unknown or unsupported request type'); + } +}; \ No newline at end of file