Skip to content

Commit 13a2e67

Browse files
authored
feat: allow custom JSON format in secret value (#602)
1 parent 95a5cf9 commit 13a2e67

7 files changed

Lines changed: 145 additions & 17 deletions

File tree

common/lib/authentication/aws_secrets_manager_plugin.ts

Lines changed: 35 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import {
1818
GetSecretValueCommand,
19+
GetSecretValueCommandOutput,
1920
SecretsManagerClient,
2021
SecretsManagerClientConfig,
2122
SecretsManagerServiceException
@@ -39,6 +40,9 @@ export class AwsSecretsManagerPlugin extends AbstractConnectionPlugin implements
3940
private static SECRETS_ARN_PATTERN: RegExp = new RegExp("^arn:aws:secretsmanager:(?<region>[^:\\n]*):[^:\\n]*:([^:/\\n]*[:/])?(.*)$");
4041
private readonly pluginService: PluginService;
4142
private readonly fetchCredentialsCounter;
43+
private readonly expirationSec: number;
44+
private readonly usernameKey: string;
45+
private readonly passwordKey: string;
4246
private secret: Secret | null = null;
4347
static secretsCache: Map<string, Secret> = new Map();
4448
secretKey: SecretCacheKey;
@@ -51,12 +55,25 @@ export class AwsSecretsManagerPlugin extends AbstractConnectionPlugin implements
5155
const secretId = WrapperProperties.SECRET_ID.get(properties);
5256
const endpoint = WrapperProperties.SECRET_ENDPOINT.get(properties);
5357
let region = WrapperProperties.SECRET_REGION.get(properties);
58+
59+
this.expirationSec = WrapperProperties.SECRET_EXPIRATION_SEC.get(properties);
60+
this.usernameKey = WrapperProperties.SECRET_USERNAME_PROPERTY.get(properties);
61+
this.passwordKey = WrapperProperties.SECRET_PASSWORD_PROPERTY.get(properties);
62+
5463
const config: SecretsManagerClientConfig = {};
5564

5665
if (!secretId) {
5766
throw new AwsWrapperError(Messages.get("AwsSecretsManagerConnectionPlugin.missingRequiredConfigParameter", WrapperProperties.SECRET_ID.name));
5867
}
5968

69+
if (!this.usernameKey || !this.passwordKey) {
70+
throw new AwsWrapperError(Messages.get("AwsSecretsManagerConnectionPlugin.emptyPropertyKeys"));
71+
}
72+
73+
if (this.expirationSec < 0) {
74+
throw new AwsWrapperError(Messages.get("AwsSecretsManagerConnectionPlugin.invalidExpirationTime", String(this.expirationSec)));
75+
}
76+
6077
if (!region) {
6178
const groups = secretId.match(AwsSecretsManagerPlugin.SECRETS_ARN_PATTERN)?.groups;
6279
if (groups?.region) {
@@ -139,12 +156,15 @@ export class AwsSecretsManagerPlugin extends AbstractConnectionPlugin implements
139156
let fetched = false;
140157
this.secret = AwsSecretsManagerPlugin.secretsCache.get(JSON.stringify(this.secretKey)) ?? null;
141158

142-
if (!this.secret || forceRefresh) {
159+
if (!this.secret || this.secret.isExpired() || forceRefresh) {
143160
try {
144161
this.secret = await this.fetchLatestCredentials();
145162
fetched = true;
146163
AwsSecretsManagerPlugin.secretsCache.set(JSON.stringify(this.secretKey), this.secret);
147164
} catch (error: any) {
165+
if (error instanceof AwsWrapperError) {
166+
throw error;
167+
}
148168
if (error instanceof SecretsManagerServiceException) {
149169
logAndThrowError(Messages.get("AwsSecretsManagerConnectionPlugin.failedToFetchDbCredentials", error.message));
150170
} else if (error instanceof Error && error.message.includes("AWS SDK error")) {
@@ -163,12 +183,14 @@ export class AwsSecretsManagerPlugin extends AbstractConnectionPlugin implements
163183
SecretId: this.secretKey.secretId
164184
};
165185
const command = new GetSecretValueCommand(commandInput);
166-
const result = await this.secretsManagerClient.send(command);
167-
const secret = new Secret(JSON.parse(result.SecretString ?? "").username, JSON.parse(result.SecretString ?? "").password);
168-
if (secret && secret.username && secret.password) {
169-
return secret;
186+
const result: GetSecretValueCommandOutput = await this.secretsManagerClient.send(command);
187+
const secretJson: string = JSON.parse(result.SecretString ?? "");
188+
const username = secretJson[this.usernameKey];
189+
const password = secretJson[this.passwordKey];
190+
if (!username || !password) {
191+
throw new AwsWrapperError(Messages.get("AwsSecretsManagerConnectionPlugin.emptySecretValue", this.usernameKey, this.passwordKey));
170192
}
171-
throw new AwsWrapperError(Messages.get("AwsSecretsManagerConnectionPlugin.failedToFetchDbCredentials"));
193+
return new Secret(username, password, this.expirationSec);
172194
}
173195

174196
releaseResources(): Promise<void> {
@@ -198,9 +220,15 @@ export class SecretCacheKey {
198220
export class Secret {
199221
readonly username: string;
200222
readonly password: string;
223+
readonly expirationTime: number;
201224

202-
constructor(username: string, password: string) {
225+
constructor(username: string, password: string, expirationSec: number) {
203226
this.username = username;
204227
this.password = password;
228+
this.expirationTime = Date.now() + expirationSec * 1000;
229+
}
230+
231+
isExpired(): boolean {
232+
return Date.now() >= this.expirationTime;
205233
}
206234
}

common/lib/utils/messages.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,12 @@ const MESSAGES: Record<string, string> = {
4040
"HostInfo.weightLessThanZero": "A HostInfo object was created with a weight value less than 0.",
4141
"AwsSecretsManagerConnectionPlugin.failedToFetchDbCredentials":
4242
"Was not able to either fetch or read the database credentials from AWS Secrets Manager due to error: %s. Ensure the correct secretId and region properties have been provided.",
43+
"AwsSecretsManagerConnectionPlugin.emptySecretValue":
44+
"Unable to fetch database credentials with the given username key and password key. Please review the values specified in secretUsernameProperty (%s) and secretPasswordProperty (%s) and ensure they match the Secrets Manager JSON format.",
4345
"AwsSecretsManagerConnectionPlugin.missingRequiredConfigParameter": "Configuration parameter '%s' is required.",
46+
"AwsSecretsManagerConnectionPlugin.emptyPropertyKeys":
47+
"secretUsernameProperty and secretPasswordProperty cannot be empty strings. Please ensure they are correct and match the Secret value's JSON format.",
48+
"AwsSecretsManagerConnectionPlugin.invalidExpirationTime": "The expiration time (%s) must be set to a non-negative value.",
4449
"AwsSecretsManagerConnectionPlugin.unhandledError": "Unhandled error: '%s'",
4550
"AwsSecretsManagerConnectionPlugin.endpointOverrideInvalidConnection": "A connection to the provided endpoint could not be established: '%s'.",
4651
"ClusterAwareReaderFailoverHandler.invalidTopology": "'%s' was called with an invalid (null or empty) topology",

common/lib/wrapper_property.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -168,6 +168,21 @@ export class WrapperProperties {
168168
static readonly SECRET_ID = new WrapperProperty<string>("secretId", "The name or the ARN of the secret to retrieve.", null);
169169
static readonly SECRET_REGION = new WrapperProperty<string>("secretRegion", "The region of the secret to retrieve.", null);
170170
static readonly SECRET_ENDPOINT = new WrapperProperty<string>("secretEndpoint", "The endpoint of the secret to retrieve.", null);
171+
static readonly SECRET_EXPIRATION_SEC = new WrapperProperty<number>(
172+
"secretExpirationSec",
173+
"Secrets Manager credentials' expiration time in seconds.",
174+
870
175+
);
176+
static readonly SECRET_USERNAME_PROPERTY = new WrapperProperty<string>(
177+
"secretUsernameProperty",
178+
"Set this value to be the key in the JSON secret that contains the username for database connection.",
179+
"username"
180+
);
181+
static readonly SECRET_PASSWORD_PROPERTY = new WrapperProperty<string>(
182+
"secretPasswordProperty",
183+
"Set this value to be the key in the JSON secret that contains the password for database connection.",
184+
"password"
185+
);
171186

172187
static readonly FAILOVER_CLUSTER_TOPOLOGY_REFRESH_RATE_MS = new WrapperProperty<number>(
173188
"failoverClusterTopologyRefreshRateMs",

docs/using-the-nodejs-wrapper/using-plugins/UsingTheAwsSecretsManagerPlugin.md

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,24 @@ The following properties are required for the AWS Secrets Manager Connection Plu
2020
> [!NOTE]
2121
> To use this plugin, you will need to set the following AWS Secrets Manager specific parameters.
2222
23-
| Parameter | Value | Required | Description | Example | Default Value |
24-
| ---------------- | :----: | :---------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :----------------------- | ------------- |
25-
| `secretId` | String | Yes | Set this value to be the secret name or the secret ARN. | `secretId` | `null` |
26-
| `secretRegion` | String | Yes unless the `secretId` is an ARN | Set this value to be the region your secret is in. | `us-east-2` | `null` |
27-
| `secretEndpoint` | String | No | Set this value to be the endpoint override to retrieve your secret from. This parameter value should be in the form of a URL, with a valid protocol (ex. `https://`) and domain (ex. `localhost`). A port number is not required. | `https://localhost:1234` | `null` |
23+
| Parameter | Value | Required | Description | Example | Default Value |
24+
| ------------------------ | :-----: | :---------------------------------: | :-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | :----------------------- | ------------- |
25+
| `secretId` | String | Yes | Set this value to be the secret name or the secret ARN. | `secretId` | `null` |
26+
| `secretRegion` | String | Yes unless the `secretId` is an ARN | Set this value to be the region your secret is in. | `us-east-2` | `null` |
27+
| `secretEndpoint` | String | No | Set this value to be the endpoint override to retrieve your secret from. This parameter value should be in the form of a URL, with a valid protocol (ex. `https://`) and domain (ex. `localhost`). A port number is not required. | `https://localhost:1234` | `null` |
28+
| `secretExpirationSec` | Integer | No | This property sets the time in seconds that secrets are cached before it is re-fetched. | `600` | `870` |
29+
| `secretUsernameProperty` | String | No | Set this value to be the key in the JSON secret that contains the username for database connection. | `db_user` | `username` |
30+
| `secretPasswordProperty` | String | No | Set this value to be the key in the JSON secret that contains the password for database connection. | `db_pass` | `password` |
2831

2932
> [!NOTE]
3033
> A Secret ARN has the following format: `arn:aws:secretsmanager:<Region>:<AccountId>:secret:SecretName-6RandomCharacters`
3134
3235
## Secret Data
3336

34-
The plugin assumes that the secret contains the following properties `username` and `password`.
37+
The secret stored in the AWS Secrets Manager should be a JSON object containing the properties `username` and `password`. If the secret contains different key names, you can specify them with the `secretUsernameProperty` and `secretPasswordProperty` parameters.
38+
39+
> [!NOTE]
40+
> Only un-nested JSON format is supported at the moment.
3541
3642
### Example
3743

examples/aws_driver_example/aws_secrets_manager_mysql_example.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ const mysqlHost = "db-identifier.XYZ.us-east-2.rds.amazonaws.com";
2020
const port = 3306;
2121
const secretId = "SecretName";
2222
const secretRegion = "us-east-1";
23+
const secretExpirationTime = 1000;
2324
/* secretId can be set as secret ARN instead. The ARN includes the secretRegion */
2425
// const secretId = "arn:aws:secretsmanager:us-east-1:AccountId:secret:SecretName-6RandomCharacters";
2526

@@ -29,7 +30,13 @@ const client = new AwsMySQLClient({
2930
port: port,
3031
secretId: secretId,
3132
secretRegion: secretRegion,
32-
plugins: "secretsManager"
33+
secretExpirationSec: secretExpirationTime,
34+
plugins: "secretsManager",
35+
// By default, the Secrets Manager plugin assumes the secret stored in the AWS Secrets Manager to be a JSON object containing the properties `username` and `password`.
36+
// If the secret contains different key names, you can specify them with the `secretUsernameProperty` and `secretPasswordProperty` parameters.
37+
// This example assumes the credentials are stored under the keys db_user and db_pass.
38+
secretUsernameProperty: "db_user",
39+
secretPasswordProperty: "db_pass"
3340
});
3441

3542
// Attempt connection.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@
3131
"mysql"
3232
],
3333
"scripts": {
34-
"check": "prettier . --check --config .prettierrc --log-level error",
34+
"check": "prettier . --check --config .prettierrc",
3535
"bench-plugin-manager": "npx tsx tests/plugin_manager_benchmarks.ts",
3636
"bench-plugins": "npx tsx tests/plugin_benchmarks.ts",
3737
"bench-plugin-manager-otel": "npx tsx tests/plugin_manager_telemetry_benchmarks.ts",

tests/unit/aws_secrets_manager_plugin.test.ts

Lines changed: 69 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,8 @@ const TEST_HOSTINFO: HostInfo = new HostInfoBuilder({
5050
hostAvailabilityStrategy: new SimpleHostAvailabilityStrategy(),
5151
host: TEST_HOST
5252
}).build();
53-
const TEST_SECRET = new Secret(TEST_USERNAME, TEST_PASSWORD);
53+
const EXPIRATION_TIME = 870;
54+
const TEST_SECRET = new Secret(TEST_USERNAME, TEST_PASSWORD, EXPIRATION_TIME);
5455
const TEST_SECRET_CACHE_KEY = new SecretCacheKey(TEST_SECRET_ID, TEST_SECRET_REGION);
5556

5657
const VALID_SECRET_RESPONSE = {
@@ -146,7 +147,7 @@ describe("testSecretsManager", () => {
146147
// In this case, the plugin will fetch the secret and will retry the connection.
147148
it.each([MYSQL_AUTH_ERROR, PG_AUTH_ERROR])("connect with new secret after trying with cached secrets", async (error) => {
148149
// Add initial cached secret to be used for a connection.
149-
AwsSecretsManagerPlugin.secretsCache.set(JSON.stringify(TEST_SECRET_CACHE_KEY), new Secret("", ""));
150+
AwsSecretsManagerPlugin.secretsCache.set(JSON.stringify(TEST_SECRET_CACHE_KEY), new Secret("", "", EXPIRATION_TIME));
150151
when(mockSecretsManagerClient.send(anything())).thenResolve(VALID_SECRET_RESPONSE);
151152
plugin.secretsManagerClient = instance(mockSecretsManagerClient);
152153

@@ -210,4 +211,70 @@ describe("testSecretsManager", () => {
210211
expect(secretKey.region).not.toBe(regionFromArn);
211212
expect(secretKey.region).toBe(expectedRegion);
212213
});
214+
215+
it("connect with custom json keys", async () => {
216+
const customSecretResponse = {
217+
SecretString: '{"db_user": "foo", "db_pass": "bar"}',
218+
$metadata: {}
219+
};
220+
const props = new Map();
221+
WrapperProperties.SECRET_ID.set(props, TEST_SECRET_ID);
222+
WrapperProperties.SECRET_REGION.set(props, TEST_SECRET_REGION);
223+
WrapperProperties.SECRET_USERNAME_PROPERTY.set(props, "db_user");
224+
WrapperProperties.SECRET_PASSWORD_PROPERTY.set(props, "db_pass");
225+
const testPlugin = new AwsSecretsManagerPlugin(instance(mockPluginService), props);
226+
when(mockSecretsManagerClient.send(anything())).thenResolve(customSecretResponse);
227+
testPlugin.secretsManagerClient = instance(mockSecretsManagerClient);
228+
229+
await testPlugin.connect(TEST_HOSTINFO, props, true, mockConnectFunction);
230+
231+
verify(mockSecretsManagerClient.send(anything())).once();
232+
expect(props.get(WrapperProperties.USER.name)).toBe("foo");
233+
expect(props.get(WrapperProperties.PASSWORD.name)).toBe("bar");
234+
});
235+
236+
it("connect with one custom json key", async () => {
237+
const customSecretResponse = {
238+
SecretString: '{"db_user": "foo", "password": "bar"}',
239+
$metadata: {}
240+
};
241+
const props = new Map();
242+
WrapperProperties.SECRET_ID.set(props, TEST_SECRET_ID);
243+
WrapperProperties.SECRET_REGION.set(props, TEST_SECRET_REGION);
244+
WrapperProperties.SECRET_USERNAME_PROPERTY.set(props, "db_user");
245+
const testPlugin = new AwsSecretsManagerPlugin(instance(mockPluginService), props);
246+
when(mockSecretsManagerClient.send(anything())).thenResolve(customSecretResponse);
247+
testPlugin.secretsManagerClient = instance(mockSecretsManagerClient);
248+
249+
await testPlugin.connect(TEST_HOSTINFO, props, true, mockConnectFunction);
250+
251+
verify(mockSecretsManagerClient.send(anything())).once();
252+
expect(props.get(WrapperProperties.USER.name)).toBe("foo");
253+
expect(props.get(WrapperProperties.PASSWORD.name)).toBe("bar");
254+
});
255+
256+
it("fetch new secret when cached secret is expired", async () => {
257+
const expiredSecret = new Secret("oldUser", "oldPass", 0);
258+
await new Promise((resolve) => setTimeout(resolve, 10));
259+
AwsSecretsManagerPlugin.secretsCache.set(JSON.stringify(TEST_SECRET_CACHE_KEY), expiredSecret);
260+
when(mockSecretsManagerClient.send(anything())).thenResolve(VALID_SECRET_RESPONSE);
261+
plugin.secretsManagerClient = instance(mockSecretsManagerClient);
262+
263+
await plugin.connect(TEST_HOSTINFO, TEST_PROPS, true, mockConnectFunction);
264+
265+
verify(mockSecretsManagerClient.send(anything())).once();
266+
expect(TEST_PROPS.get(WrapperProperties.USER.name)).toBe(TEST_USERNAME);
267+
expect(TEST_PROPS.get(WrapperProperties.PASSWORD.name)).toBe(TEST_PASSWORD);
268+
});
269+
270+
it("use cached secret when not expired", async () => {
271+
const validSecret = new Secret(TEST_USERNAME, TEST_PASSWORD, 3600);
272+
AwsSecretsManagerPlugin.secretsCache.set(JSON.stringify(TEST_SECRET_CACHE_KEY), validSecret);
273+
274+
await plugin.connect(TEST_HOSTINFO, TEST_PROPS, true, mockConnectFunction);
275+
276+
verify(mockSecretsManagerClient.send(anything())).never();
277+
expect(TEST_PROPS.get(WrapperProperties.USER.name)).toBe(TEST_USERNAME);
278+
expect(TEST_PROPS.get(WrapperProperties.PASSWORD.name)).toBe(TEST_PASSWORD);
279+
});
213280
});

0 commit comments

Comments
 (0)