Skip to content

Commit

Permalink
feat(parameters): SecretsProvider support (#1206)
Browse files Browse the repository at this point in the history
* wip: SecretsProvider

* feat: SecretsProvider

* refactor: types & auto-transform single

* test: unit tests

* Update packages/parameters/src/BaseProvider.ts

* refactor: readability
  • Loading branch information
dreamorosi authored Jan 5, 2023
1 parent 6a37b70 commit 02516b7
Show file tree
Hide file tree
Showing 11 changed files with 966 additions and 12 deletions.
666 changes: 664 additions & 2 deletions package-lock.json

Large diffs are not rendered by default.

9 changes: 7 additions & 2 deletions packages/parameters/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -52,5 +52,10 @@
"secrets",
"serverless",
"nodejs"
]
}
],
"devDependencies": {
"@aws-sdk/client-secrets-manager": "^3.238.0",
"aws-sdk-client-mock": "^2.0.1",
"aws-sdk-client-mock-jest": "^2.0.1"
}
}
8 changes: 5 additions & 3 deletions packages/parameters/src/BaseProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { ExpirableValue } from './ExpirableValue';
import { TRANSFORM_METHOD_BINARY, TRANSFORM_METHOD_JSON } from './constants';
import { GetParameterError, TransformParameterError } from './Exceptions';
import type { BaseProviderInterface, GetMultipleOptionsInterface, GetOptionsInterface, TransformOptions } from './types';
import type { SecretsGetOptionsInterface } from './types/SecretsProvider';

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

if (value && configs.transform) {
value = transformValue(value, configs.transform, true);
value = transformValue(value, configs.transform, true, name);
}

if (value) {
Expand Down Expand Up @@ -130,7 +132,7 @@ abstract class BaseProvider implements BaseProviderInterface {

}

const transformValue = (value: string | Uint8Array | undefined, transform: TransformOptions, throwOnTransformError: boolean, key: string = ''): string | Record<string, unknown> | undefined => {
const transformValue = (value: string | Uint8Array | undefined, transform: TransformOptions, throwOnTransformError: boolean, key: string): string | Record<string, unknown> | undefined => {
try {
const normalizedTransform = transform.toLowerCase();
if (
Expand Down
58 changes: 58 additions & 0 deletions packages/parameters/src/secrets/SecretsProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { BaseProvider } from '../BaseProvider';
import {
SecretsManagerClient,
GetSecretValueCommand
} from '@aws-sdk/client-secrets-manager';
import type { GetSecretValueCommandInput } from '@aws-sdk/client-secrets-manager';
import type {
SecretsProviderOptions,
SecretsGetOptionsInterface
} from '../types/SecretsProvider';

class SecretsProvider extends BaseProvider {
public client: SecretsManagerClient;

public constructor (config?: SecretsProviderOptions) {
super();

const clientConfig = config?.clientConfig || {};
this.client = new SecretsManagerClient(clientConfig);
}

public async get(
name: string,
options?: SecretsGetOptionsInterface
): Promise<undefined | string | Uint8Array | Record<string, unknown>> {
return super.get(name, options);
}

protected async _get(
name: string,
options?: SecretsGetOptionsInterface
): Promise<string | Uint8Array | undefined> {
const sdkOptions: GetSecretValueCommandInput = {
...(options?.sdkOptions || {}),
SecretId: name,
};

const result = await this.client.send(new GetSecretValueCommand(sdkOptions));

if (result.SecretString) return result.SecretString;

return result.SecretBinary;
}

/**
* Retrieving multiple parameter values is not supported with AWS Secrets Manager.
*/
protected async _getMultiple(
_path: string,
_options?: unknown
): Promise<Record<string, string | undefined>> {
throw new Error('Method not implemented.');
}
}

export {
SecretsProvider,
};
15 changes: 15 additions & 0 deletions packages/parameters/src/secrets/getSecret.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import { DEFAULT_PROVIDERS } from '../BaseProvider';
import { SecretsProvider } from './SecretsProvider';
import type { SecretsGetOptionsInterface } from '../types/SecretsProvider';

const getSecret = async (name: string, options?: SecretsGetOptionsInterface): Promise<undefined | string | Uint8Array | Record<string, unknown>> => {
if (!DEFAULT_PROVIDERS.hasOwnProperty('secrets')) {
DEFAULT_PROVIDERS.secrets = new SecretsProvider();
}

return DEFAULT_PROVIDERS.secrets.get(name, options);
};

export {
getSecret
};
2 changes: 2 additions & 0 deletions packages/parameters/src/secrets/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './SecretsProvider';
export * from './getSecret';
6 changes: 1 addition & 5 deletions packages/parameters/src/types/BaseProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,7 @@ interface GetOptionsInterface {
transform?: TransformOptions
}

interface GetMultipleOptionsInterface {
maxAge?: number
forceFetch?: boolean
sdkOptions?: unknown
transform?: string
interface GetMultipleOptionsInterface extends GetOptionsInterface {
throwOnTransformError?: boolean
}

Expand Down
15 changes: 15 additions & 0 deletions packages/parameters/src/types/SecretsProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
import type { GetOptionsInterface } from './BaseProvider';
import type { SecretsManagerClientConfig, GetSecretValueCommandInput } from '@aws-sdk/client-secrets-manager';

interface SecretsProviderOptions {
clientConfig?: SecretsManagerClientConfig
}

interface SecretsGetOptionsInterface extends GetOptionsInterface {
sdkOptions?: Omit<Partial<GetSecretValueCommandInput>, 'SecretId'>
}

export type {
SecretsProviderOptions,
SecretsGetOptionsInterface,
};
15 changes: 15 additions & 0 deletions packages/parameters/tests/unit/BaseProvider.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,21 @@ describe('Class: BaseProvider', () => {
expect(value).toEqual('my-value');

});

test('when called with an auto transform, and the value is a valid JSON, it returns the parsed value', async () => {

// Prepare
const mockData = JSON.stringify({ foo: 'bar' });
const provider = new TestProvider();
jest.spyOn(provider, '_get').mockImplementation(() => new Promise((resolve, _reject) => resolve(mockData)));

// Act
const value = await provider.get('my-parameter.json', { transform: 'auto' });

// Assess
expect(value).toStrictEqual({ foo: 'bar' });

});

});

Expand Down
122 changes: 122 additions & 0 deletions packages/parameters/tests/unit/SecretsProvider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/**
* Test SecretsProvider class
*
* @group unit/parameters/SecretsProvider/class
*/
import { SecretsProvider } from '../../src/secrets';
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
import type { GetSecretValueCommandInput } from '@aws-sdk/client-secrets-manager';
import { mockClient } from 'aws-sdk-client-mock';
import 'aws-sdk-client-mock-jest';

const encoder = new TextEncoder();

describe('Class: SecretsProvider', () => {

const client = mockClient(SecretsManagerClient);

beforeEach(() => {
jest.clearAllMocks();
});

describe('Method: _get', () => {

test('when called with only a name, it gets the secret string', async () => {

// Prepare
const provider = new SecretsProvider();
const secretName = 'foo';
client.on(GetSecretValueCommand).resolves({
SecretString: 'bar',
});

// Act
const result = await provider.get(secretName);

// Assess
expect(result).toBe('bar');

});

test('when called with only a name, it gets the secret binary', async () => {

// Prepare
const provider = new SecretsProvider();
const secretName = 'foo';
const mockData = encoder.encode('my-value');
client.on(GetSecretValueCommand).resolves({
SecretBinary: mockData,
});

// Act
const result = await provider.get(secretName);

// Assess
expect(result).toBe(mockData);

});

test('when called with a name and sdkOptions, it gets the secret using the options provided', async () => {

// Prepare
const provider = new SecretsProvider();
const secretName = 'foo';
client.on(GetSecretValueCommand).resolves({
SecretString: 'bar',
});

// Act
await provider.get(secretName, {
sdkOptions: {
VersionId: 'test-version',
}
});

// Assess
expect(client).toReceiveCommandWith(GetSecretValueCommand, {
SecretId: secretName,
VersionId: 'test-version',
});

});

test('when called with sdkOptions that override arguments passed to the method, it gets the secret using the arguments', async () => {

// Prepare
const provider = new SecretsProvider();
const secretName = 'foo';
client.on(GetSecretValueCommand).resolves({
SecretString: 'bar',
});

// Act
await provider.get(secretName, {
sdkOptions: {
SecretId: 'test-secret',
} as unknown as GetSecretValueCommandInput,
});

// Assess
expect(client).toReceiveCommandWith(GetSecretValueCommand, {
SecretId: secretName,
});

});

});

describe('Method: _getMultiple', () => {

test('when called, it throws an error', async () => {

// Prepare
const provider = new SecretsProvider();

// Act & Assess
await expect(provider.getMultiple('foo')).rejects.toThrow('Method not implemented.');

});

});

});
62 changes: 62 additions & 0 deletions packages/parameters/tests/unit/getSecret.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/**
* Test getSecret function
*
* @group unit/parameters/SecretsProvider/getSecret/function
*/
import { DEFAULT_PROVIDERS } from '../../src/BaseProvider';
import { SecretsProvider, getSecret } from '../../src/secrets';
import { SecretsManagerClient, GetSecretValueCommand } from '@aws-sdk/client-secrets-manager';
import { mockClient } from 'aws-sdk-client-mock';
import 'aws-sdk-client-mock-jest';

const encoder = new TextEncoder();

describe('Function: getSecret', () => {

const client = mockClient(SecretsManagerClient);

beforeEach(() => {
jest.clearAllMocks();
});

test('when called and a default provider doesn\'t exist, it instantiates one and returns the value', async () => {

// Prepare
const secretName = 'foo';
const secretValue = 'bar';
client.on(GetSecretValueCommand).resolves({
SecretString: secretValue,
});

// Act
const result = await getSecret(secretName);

// Assess
expect(client).toReceiveCommandWith(GetSecretValueCommand, { SecretId: secretName });
expect(result).toBe(secretValue);

});

test('when called and a default provider exists, it uses it and returns the value', async () => {

// Prepare
const provider = new SecretsProvider();
DEFAULT_PROVIDERS.secrets = provider;
const secretName = 'foo';
const secretValue = 'bar';
const binary = encoder.encode(secretValue);
client.on(GetSecretValueCommand).resolves({
SecretBinary: binary,
});

// Act
const result = await getSecret(secretName);

// Assess
expect(client).toReceiveCommandWith(GetSecretValueCommand, { SecretId: secretName });
expect(result).toStrictEqual(binary);
expect(DEFAULT_PROVIDERS.secrets).toBe(provider);

});

});

0 comments on commit 02516b7

Please sign in to comment.