diff --git a/README.md b/README.md index c938b1a..33a4ddf 100644 --- a/README.md +++ b/README.md @@ -33,12 +33,12 @@ decorates the output with HAL links for the entity and a custom middleware ```ge ### How it works This module implements the middleware pipeline in a onion style. - + The terminology "pipeline" is often used to describe the onion. One way of looking at the "onion" is as a queue, which is first-in-first-out (FIFO) in operation. This means that the first middleware on the queue is executed first, and this invokes the next, and so on (and hence the "next" terminology) Each middleware receives the event and context, and pass to the handler only when wanting to hand off processing. -A middleware can return a response immediately, for example validate in input before handling it to the handler. In this case, the pipeline will not continue and the request will not even reach the handler. +A middleware can return a response immediately, for example validate in input before handling it to the handler. In this case, the pipeline will not continue and the request will not even reach the handler. ### Pipeline @@ -89,11 +89,43 @@ Will fetch the keys from SSM Parameter Store, embed in the destination and cache * ssmOptions: Will be passed to the SSM constructor * expiryMs: Will keep the parameters in the memory cache for expiryMs in milliseconds. Default is 10 minutes. + +#### secretsManager(keyMap, destination, { secretsManagerOptions: {}, expiryMs: 10 * 60 * 1000 }) +Will fetch the keys from AWS Secrets Manager, embed in the destination and cache the result for the specified expiryMs. +* keyMap: A map of key names and paths in Secrets Manager. Example: +{ + MYSQL_HOST: '/production/MYSQL_HOST', + MYSQL_USER: '/production/MYSQL_USER', + MYSQL_PASS: '/production/MYSQL_PASS', +} +* destination: the destination of the parameters. Example: ```context``` . context will be used if null +* secretsManagerOptions: Will be passed to the AWS.secretsmanager constructor +* expiryMs: Will keep the parameters in the memory cache for expiryMs in milliseconds. Default is 10 minutes. + #### Custom You can create a custom middleware and even call another middleware, just follow the following signature and example: ```javascript const middleware = require('../lambda-middleware'); +module.exports = () => { + return async (event, context, next) => { + if (process.env.NODE_ENV !== 'development') { + const params = {}; + await secretManager({ MYSQL: 'prod/mysql' })(event, params, (ev, ctx) => { + process.env.MYSQL_HOST = ctx.MYSQL.host; + process.env.MYSQL_USER = ctx.MYSQL.username; + process.env.MYSQL_PASS = ctx.MYSQL.password; + }); + } + + return next(event, context); + }; +}; +``` + +```javascript +const middleware = require('../lambda-middleware'); + module.exports = () => { return async (event, context, next) => { if (process.env.NODE_ENV !== 'development') { @@ -112,4 +144,4 @@ module.exports = () => { }; ``` -***Warning*** Don't forget to call ```return next(event, context)``` at the end or the pipeline will not continue and will get no response. +***Warning*** Don't forget to call ```return next(event, context)``` at the end or the pipeline will not continue and will get no response. diff --git a/index.js b/index.js index c525f7e..d5aa81f 100644 --- a/index.js +++ b/index.js @@ -6,4 +6,5 @@ module.exports = { doNotWaitEmptyEventLoop: require('./src/middlewares/doNotWaitEmptyEventLoop'), halResponse: require('./src/middlewares/halResponse'), parameterStore: require('./src/middlewares/parameterStore'), + secretsManager: require('./src/middlewares/secretsManager'), }; diff --git a/src/middlewares/secretsManager.js b/src/middlewares/secretsManager.js new file mode 100644 index 0000000..5b9f491 --- /dev/null +++ b/src/middlewares/secretsManager.js @@ -0,0 +1,80 @@ +/* eslint-disable no-param-reassign,import/no-extraneous-dependencies */ +const { promisify } = require('util'); +const SecretsManager = require('aws-sdk/clients/secretsmanager'); +const debug = require('debug')('lambda-middleware'); + +let secretsManager; +const cache = { + expiration: new Date(0), + items: {}, +}; + +/** + * @param {Object} keyMap + * @param {Object} destination + * @param {{ + * secretsManagerOptions: Object, + * expiryMs: integer + * }} incomingOptions + * @returns {function(*=, *=, *): *} + */ +module.exports = (keyMap, destination, incomingOptions) => { + const options = { + secretsManagerOptions: {}, + expiryMs: 10 * 60 * 1000, // default expiry is 10 mins + ...incomingOptions, + }; + + if (!keyMap) { + throw new Error('You need to provide a keys map'); + } + + if (options.expiryMs <= 0) { + throw new Error('You need to specify an expiry (ms) greater than 0, or leave it undefined'); + } + + const vars = Object.keys(keyMap); + + const createSecretsManagerClient = () => { + if (secretsManager !== null) { + secretsManager = new SecretsManager(options.secretsManagerOptions); + // noinspection JSValidateTypes + secretsManager.getSecretValue = promisify(secretsManager.getSecretValue); + } + }; + + const reload = async () => { + debug(`Loading cache keys: ${vars}`); + + createSecretsManagerClient(); + + cache.items = {}; + await Promise.all( + vars.map(async key => { + const resp = await secretsManager.getSecretValue({ SecretId: keyMap[key] }); + const secret = JSON.parse(resp.SecretString || '{}'); + cache.items[key] = secret; + }), + ); + + debug(`Successfully loaded cache keys: ${vars}`); + const now = new Date(); + + cache.expiration = new Date(now.getTime() + options.expiryMs); + }; + + return async (event, context, next) => { + const now = new Date(); + if (now > cache.expiration) { + await reload(); + } + + if (typeof destination !== 'object' || destination === null) { + Object.assign(context, cache.items); + } else { + Object.assign(destination, cache.items); + } + + return next(event, context); + }; +};