diff --git a/README.md b/README.md index d611538..a260dfe 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,26 @@ If you supply `SECRETS_MANAGER_SECRET_ID`, you can ommit the 'PG\*' keys, and th You can provide overrides in your event to any PG\* keys as event parameters will take precedence over secret values. +#### SSM Parameter Store-based configuration + +If you want to load configuration or secrets from AWS Systems Manager Parameter Store, you can provide a list of parameter names in the event via the `SSM_PARAMETER_NAMES` field. Only one external configuration source (IAM, Secrets Manager, or SSM) is used per invocation: if IAM or Secrets Manager fields are present, those take precedence and SSM is ignored. + +**Example:** + +```json +{ + "SSM_PARAMETER_NAMES": ["/my/app/PGUSER", "/my/app/PGPASSWORD", "/my/app/PGHOST", "/my/app/PGDATABASE"], + "S3_BUCKET": "db-backups", + "ROOT": "hourly-backups" +} +``` + +The fetched parameter values will be merged into the event config, using only the last segment of each parameter name as the config key (e.g., '/my/app/PGUSER' becomes 'PGUSER'). If there are conflicts, event fields override SSM values. + +If any requested SSM parameter is missing or an error occurs during fetching, the function will log an error and fall back to using only the provided event configuration (i.e., SSM values will not be merged). + +The Lambda execution role must have permission to call `ssm:GetParameters` for the specified parameter names (e.g., by attaching the AWS managed policy `AmazonSSMReadOnlyAccess`). + #### Multiple databases If you'd like to export multiple databases in a single event, you can add a comma-separated list of database names to the PGDATABASE setting. The results will return in a list. diff --git a/lib/handler.js b/lib/handler.js index bef2e53..9070c2c 100644 --- a/lib/handler.js +++ b/lib/handler.js @@ -5,6 +5,7 @@ const decorateWithIamToken = require('./iam') const decorateWithSecretsManagerCredentials = require('./secrets-manager') const parseDatabaseNames = require('./parseDatabaseNames') const encryption = require('./encryption') +const decorateWithSsmParameters = require('./ssm-parameters') const DEFAULT_CONFIG = require('./config') @@ -46,6 +47,9 @@ async function handler(event) { else if (event.SECRETS_MANAGER_SECRET_ID) { decoratedConfig = await decorateWithSecretsManagerCredentials(baseConfig) } + else if (event.SSM_PARAMETER_NAMES) { + decoratedConfig = await decorateWithSsmParameters(baseConfig) + } else { decoratedConfig = baseConfig } diff --git a/lib/ssm-parameters.js b/lib/ssm-parameters.js new file mode 100644 index 0000000..340cf73 --- /dev/null +++ b/lib/ssm-parameters.js @@ -0,0 +1,53 @@ +const AWS = require('aws-sdk') + +async function getSsmParameters(names, options = {}) { + const ssm = new AWS.SSM() + const params = { + Names: names, + WithDecryption: options.withDecryption !== false + } + const result = await ssm.getParameters(params).promise() + const values = {} + const requestedKeys = names.map(name => name.substring(name.lastIndexOf('/') + 1)) + const returnedKeys = result.Parameters.map(param => param.Name.substring(param.Name.lastIndexOf('/') + 1)) + for (const param of result.Parameters) { + // Use only the last segment after the last slash as the config key + const key = param.Name.substring(param.Name.lastIndexOf('/') + 1); + values[key] = param.Value; + } + // If any requested keys are missing, throw a clear error + const missingKeys = requestedKeys.filter(key => !returnedKeys.includes(key)) + if (missingKeys.length > 0) { + throw new Error(`Missing SSM Parameters (by config key): ${missingKeys.join(', ')}`) + } + if (result.InvalidParameters && result.InvalidParameters.length > 0) { + throw new Error(`Invalid SSM Parameters: ${result.InvalidParameters.join(', ')}`) + } + return values +} + +/** + * Decorates config with values from AWS SSM Parameter Store. + * If SSM_PARAMETER_NAMES is present and valid, fetches and merges those values. + * SSM values are overridden by direct event fields. + * @param {object} baseConfig + * @returns {Promise} decorated config + */ +async function decorateWithSsmParameters(baseConfig) { + if ( + baseConfig.SSM_PARAMETER_NAMES && + Array.isArray(baseConfig.SSM_PARAMETER_NAMES) && + baseConfig.SSM_PARAMETER_NAMES.length > 0 + ) { + try { + const ssmValues = await getSsmParameters(baseConfig.SSM_PARAMETER_NAMES) + return { ...ssmValues, ...baseConfig } + } catch (error) { + console.log('Error fetching SSM parameters:', error) + return baseConfig + } + } + return baseConfig +} + +module.exports = decorateWithSsmParameters diff --git a/test/ssm-parameters.js b/test/ssm-parameters.js new file mode 100644 index 0000000..d773f1f --- /dev/null +++ b/test/ssm-parameters.js @@ -0,0 +1,73 @@ +const { expect } = require('chai') +const AWSMOCK = require('aws-sdk-mock') +const AWS = require('aws-sdk') +const decorateWithSsmParameters = require('../lib/ssm-parameters') + +describe('decorateWithSsmParameters', () => { + before(() => { + AWSMOCK.setSDKInstance(AWS) + }) + + afterEach(() => { + AWSMOCK.restore('SSM') + }) + + it('should merge SSM parameter values into config', async () => { + AWSMOCK.mock('SSM', 'getParameters', (params, cb) => { + cb(null, { + Parameters: [ + { Name: '/my/app/PGUSER', Value: 'bar' }, + { Name: '/my/app/PGPASSWORD', Value: 'qux' } + ], + InvalidParameters: [] + }) + }) + const baseConfig = { SSM_PARAMETER_NAMES: ['/my/app/PGUSER', '/my/app/PGPASSWORD'], someOther: 'value' } + const result = await decorateWithSsmParameters(baseConfig) + expect(result).to.deep.equal({ PGUSER: 'bar', PGPASSWORD: 'qux', SSM_PARAMETER_NAMES: ['/my/app/PGUSER', '/my/app/PGPASSWORD'], someOther: 'value' }) + }) + + it('should allow event fields to override SSM values', async () => { + AWSMOCK.mock('SSM', 'getParameters', (params, cb) => { + cb(null, { + Parameters: [ + { Name: '/my/app/PGUSER', Value: 'bar' } + ], + InvalidParameters: [] + }) + }) + const baseConfig = { SSM_PARAMETER_NAMES: ['/my/app/PGUSER'], PGUSER: 'override' } + const result = await decorateWithSsmParameters(baseConfig) + expect(result.PGUSER).to.equal('override') + }) + + it('should return baseConfig if no SSM_PARAMETER_NAMES', async () => { + const baseConfig = { PGUSER: 'user' } + const result = await decorateWithSsmParameters(baseConfig) + expect(result).to.deep.equal(baseConfig) + }) + + it('should return baseConfig on SSM error', async () => { + AWSMOCK.mock('SSM', 'getParameters', (params, cb) => { + cb(new Error('fail')) + }) + const baseConfig = { SSM_PARAMETER_NAMES: ['foo'] } + const result = await decorateWithSsmParameters(baseConfig) + expect(result).to.deep.equal(baseConfig) + }) + + it('should return baseConfig if any requested SSM parameter is missing', async () => { + AWSMOCK.mock('SSM', 'getParameters', (params, cb) => { + cb(null, { + Parameters: [ + { Name: '/my/app/PGUSER', Value: 'bar' } + ], + InvalidParameters: [] + }) + }) + // PGUSER will be found, PGPASSWORD will be missing + const baseConfig = { SSM_PARAMETER_NAMES: ['/my/app/PGUSER', '/my/app/PGPASSWORD'] } + const result = await decorateWithSsmParameters(baseConfig) + expect(result).to.deep.equal(baseConfig) + }) +})