Skip to content

Commit 02516b7

Browse files
authored
feat(parameters): SecretsProvider support (#1206)
* wip: SecretsProvider * feat: SecretsProvider * refactor: types & auto-transform single * test: unit tests * Update packages/parameters/src/BaseProvider.ts * refactor: readability
1 parent 6a37b70 commit 02516b7

11 files changed

+966
-12
lines changed

Diff for: package-lock.json

+664-2
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Diff for: packages/parameters/package.json

+7-2
Original file line numberDiff line numberDiff line change
@@ -52,5 +52,10 @@
5252
"secrets",
5353
"serverless",
5454
"nodejs"
55-
]
56-
}
55+
],
56+
"devDependencies": {
57+
"@aws-sdk/client-secrets-manager": "^3.238.0",
58+
"aws-sdk-client-mock": "^2.0.1",
59+
"aws-sdk-client-mock-jest": "^2.0.1"
60+
}
61+
}

Diff for: packages/parameters/src/BaseProvider.ts

+5-3
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { ExpirableValue } from './ExpirableValue';
55
import { TRANSFORM_METHOD_BINARY, TRANSFORM_METHOD_JSON } from './constants';
66
import { GetParameterError, TransformParameterError } from './Exceptions';
77
import type { BaseProviderInterface, GetMultipleOptionsInterface, GetOptionsInterface, TransformOptions } from './types';
8+
import type { SecretsGetOptionsInterface } from './types/SecretsProvider';
89

910
// These providers are dinamycally intialized on first use of the helper functions
1011
const DEFAULT_PROVIDERS: Record<string, BaseProvider> = {};
@@ -38,8 +39,9 @@ abstract class BaseProvider implements BaseProviderInterface {
3839
* this should be an acceptable tradeoff.
3940
*
4041
* @param {string} name - Parameter name
41-
* @param {GetOptionsInterface} options - Options to configure maximum age, trasformation, AWS SDK options, or force fetch
42+
* @param {GetOptionsInterface|SecretsGetOptionsInterface} options - Options to configure maximum age, trasformation, AWS SDK options, or force fetch
4243
*/
44+
public async get(name: string, options?: SecretsGetOptionsInterface): Promise<undefined | string | Uint8Array | Record<string, unknown>>;
4345
public async get(name: string, options?: GetOptionsInterface): Promise<undefined | string | Uint8Array | Record<string, unknown>> {
4446
const configs = new GetOptions(options);
4547
const key = [ name, configs.transform ].toString();
@@ -58,7 +60,7 @@ abstract class BaseProvider implements BaseProviderInterface {
5860
}
5961

6062
if (value && configs.transform) {
61-
value = transformValue(value, configs.transform, true);
63+
value = transformValue(value, configs.transform, true, name);
6264
}
6365

6466
if (value) {
@@ -130,7 +132,7 @@ abstract class BaseProvider implements BaseProviderInterface {
130132

131133
}
132134

133-
const transformValue = (value: string | Uint8Array | undefined, transform: TransformOptions, throwOnTransformError: boolean, key: string = ''): string | Record<string, unknown> | undefined => {
135+
const transformValue = (value: string | Uint8Array | undefined, transform: TransformOptions, throwOnTransformError: boolean, key: string): string | Record<string, unknown> | undefined => {
134136
try {
135137
const normalizedTransform = transform.toLowerCase();
136138
if (

Diff for: packages/parameters/src/secrets/SecretsProvider.ts

+58
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
import { BaseProvider } from '../BaseProvider';
2+
import {
3+
SecretsManagerClient,
4+
GetSecretValueCommand
5+
} from '@aws-sdk/client-secrets-manager';
6+
import type { GetSecretValueCommandInput } from '@aws-sdk/client-secrets-manager';
7+
import type {
8+
SecretsProviderOptions,
9+
SecretsGetOptionsInterface
10+
} from '../types/SecretsProvider';
11+
12+
class SecretsProvider extends BaseProvider {
13+
public client: SecretsManagerClient;
14+
15+
public constructor (config?: SecretsProviderOptions) {
16+
super();
17+
18+
const clientConfig = config?.clientConfig || {};
19+
this.client = new SecretsManagerClient(clientConfig);
20+
}
21+
22+
public async get(
23+
name: string,
24+
options?: SecretsGetOptionsInterface
25+
): Promise<undefined | string | Uint8Array | Record<string, unknown>> {
26+
return super.get(name, options);
27+
}
28+
29+
protected async _get(
30+
name: string,
31+
options?: SecretsGetOptionsInterface
32+
): Promise<string | Uint8Array | undefined> {
33+
const sdkOptions: GetSecretValueCommandInput = {
34+
...(options?.sdkOptions || {}),
35+
SecretId: name,
36+
};
37+
38+
const result = await this.client.send(new GetSecretValueCommand(sdkOptions));
39+
40+
if (result.SecretString) return result.SecretString;
41+
42+
return result.SecretBinary;
43+
}
44+
45+
/**
46+
* Retrieving multiple parameter values is not supported with AWS Secrets Manager.
47+
*/
48+
protected async _getMultiple(
49+
_path: string,
50+
_options?: unknown
51+
): Promise<Record<string, string | undefined>> {
52+
throw new Error('Method not implemented.');
53+
}
54+
}
55+
56+
export {
57+
SecretsProvider,
58+
};

Diff for: packages/parameters/src/secrets/getSecret.ts

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import { DEFAULT_PROVIDERS } from '../BaseProvider';
2+
import { SecretsProvider } from './SecretsProvider';
3+
import type { SecretsGetOptionsInterface } from '../types/SecretsProvider';
4+
5+
const getSecret = async (name: string, options?: SecretsGetOptionsInterface): Promise<undefined | string | Uint8Array | Record<string, unknown>> => {
6+
if (!DEFAULT_PROVIDERS.hasOwnProperty('secrets')) {
7+
DEFAULT_PROVIDERS.secrets = new SecretsProvider();
8+
}
9+
10+
return DEFAULT_PROVIDERS.secrets.get(name, options);
11+
};
12+
13+
export {
14+
getSecret
15+
};

Diff for: packages/parameters/src/secrets/index.ts

+2
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export * from './SecretsProvider';
2+
export * from './getSecret';

Diff for: packages/parameters/src/types/BaseProvider.ts

+1-5
Original file line numberDiff line numberDiff line change
@@ -7,11 +7,7 @@ interface GetOptionsInterface {
77
transform?: TransformOptions
88
}
99

10-
interface GetMultipleOptionsInterface {
11-
maxAge?: number
12-
forceFetch?: boolean
13-
sdkOptions?: unknown
14-
transform?: string
10+
interface GetMultipleOptionsInterface extends GetOptionsInterface {
1511
throwOnTransformError?: boolean
1612
}
1713

Diff for: packages/parameters/src/types/SecretsProvider.ts

+15
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import type { GetOptionsInterface } from './BaseProvider';
2+
import type { SecretsManagerClientConfig, GetSecretValueCommandInput } from '@aws-sdk/client-secrets-manager';
3+
4+
interface SecretsProviderOptions {
5+
clientConfig?: SecretsManagerClientConfig
6+
}
7+
8+
interface SecretsGetOptionsInterface extends GetOptionsInterface {
9+
sdkOptions?: Omit<Partial<GetSecretValueCommandInput>, 'SecretId'>
10+
}
11+
12+
export type {
13+
SecretsProviderOptions,
14+
SecretsGetOptionsInterface,
15+
};

Diff for: packages/parameters/tests/unit/BaseProvider.test.ts

+15
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,21 @@ describe('Class: BaseProvider', () => {
217217
expect(value).toEqual('my-value');
218218

219219
});
220+
221+
test('when called with an auto transform, and the value is a valid JSON, it returns the parsed value', async () => {
222+
223+
// Prepare
224+
const mockData = JSON.stringify({ foo: 'bar' });
225+
const provider = new TestProvider();
226+
jest.spyOn(provider, '_get').mockImplementation(() => new Promise((resolve, _reject) => resolve(mockData)));
227+
228+
// Act
229+
const value = await provider.get('my-parameter.json', { transform: 'auto' });
230+
231+
// Assess
232+
expect(value).toStrictEqual({ foo: 'bar' });
233+
234+
});
220235

221236
});
222237

+122
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
/**
2+
* Test SecretsProvider class
3+
*
4+
* @group unit/parameters/SecretsProvider/class
5+
*/
6+
import { SecretsProvider } from '../../src/secrets';
7+
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
8+
import type { GetSecretValueCommandInput } from '@aws-sdk/client-secrets-manager';
9+
import { mockClient } from 'aws-sdk-client-mock';
10+
import 'aws-sdk-client-mock-jest';
11+
12+
const encoder = new TextEncoder();
13+
14+
describe('Class: SecretsProvider', () => {
15+
16+
const client = mockClient(SecretsManagerClient);
17+
18+
beforeEach(() => {
19+
jest.clearAllMocks();
20+
});
21+
22+
describe('Method: _get', () => {
23+
24+
test('when called with only a name, it gets the secret string', async () => {
25+
26+
// Prepare
27+
const provider = new SecretsProvider();
28+
const secretName = 'foo';
29+
client.on(GetSecretValueCommand).resolves({
30+
SecretString: 'bar',
31+
});
32+
33+
// Act
34+
const result = await provider.get(secretName);
35+
36+
// Assess
37+
expect(result).toBe('bar');
38+
39+
});
40+
41+
test('when called with only a name, it gets the secret binary', async () => {
42+
43+
// Prepare
44+
const provider = new SecretsProvider();
45+
const secretName = 'foo';
46+
const mockData = encoder.encode('my-value');
47+
client.on(GetSecretValueCommand).resolves({
48+
SecretBinary: mockData,
49+
});
50+
51+
// Act
52+
const result = await provider.get(secretName);
53+
54+
// Assess
55+
expect(result).toBe(mockData);
56+
57+
});
58+
59+
test('when called with a name and sdkOptions, it gets the secret using the options provided', async () => {
60+
61+
// Prepare
62+
const provider = new SecretsProvider();
63+
const secretName = 'foo';
64+
client.on(GetSecretValueCommand).resolves({
65+
SecretString: 'bar',
66+
});
67+
68+
// Act
69+
await provider.get(secretName, {
70+
sdkOptions: {
71+
VersionId: 'test-version',
72+
}
73+
});
74+
75+
// Assess
76+
expect(client).toReceiveCommandWith(GetSecretValueCommand, {
77+
SecretId: secretName,
78+
VersionId: 'test-version',
79+
});
80+
81+
});
82+
83+
test('when called with sdkOptions that override arguments passed to the method, it gets the secret using the arguments', async () => {
84+
85+
// Prepare
86+
const provider = new SecretsProvider();
87+
const secretName = 'foo';
88+
client.on(GetSecretValueCommand).resolves({
89+
SecretString: 'bar',
90+
});
91+
92+
// Act
93+
await provider.get(secretName, {
94+
sdkOptions: {
95+
SecretId: 'test-secret',
96+
} as unknown as GetSecretValueCommandInput,
97+
});
98+
99+
// Assess
100+
expect(client).toReceiveCommandWith(GetSecretValueCommand, {
101+
SecretId: secretName,
102+
});
103+
104+
});
105+
106+
});
107+
108+
describe('Method: _getMultiple', () => {
109+
110+
test('when called, it throws an error', async () => {
111+
112+
// Prepare
113+
const provider = new SecretsProvider();
114+
115+
// Act & Assess
116+
await expect(provider.getMultiple('foo')).rejects.toThrow('Method not implemented.');
117+
118+
});
119+
120+
});
121+
122+
});

Diff for: packages/parameters/tests/unit/getSecret.test.ts

+62
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
/**
2+
* Test getSecret function
3+
*
4+
* @group unit/parameters/SecretsProvider/getSecret/function
5+
*/
6+
import { DEFAULT_PROVIDERS } from '../../src/BaseProvider';
7+
import { SecretsProvider, getSecret } from '../../src/secrets';
8+
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
9+
import { mockClient } from 'aws-sdk-client-mock';
10+
import 'aws-sdk-client-mock-jest';
11+
12+
const encoder = new TextEncoder();
13+
14+
describe('Function: getSecret', () => {
15+
16+
const client = mockClient(SecretsManagerClient);
17+
18+
beforeEach(() => {
19+
jest.clearAllMocks();
20+
});
21+
22+
test('when called and a default provider doesn\'t exist, it instantiates one and returns the value', async () => {
23+
24+
// Prepare
25+
const secretName = 'foo';
26+
const secretValue = 'bar';
27+
client.on(GetSecretValueCommand).resolves({
28+
SecretString: secretValue,
29+
});
30+
31+
// Act
32+
const result = await getSecret(secretName);
33+
34+
// Assess
35+
expect(client).toReceiveCommandWith(GetSecretValueCommand, { SecretId: secretName });
36+
expect(result).toBe(secretValue);
37+
38+
});
39+
40+
test('when called and a default provider exists, it uses it and returns the value', async () => {
41+
42+
// Prepare
43+
const provider = new SecretsProvider();
44+
DEFAULT_PROVIDERS.secrets = provider;
45+
const secretName = 'foo';
46+
const secretValue = 'bar';
47+
const binary = encoder.encode(secretValue);
48+
client.on(GetSecretValueCommand).resolves({
49+
SecretBinary: binary,
50+
});
51+
52+
// Act
53+
const result = await getSecret(secretName);
54+
55+
// Assess
56+
expect(client).toReceiveCommandWith(GetSecretValueCommand, { SecretId: secretName });
57+
expect(result).toStrictEqual(binary);
58+
expect(DEFAULT_PROVIDERS.secrets).toBe(provider);
59+
60+
});
61+
62+
});

0 commit comments

Comments
 (0)