Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(parameters): SecretsProvider support #1206

Merged
merged 6 commits into from
Jan 5, 2023
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
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|DynamoDBGetOptionsInterface} options - Options to configure maximum age, trasformation, AWS SDK options, or force fetch
dreamorosi marked this conversation as resolved.
Show resolved Hide resolved
*/
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
57 changes: 57 additions & 0 deletions packages/parameters/src/secrets/SecretsProvider.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
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 = {
SecretId: name,
};
if (options?.sdkOptions) {
this.removeNonOverridableOptions(options.sdkOptions as GetSecretValueCommandInput);
Object.assign(sdkOptions, options.sdkOptions);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explicit arguments passed to the constructor will take precedence over ones passed to the method.

I looked at the comment above the removeNonOverridableOptions function and instead of explicitly deleting properties, spread options.sdkOptions first (if any) and then overwrite them with name would work:

const sdkOptions: GetSecretValueCommandInput = {
  ...(options?.sdkOptions || {}),
  SecretId: name,
};

It may be less readable, but I like it more than duplicating removeNonOverridableOptions in every provider. Am I missing something?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good point, I have applied your suggestion. Thank you!


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.');
}

/**
* Explicit arguments passed to the constructor will take precedence over ones passed to the method.
* For users who consume the library with TypeScript, this will be enforced by the type system. However,
* for JavaScript users, we need to manually delete the properties that are not allowed to be overridden.
*/
protected removeNonOverridableOptions(options: GetSecretValueCommandInput): void {
if (options.hasOwnProperty('SecretId')) {
delete options.SecretId;
}
}
}

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
128 changes: 128 additions & 0 deletions packages/parameters/tests/unit/SecretsProvider.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/**
* 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',
});

});

});

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.');

});

});

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

class DummyProvider extends SecretsProvider {
public removeNonOverridableOptions(options: GetSecretValueCommandInput): void {
super.removeNonOverridableOptions(options);
}
}

test('when called with a valid GetSecretValueCommandInput, it removes the non-overridable options', () => {

// Prepare
const provider = new DummyProvider();
const options: GetSecretValueCommandInput = {
SecretId: 'test-secret',
VersionId: 'test-version',
};

// Act
provider.removeNonOverridableOptions(options);

// Assess
expect(options).toEqual({
VersionId: 'test-version',
});

});

});

});
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);

});

});