diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 167c7e3..86d7df4 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -10,7 +10,7 @@ jobs: steps: - uses: actions/checkout@v2 - name: Set up NodeJS - uses: actions/setup-node@v2 + uses: actions/setup-node@v4 with: node-version: 18.20.4 cache: 'npm' diff --git a/README.md b/README.md index 1280801..4d221f3 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,3 @@ - # good-env

@@ -157,6 +156,29 @@ const { const credentials = env.getAWS({ region: 'us-west-2' }); ``` +### AWS Secrets Manager Integration + +Some folks like to store secrets in AWS secrets manager in the form of a JSON object as opposed (or in addition) to environment variables. It's me, I'm some folks. Good Env now supports this pattern. To avoid introducing a dependency you'll have to bring your own instance of AWS Secrets Manager though. Be sure to specify your AWS region as an environment variable, otherwise, it'll default to `us-east-1`. + +Note, if something goes wrong, this function _will_ throw an error. + +```javascript +const awsSecretsManager = require('@aws-sdk/client-secrets-manager'); + +(async function() { + // Load secrets from AWS Secrets Manager + await env.use(awsSecretsManager, 'my-secret-id'); + + // The secret ID can also be specified via environment variables + // AWS_SECRET_ID or SECRET_ID + await env.use(awsSecretsManager); + + // Secrets are automatically merged with existing environment variables + // and can be accessed using any of the standard methods + const secretValue = env.get('someSecretFromAWSSecretsManager'); +}()); +``` + ## Important Behavior Notes ### Boolean Existence vs Value diff --git a/package.json b/package.json index 708f87e..d7ce904 100644 --- a/package.json +++ b/package.json @@ -1,13 +1,13 @@ { "name": "good-env", - "version": "7.4.0", + "version": "7.5.0", "description": "Better environment variable handling for Twelve-Factor node apps", "main": "src/index.js", "scripts": { "test": "nyc --check-coverage --lines 100 node test/test.js", "lint": "semistandard test/ src/index.js", - "ci": "semistandard test/ src/index.js && nyc --check-coverage --lines 100 node test/test.js", - "format": "semistandard --fix test/ src/index.js" + "ci": "semistandard test/test.js src/index.js && nyc --check-coverage --lines 100 node test/test.js", + "format": "semistandard --fix test/test.js src/*.js" }, "keywords": [ "environment", diff --git a/src/index.d.ts b/src/index.d.ts index 03d2be7..b4401a4 100644 --- a/src/index.d.ts +++ b/src/index.d.ts @@ -1,4 +1,12 @@ declare module "good-env" { + /** + * @description Tell Good Env to go to secrets manager, grab the object under the specified secretId and merge it with the + * environment. + * @param {any} awsSecretsManager - An instance of AWS Secrets Manager is imported from the SDK + * @param {string} awsSecretsManager - The secret ID to use to fetch the secrets object. If not supplied, the function will + * check environment variables AWS_SECRET_ID and SECRET_ID. If neither of which are defined, the function will throw an error + */ + export const use: (awsSecretsManager: any, secretId?: string) => Promise; /** * @description Fetches an IP address from the environment. If the value found under the specified key is not a valid IPv4 * or IPv6 IP and there's no default value, null is returned. If a default value is provided and it is a valid IPv4 or IPv6 diff --git a/src/index.js b/src/index.js index 0c8eb30..204d4f4 100644 --- a/src/index.js +++ b/src/index.js @@ -17,10 +17,29 @@ let store = { ...process.env }; module.exports = Object .create({ - async _mergeSecrets_ ({ fetcherFunc }) { - const secret = await fetcherFunc(); + async use (awsSecretsManager, secretId) { + const { SecretsManagerClient, GetSecretValueCommand } = awsSecretsManager; + const client = new SecretsManagerClient({ + region: process.env.AWS_REGION || 'us-east-1' + }); + + if (!secretId) { + secretId = this.get(['AWS_SECRET_ID', 'SECRET_ID']); + } + + if (!secretId) { + throw new Error('\'secretId\' was not specified, and it wasn\'t found as \'AWS_SECRET_ID\' or \'SECRET_ID\' in the environment.'); + } + + const response = await client.send( + new GetSecretValueCommand({ + SecretId: secretId, + VersionStage: 'AWSCURRENT' // VersionStage defaults to AWSCURRENT if unspecified + }) + ); + const secretStr = response.SecretString; + const secret = JSON.parse(secretStr); store = { ...store, ...secret }; - return this; }, /** * @description Fetches an IP address from the environment. If the value found under the specified key is not a valid IPv4 diff --git a/test/mocks.js b/test/mocks.js new file mode 100644 index 0000000..25e4ff8 --- /dev/null +++ b/test/mocks.js @@ -0,0 +1,24 @@ + +class GetSecretValueCommand { + constructor ({}) {} +} + +class SecretsManagerClientHappy { + constructor ({}) {} + send () { + return Promise.resolve({ SecretString: JSON.stringify({ secretVal1: 'val1', secretVal2: 'val2' }) }); + } +} + +class SecretsManagerClientNotHappy { + constructor ({}) {} + send () { + return Promise.reject('Something went wrong'); + } +} + +module.exports = { + GetSecretValueCommand, + SecretsManagerClientHappy, + SecretsManagerClientNotHappy, +}; \ No newline at end of file diff --git a/test/test.js b/test/test.js index cc17714..4905244 100644 --- a/test/test.js +++ b/test/test.js @@ -4,21 +4,35 @@ require('dotenv').config({ path: 'test/test.env' }); const test = require('tape'); const env = require('../src/index'); +const { + GetSecretValueCommand, + SecretsManagerClientHappy +} = require('./mocks'); + +test('it throws when no secretId is given when attempting to use secretsManager', async (t) => { + const awsSecretsManager = { SecretsManagerClient: SecretsManagerClientHappy, GetSecretValueCommand }; + + try { + await env.use(awsSecretsManager); + t.fail('We should not be here. An error should have been thrown'); + } catch (e) { + console.log(e.message); + t.equals(e.message, '\'secretId\' was not specified, and it wasn\'t found as \'AWS_SECRET_ID\' or \'SECRET_ID\' in the environment.'); + t.end(); + } +}); + +test('it uses secrets manager (happy path)', async (t) => { + const awsSecretsManager = { SecretsManagerClient: SecretsManagerClientHappy, GetSecretValueCommand }; + await env.use(awsSecretsManager, 'my-secret'); + const foo = env.get('FOO'); + const secretVal1 = env.get('secretVal1'); + const secretVal2 = env.get('secretVal2'); + + t.equals(foo, 'bar'); + t.equals(secretVal1, 'val1'); + t.equals(secretVal2, 'val2'); -test('it merges secrets', async (t) => { - const fetcherFunc = () => { - return new Promise((resolve) => { - setTimeout(() => { - resolve({ - FOOB: 'bar', - BARZ: 'baz' - }); - }, 10); - }); - }; - const env2 = await env._mergeSecrets_({ fetcherFunc }); - t.equals(env2.get('FOOB'), 'bar'); - t.equals(env2.get('BARZ'), 'baz'); t.end(); });