Skip to content

Commit

Permalink
Merge pull request #1064 from middyjs/feature/secret-manager-rotation
Browse files Browse the repository at this point in the history
[secret-manager]: Add in support for secret rotation date
  • Loading branch information
willfarrell authored Aug 10, 2023
2 parents a559b02 + 07a824c commit b2d70e8
Show file tree
Hide file tree
Showing 12 changed files with 265 additions and 20 deletions.
1 change: 1 addition & 0 deletions packages/appconfig/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const defaults = {
fetchData: {},
disablePrefetch: false,
cacheKey: 'appconfig',
cacheKeyExpiry: {},
cacheExpiry: -1,
setToContext: false
}
Expand Down
1 change: 1 addition & 0 deletions packages/dynamodb/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const defaults = {
fetchData: {},
disablePrefetch: false,
cacheKey: 'dynamodb',
cacheKeyExpiry: {},
cacheExpiry: -1,
setToContext: false
}
Expand Down
1 change: 1 addition & 0 deletions packages/rds-signer/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const defaults = {
fetchData: {},
disablePrefetch: false,
cacheKey: 'rds-signer',
cacheKeyExpiry: {},
cacheExpiry: -1,
setToContext: false
}
Expand Down
1 change: 1 addition & 0 deletions packages/s3/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const defaults = {
fetchData: {},
disablePrefetch: false,
cacheKey: 's3',
cacheKeyExpiry: {},
cacheExpiry: -1,
setToContext: false
}
Expand Down
126 changes: 126 additions & 0 deletions packages/secrets-manager/__tests__/index.js
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import { setTimeout } from 'node:timers/promises'
import test from 'ava'
import sinon from 'sinon'
import { mockClient } from 'aws-sdk-client-mock'
import middy from '../../core/index.js'
import { getInternal, clearCache } from '../../util/index.js'
import {
SecretsManagerClient,
DescribeSecretCommand,
GetSecretValueCommand
} from '@aws-sdk/client-secrets-manager'
import secretsManager from '../index.js'
Expand Down Expand Up @@ -242,6 +244,130 @@ test.serial(
}
)

test.serial(
'It should call aws-sdk if cache enabled but cached param has expired using LastRotationDate',
async (t) => {
const mockService = mockClient(SecretsManagerClient)
.on(DescribeSecretCommand, { SecretId: 'api_key' })
.resolves({
LastRotationDate: Date.now() / 1000 - 50,
LastChangedDate: Date.now() / 1000 - 50
})
.on(GetSecretValueCommand, { SecretId: 'api_key' })
.resolves({ SecretString: 'token' })
const sendStub = mockService.send
const handler = middy(() => {})

const middleware = async (request) => {
const values = await getInternal(true, request)
t.is(values.token, 'token')
}

handler
.use(
secretsManager({
AwsClient: SecretsManagerClient,
cacheExpiry: 100,
fetchData: {
token: 'api_key'
},
fetchRotationDate: true,
disablePrefetch: true
})
)
.before(middleware)

await handler(event, context) // fetch x 2
await handler(event, context)
await setTimeout(100)
await handler(event, context) // fetch x 2

t.is(sendStub.callCount, 2 * 2)
}
)

test.serial(
'It should call aws-sdk if cache enabled but cached param has expired using LastRotationDate, fallback to NextRotationDate',
async (t) => {
const mockService = mockClient(SecretsManagerClient)
.on(DescribeSecretCommand, { SecretId: 'api_key' })
.resolves({
LastRotationDate: Date.now() / 1000 - 25,
LastChangedDate: Date.now() / 1000 - 25,
NextRotationDate: Date.now() / 1000 + 50
})
.on(GetSecretValueCommand, { SecretId: 'api_key' })
.resolves({ SecretString: 'token' })
const sendStub = mockService.send
const handler = middy(() => {})

const middleware = async (request) => {
const values = await getInternal(true, request)
t.is(values.token, 'token')
}

handler
.use(
secretsManager({
AwsClient: SecretsManagerClient,
cacheExpiry: 100,
fetchData: {
token: 'api_key'
},
fetchRotationDate: true,
disablePrefetch: true
})
)
.before(middleware)

await handler(event, context) // fetch x 2
await handler(event, context)
await setTimeout(100)
await handler(event, context) // fetch x 2

t.is(sendStub.callCount, 2 * 2)
}
)

test.serial(
'It should call aws-sdk if cache enabled but cached param has expired using NextRotationDate',
async (t) => {
const mockService = mockClient(SecretsManagerClient)
.on(DescribeSecretCommand, { SecretId: 'api_key' })
.resolves({ NextRotationDate: Date.now() / 1000 + 50 })
.on(GetSecretValueCommand, { SecretId: 'api_key' })
.resolves({ SecretString: 'token' })
const sendStub = mockService.send
const handler = middy(() => {})

const middleware = async (request) => {
const values = await getInternal(true, request)
t.is(values.token, 'token')
}

handler
.use(
secretsManager({
AwsClient: SecretsManagerClient,
cacheExpiry: -1,
fetchData: {
token: 'api_key'
},
fetchRotationDate: true,
disablePrefetch: true
})
)
.before(middleware)

await handler(event, context) // fetch x 2
await handler(event, context)
await setTimeout(100)
await handler(event, context) // fetch x 2

t.is(sendStub.callCount, 2 * 2)
}
)

test.serial('It should catch if an error is returned from fetch', async (t) => {
const mockService = mockClient(SecretsManagerClient)
.on(GetSecretValueCommand, { SecretId: 'api_key' })
Expand Down
50 changes: 39 additions & 11 deletions packages/secrets-manager/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from '@middy/util'
import {
SecretsManagerClient,
DescribeSecretCommand,
GetSecretValueCommand
} from '@aws-sdk/client-secrets-manager'

Expand All @@ -18,10 +19,12 @@ const defaults = {
awsClientOptions: {},
awsClientAssumeRole: undefined,
awsClientCapture: undefined,
fetchData: {}, // If more than 2, consider writing own using ListSecrets
fetchData: {},
fetchRotationDate: false, // true: apply to all or {key: true} for individual
disablePrefetch: false,
cacheKey: 'secrets-manager',
cacheExpiry: -1,
cacheKeyExpiry: {},
cacheExpiry: -1, // ignored when fetchRotationRules is true/object
setToContext: false
}

Expand All @@ -31,17 +34,42 @@ const secretsManagerMiddleware = (opts = {}) => {
const fetch = (request, cachedValues = {}) => {
const values = {}

// Multiple secrets can be requested in a single requests,
// however this is likely uncommon IRL, increases complexity to handle,
// and will require recursive promise resolution impacting performance.
// See https://docs.aws.amazon.com/AWSJavaScriptSDK/latest/AWS/SecretsManager.html#listSecrets-property
for (const internalKey of Object.keys(options.fetchData)) {
if (cachedValues[internalKey]) continue
values[internalKey] = client
.send(
new GetSecretValueCommand({
SecretId: options.fetchData[internalKey]
})

values[internalKey] = Promise.resolve()
.then(() => {
if (
options.fetchRotationDate === true ||
options.fetchRotationDate?.[internalKey]
) {
return client
.send(
new DescribeSecretCommand({
SecretId: options.fetchData[internalKey]
})
)
.then((resp) => {
if (options.cacheExpiry < 0) {
options.cacheKeyExpiry[internalKey] =
resp.NextRotationDate * 1000
} else {
options.cacheKeyExpiry[internalKey] = Math.min(
Math.max(resp.LastRotationDate, resp.LastChangedDate) *
1000 +
options.cacheExpiry,
resp.NextRotationDate * 1000
)
}
})
}
})
.then(() =>
client.send(
new GetSecretValueCommand({
SecretId: options.fetchData[internalKey]
})
)
)
.then((resp) => jsonSafeParse(resp.SecretString))
.catch((e) => {
Expand Down
1 change: 1 addition & 0 deletions packages/service-discovery/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const defaults = {
fetchData: {}, // { contextKey: {NamespaceName, ServiceName, HealthStatus} }
disablePrefetch: false,
cacheKey: 'cloud-map',
cacheKeyExpiry: {},
cacheExpiry: -1,
setToContext: false
}
Expand Down
1 change: 1 addition & 0 deletions packages/ssm/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ const defaults = {
fetchData: {}, // { contextKey: fetchKey, contextPrefix: fetchPath/ }
disablePrefetch: false,
cacheKey: 'ssm',
cacheKeyExpiry: {},
cacheExpiry: -1,
setToContext: false
}
Expand Down
1 change: 1 addition & 0 deletions packages/sts/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ const defaults = {
fetchData: {}, // { contextKey: {RoleArn, RoleSessionName} }
disablePrefetch: false,
cacheKey: 'sts',
cacheKeyExpiry: {},
cacheExpiry: -1,
setToContext: false
}
Expand Down
86 changes: 83 additions & 3 deletions packages/util/__tests__/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -268,6 +268,65 @@ test.serial('processCache should cache when not expired', async (t) => {
t.is(fetch.callCount, 1)
clearCache()
})
test.serial(
'processCache should cache when not expired w/ unix timestamp',
async (t) => {
const fetch = sinon.stub().resolves('value')
const options = {
cacheKey: 'key',
cacheExpiry: Date.now() + 100
}
processCache(options, fetch, cacheRequest)
await setTimeout(50)
const cacheValue = getCache('key').value
t.is(await cacheValue, 'value')
const { value, cache } = processCache(options, fetch, cacheRequest)
t.is(await value, 'value')
t.is(cache, true)
t.is(fetch.callCount, 1)
clearCache()
}
)
test.serial(
'processCache should cache when not expired using cacheKeyExpire',
async (t) => {
const fetch = sinon.stub().resolves('value')
const options = {
cacheKey: 'key',
cacheExpiry: 0,
cacheKeyExpiry: { key: Date.now() + 100 }
}
processCache(options, fetch, cacheRequest)
await setTimeout(50)
const cacheValue = getCache('key').value
t.is(await cacheValue, 'value')
const { value, cache } = processCache(options, fetch, cacheRequest)
t.is(await value, 'value')
t.is(cache, true)
t.is(fetch.callCount, 1)
clearCache()
}
)
test.serial(
'processCache should cache when not expired using cacheKeyExpire w/ unix timestamp',
async (t) => {
const fetch = sinon.stub().resolves('value')
const options = {
cacheKey: 'key',
cacheExpiry: Date.now() + 0,
cacheKeyExpiry: { key: Date.now() + 100 }
}
processCache(options, fetch, cacheRequest)
await setTimeout(50)
const cacheValue = getCache('key').value
t.is(await cacheValue, 'value')
const { value, cache } = processCache(options, fetch, cacheRequest)
t.is(await value, 'value')
t.is(cache, true)
t.is(fetch.callCount, 1)
clearCache()
}
)

test.serial(
'processCache should clear and re-fetch modified cache',
Expand Down Expand Up @@ -331,20 +390,41 @@ test.serial(
test.serial('processCache should cache and expire', async (t) => {
const fetch = sinon.stub().resolves('value')
const options = {
cacheKey: 'key',
cacheKey: 'key-cache-expire',
cacheExpiry: 150
}
processCache(options, fetch, cacheRequest)
await setTimeout(100)
let cache = getCache('key')
let cache = getCache('key-cache-expire')
t.not(cache, undefined)
await setTimeout(250) // expire twice
cache = getCache('key')
cache = getCache('key-cache-expire')
t.true(cache.expiry > Date.now())
t.is(fetch.callCount, 3)
clearCache()
})

test.serial(
'processCache should cache and expire w/ unix timestamp',
async (t) => {
const fetch = sinon.stub().resolves('value')
const options = {
cacheKey: 'key-cache-unix-expire',
cacheExpiry: Date.now() + 155
}
processCache(options, fetch, cacheRequest)
await setTimeout(100)
let cache = getCache('key-cache-unix-expire')
t.not(cache, undefined)
await setTimeout(250) // expire once, then doesn't cache
cache = getCache('key-cache-unix-expire')

t.true(cache.expiry < Date.now())
t.is(fetch.callCount, 3)
clearCache()
}
)

test.serial('processCache should clear single key cache', async (t) => {
const fetch = sinon.stub().resolves('value')
processCache(
Expand Down
Loading

0 comments on commit b2d70e8

Please sign in to comment.