node-vault-client 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,5 @@
1
1
  'use strict';
2
2
 
3
- const _ = require('lodash');
4
3
  const Lease = require('./Lease');
5
4
  const errors = require('./errors');
6
5
  const VaultApiClient = require('./VaultApiClient');
@@ -9,6 +8,10 @@ const VaultTokenAuth = require('./auth/VaultTokenAuth');
9
8
  const VaultIAMAuth = require('./auth/VaultIAMAuth');
10
9
  const VaultNodeConfig = require('./VaultNodeConfig');
11
10
  const VaultKubernetesAuth = require('./auth/VaultKubernetesAuth');
11
+ const MountResolver = require('./MountResolver');
12
+ const { rewritePath, normalizeResponse } = require('./kvTransform');
13
+
14
+ const noop = () => {};
12
15
  const vaultInstances = {};
13
16
 
14
17
  class VaultClient {
@@ -23,6 +26,12 @@ class VaultClient {
23
26
  * @param {Object} [options.api.requestOptions] - extra options merged into every HTTP
24
27
  * request (e.g. an undici `dispatcher` for a proxy/SOCKS agent or custom TLS/CA trust).
25
28
  * See {@link VaultApiClient#constructor}.
29
+ * @param {Object} [options.api.kv] - KV engine options
30
+ * @param {boolean} [options.api.kv.autoDetect=false] - When true, auto-detect KV version per mount
31
+ * @param {Object} [options.api.engines] - Mount-to-version overrides, e.g. { secret: 2, legacy: 1 }
32
+ * @param {String} [options.api.namespace] - Vault namespace applied (as `X-Vault-Namespace`) to
33
+ * every request across all auth backends. For backward compatibility `options.auth.config.namespace`
34
+ * is also honored when this is not set.
26
35
  * @param {Object} options.auth
27
36
  * @param {String} options.auth.type
28
37
  * @param {Object} options.auth.config - auth configuration variables
@@ -31,9 +40,18 @@ class VaultClient {
31
40
  constructor(options) {
32
41
  this.__log = this.__setupLogger(options.logger);
33
42
 
43
+ // Namespace is a server-routing concern that applies to every request, so it is resolved
44
+ // once here and owned by the API client (see VaultApiClient#makeRequest) rather than being
45
+ // reapplied in each auth backend. `api.namespace` is the canonical location; the legacy
46
+ // `auth.config.namespace` is still honored for backward compatibility.
47
+ const namespace = (options.api && options.api.namespace !== undefined)
48
+ ? options.api.namespace
49
+ : (options.auth && options.auth.config ? options.auth.config.namespace : undefined);
50
+
34
51
  this.__api = new VaultApiClient(
35
52
  options.api,
36
- this.__log
53
+ this.__log,
54
+ namespace
37
55
  );
38
56
 
39
57
  /**
@@ -45,7 +63,24 @@ class VaultClient {
45
63
  this.__api
46
64
  );
47
65
 
48
- this.__namespace = options.auth.config.namespace;
66
+ // KV v2 support
67
+ const kvOpts = (options.api && options.api.kv) || {};
68
+ const autoDetect = kvOpts.autoDetect === true;
69
+ const engines = (options.api && options.api.engines) || {};
70
+
71
+ // Detection (the sys/internal/ui/mounts round-trip) only happens when
72
+ // autoDetect is on. With engines set but autoDetect off, listed mounts use
73
+ // the override and any other mount is passthrough (v1) — no detection call
74
+ // is ever made. This keeps the default byte-for-byte and makes engines a
75
+ // working fallback for Vaults that deny the detection endpoint.
76
+ const resolverDisabled = !autoDetect;
77
+
78
+ // Build detectFn lazily — it uses __auth and __api which are already set above.
79
+ const detectFn = (path) => this.__detectMount(path);
80
+
81
+ this.__resolver = new MountResolver(detectFn, engines, this.__log, {
82
+ disabled: resolverDisabled,
83
+ });
49
84
  }
50
85
 
51
86
  /**
@@ -188,16 +223,121 @@ class VaultClient {
188
223
  }
189
224
 
190
225
  getHeaders(token) {
191
- if (this.__namespace) {
192
- return {
193
- 'X-Vault-Token': token.getId(),
194
- 'X-Vault-Namespace': this.__namespace
195
- }
196
- }
197
-
226
+ // Namespace is injected centrally by VaultApiClient#makeRequest, so callers only
227
+ // need to supply the token here.
198
228
  return {'X-Vault-Token': token.getId()}
199
229
  }
200
230
 
231
+ // -------------------------------------------------------------------------
232
+ // Detection helper (used by MountResolver's detectFn)
233
+ // -------------------------------------------------------------------------
234
+
235
+ /**
236
+ * Calls GET sys/internal/ui/mounts/<path> with the current auth token.
237
+ * Returns the parsed Vault response body.
238
+ * @private
239
+ */
240
+ __detectMount(path) {
241
+ return this.__auth.getAuthToken()
242
+ .then((token) => {
243
+ const detectPath = `sys/internal/ui/mounts/${path}`;
244
+ return this.__api.makeRequest('GET', detectPath, null, this.getHeaders(token));
245
+ });
246
+ }
247
+
248
+ // -------------------------------------------------------------------------
249
+ // Internal resolve + request pipeline — the single entry point
250
+ // -------------------------------------------------------------------------
251
+
252
+ /**
253
+ * The unified request pipeline: fetch an auth token, build headers, resolve
254
+ * the mount version, rewrite the path for the engine version, make the
255
+ * request, and return a { body, version, mount, apiPath } object.
256
+ *
257
+ * When the resolver is disabled (autoDetect:false, no engines), resolution
258
+ * yields a v1 passthrough, preserving identical behavior to before.
259
+ *
260
+ * @param {string} op - logical operation (read, write, list, delete, update, deleteVersions, …)
261
+ * @param {string} method - HTTP method
262
+ * @param {string} path - caller-supplied secret path
263
+ * @param {Object|null} data - request body
264
+ * @param {Object} [extraHeaders] - extra HTTP headers merged over the token header
265
+ * @param {Object} [options]
266
+ * @param {boolean} [options.raw=false] - skip mount resolution and path rewriting;
267
+ * the literal path and data are sent as-is (raw request() semantics)
268
+ * @param {boolean} [options.v2Only=false] - reject with UnsupportedOperationError
269
+ * unless the resolved mount is a KV v2 engine
270
+ * @param {boolean} [options.wrapData=false] - force the { data } envelope on every
271
+ * engine version (merge-patch update() semantics); by default only v2
272
+ * write/update wrap non-null data and everything else passes through
273
+ * @returns {Promise<{body: *, version: (number|undefined), mount: (string|undefined), apiPath: string}>}
274
+ * @private
275
+ */
276
+ __resolveAndRequest(op, method, path, data, extraHeaders, options) {
277
+ const { raw = false, v2Only = false, wrapData = false } = options || {};
278
+
279
+ return this.__auth.getAuthToken()
280
+ .then((token) => {
281
+ const headers = Object.assign({}, this.getHeaders(token), extraHeaders || {});
282
+
283
+ if (raw) {
284
+ return this.__api.makeRequest(method, path, data, headers)
285
+ .then((body) => ({ body, version: undefined, mount: undefined, apiPath: path }));
286
+ }
287
+
288
+ return this.__resolver.resolve(path)
289
+ .then(({ mount, version }) => {
290
+ if (v2Only && version !== 2) {
291
+ throw new errors.UnsupportedOperationError(
292
+ `Operation "${op}" is only supported on KV v2 mounts. ` +
293
+ `Mount "${mount}" is not a KV v2 engine.`
294
+ );
295
+ }
296
+
297
+ let apiPath;
298
+ if (version === 2) {
299
+ // Split the path into mount + logical sub-path and rewrite for KV v2
300
+ const logicalPath = this.__logicalPath(path, mount);
301
+ apiPath = rewritePath(version, op, mount, logicalPath);
302
+ } else {
303
+ // v1 / disabled: preserve the caller's literal path byte-for-byte,
304
+ // including any trailing slash, to maintain the old wire behavior.
305
+ apiPath = path;
306
+ }
307
+
308
+ // Data envelope: wrapData forces { data } on every engine version
309
+ // (merge-patch update() semantics — v1 mounts reject PATCH anyway);
310
+ // otherwise only v2 write/update wrap non-null data.
311
+ let requestData = data;
312
+ if (wrapData) {
313
+ requestData = { data };
314
+ } else if (version === 2 && (op === 'write' || op === 'update')
315
+ && data !== null && data !== undefined) {
316
+ requestData = { data };
317
+ }
318
+
319
+ return this.__api.makeRequest(method, apiPath, requestData, headers)
320
+ .then((body) => ({ body, version, mount, apiPath }));
321
+ });
322
+ });
323
+ }
324
+
325
+ /**
326
+ * Extract the logical path after the mount prefix.
327
+ * e.g. path='secret/foo/bar', mount='secret' => 'foo/bar'
328
+ * @private
329
+ */
330
+ __logicalPath(path, mount) {
331
+ const prefix = mount.replace(/\/+$/, '');
332
+ if (path === prefix) return '';
333
+ if (path.startsWith(prefix + '/')) return path.slice(prefix.length + 1);
334
+ return path;
335
+ }
336
+
337
+ // -------------------------------------------------------------------------
338
+ // Public API — existing methods (with KV v2 awareness)
339
+ // -------------------------------------------------------------------------
340
+
201
341
  /**
202
342
  * Read secret from Vault
203
343
  * @param {string} path - path to the secret
@@ -205,11 +345,11 @@ class VaultClient {
205
345
  */
206
346
  read(path) {
207
347
  this.__log.debug('read secret %s', path);
208
- return this.__auth.getAuthToken()
209
- .then(token => this.__api.makeRequest('GET', path, null, this.getHeaders(token)))
210
- .then(res => {
348
+ return this.__resolveAndRequest('read', 'GET', path, null)
349
+ .then(({ body, version }) => {
211
350
  this.__log.debug('receive secret %s', path);
212
- return Lease.fromResponse(res);
351
+ const normalised = normalizeResponse(version, 'read', body);
352
+ return Lease.fromResponse(normalised);
213
353
  })
214
354
  .catch((reason) => {
215
355
  this.__log.error('read secret failed: %s', reason.message);
@@ -225,11 +365,11 @@ class VaultClient {
225
365
  */
226
366
  list(path) {
227
367
  this.__log.debug('list secrets %s', path);
228
- return this.__auth.getAuthToken()
229
- .then(token => this.__api.makeRequest('LIST', path, null, this.getHeaders(token)))
230
- .then(res => {
368
+ return this.__resolveAndRequest('list', 'LIST', path, null)
369
+ .then(({ body, version }) => {
231
370
  this.__log.debug('got secrets list %s', path);
232
- return Lease.fromResponse(res);
371
+ const normalised = normalizeResponse(version, 'list', body);
372
+ return Lease.fromResponse(normalised);
233
373
  })
234
374
  .catch((reason) => {
235
375
  this.__log.error('list secrets failed: %s', reason.message);
@@ -246,11 +386,10 @@ class VaultClient {
246
386
  */
247
387
  write(path, data) {
248
388
  this.__log.debug('write secret %s', path);
249
- return this.__auth.getAuthToken()
250
- .then((token) => this.__api.makeRequest('POST', path, data, this.getHeaders(token)))
251
- .then((response) => {
389
+ return this.__resolveAndRequest('write', 'POST', path, data)
390
+ .then(({ body }) => {
252
391
  this.__log.debug('secret %s was written', path);
253
- return response;
392
+ return body;
254
393
  })
255
394
  .catch((reason) => {
256
395
  this.__log.error('write secret failed: %s', reason.message);
@@ -258,19 +397,185 @@ class VaultClient {
258
397
  });
259
398
  }
260
399
 
400
+ /**
401
+ * Delete (soft-delete latest version) a secret.
402
+ *
403
+ * On KV v1 / non-kv mounts this sends DELETE to the raw path.
404
+ * On KV v2 mounts this sends DELETE to the data/ path, soft-deleting the latest version.
405
+ *
406
+ * @param {string} path
407
+ * @returns {Promise<Object>}
408
+ */
409
+ delete(path) {
410
+ this.__log.debug('delete secret %s', path);
411
+ return this.__resolveAndRequest('delete', 'DELETE', path, null)
412
+ .then(({ body }) => {
413
+ this.__log.debug('secret %s was deleted', path);
414
+ return body;
415
+ })
416
+ .catch((reason) => {
417
+ this.__log.error('delete secret failed: %s', reason.message);
418
+ throw reason;
419
+ });
420
+ }
421
+
422
+ // -------------------------------------------------------------------------
423
+ // New methods
424
+ // -------------------------------------------------------------------------
425
+
426
+ /**
427
+ * PATCH (update) a KV v2 secret using merge-patch semantics.
428
+ * Sends the data wrapped in { data: ... } with Content-Type: application/merge-patch+json.
429
+ *
430
+ * @param {string} path
431
+ * @param {object} data
432
+ * @returns {Promise<Object>}
433
+ */
434
+ update(path, data) {
435
+ this.__log.debug('update (patch) secret %s', path);
436
+ // wrapData: update() is a KV v2 merge-patch operation, so the { data } envelope
437
+ // is always applied (v1 mounts do not support PATCH and Vault will return 405)
438
+ return this.__resolveAndRequest('update', 'PATCH', path, data,
439
+ { 'Content-Type': 'application/merge-patch+json' }, { wrapData: true })
440
+ .then(({ body }) => body)
441
+ .catch((reason) => {
442
+ this.__log.error('update secret failed: %s', reason.message);
443
+ throw reason;
444
+ });
445
+ }
446
+
447
+ /**
448
+ * Raw request — literal path, no path rewriting or response unwrapping.
449
+ * Returns the parsed body directly.
450
+ *
451
+ * @param {string} method - HTTP method
452
+ * @param {string} path - literal API path
453
+ * @param {object} [data] - request body
454
+ * @returns {Promise<Object>}
455
+ */
456
+ request(method, path, data) {
457
+ this.__log.debug('raw request %s %s', method, path);
458
+ return this.__resolveAndRequest('request', method, path, data === undefined ? null : data, null, { raw: true })
459
+ .then(({ body }) => body)
460
+ .catch((reason) => {
461
+ this.__log.error('raw request failed: %s', reason.message);
462
+ throw reason;
463
+ });
464
+ }
465
+
466
+ // -------------------------------------------------------------------------
467
+ // KV v2-only helpers
468
+ // -------------------------------------------------------------------------
469
+
470
+ /**
471
+ * Soft-delete specific versions of a KV v2 secret.
472
+ * Throws UnsupportedOperationError on non-v2 mounts.
473
+ *
474
+ * @param {string} path
475
+ * @param {number[]} versions
476
+ * @returns {Promise<Object>}
477
+ */
478
+ deleteVersions(path, versions) {
479
+ this.__log.debug('deleteVersions %s %j', path, versions);
480
+ return this.__v2Only('deleteVersions', 'POST', path, { versions })
481
+ .catch((reason) => {
482
+ this.__log.error('deleteVersions failed: %s', reason.message);
483
+ throw reason;
484
+ });
485
+ }
486
+
487
+ /**
488
+ * Undelete (restore) specific versions of a KV v2 secret.
489
+ * Throws UnsupportedOperationError on non-v2 mounts.
490
+ *
491
+ * @param {string} path
492
+ * @param {number[]} versions
493
+ * @returns {Promise<Object>}
494
+ */
495
+ undeleteVersions(path, versions) {
496
+ this.__log.debug('undeleteVersions %s %j', path, versions);
497
+ return this.__v2Only('undeleteVersions', 'POST', path, { versions })
498
+ .catch((reason) => {
499
+ this.__log.error('undeleteVersions failed: %s', reason.message);
500
+ throw reason;
501
+ });
502
+ }
503
+
504
+ /**
505
+ * Permanently destroy specific versions of a KV v2 secret.
506
+ * Throws UnsupportedOperationError on non-v2 mounts.
507
+ *
508
+ * @param {string} path
509
+ * @param {number[]} versions
510
+ * @returns {Promise<Object>}
511
+ */
512
+ destroyVersions(path, versions) {
513
+ this.__log.debug('destroyVersions %s %j', path, versions);
514
+ return this.__v2Only('destroyVersions', 'POST', path, { versions })
515
+ .catch((reason) => {
516
+ this.__log.error('destroyVersions failed: %s', reason.message);
517
+ throw reason;
518
+ });
519
+ }
520
+
521
+ /**
522
+ * Read KV v2 metadata for a secret.
523
+ * Throws UnsupportedOperationError on non-v2 mounts.
524
+ *
525
+ * @param {string} path
526
+ * @returns {Promise<Object>}
527
+ */
528
+ readMetadata(path) {
529
+ this.__log.debug('readMetadata %s', path);
530
+ return this.__v2Only('readMetadata', 'GET', path, null)
531
+ .then((body) => {
532
+ return normalizeResponse(2, 'readMetadata', body);
533
+ })
534
+ .catch((reason) => {
535
+ this.__log.error('readMetadata failed: %s', reason.message);
536
+ throw reason;
537
+ });
538
+ }
539
+
540
+ /**
541
+ * Delete all metadata and version history for a KV v2 secret (permanent).
542
+ * Throws UnsupportedOperationError on non-v2 mounts.
543
+ *
544
+ * @param {string} path
545
+ * @returns {Promise<Object>}
546
+ */
547
+ deleteMetadata(path) {
548
+ this.__log.debug('deleteMetadata %s', path);
549
+ return this.__v2Only('deleteMetadata', 'DELETE', path, null)
550
+ .catch((reason) => {
551
+ this.__log.error('deleteMetadata failed: %s', reason.message);
552
+ throw reason;
553
+ });
554
+ }
555
+
556
+ /**
557
+ * Shared implementation for v2-only operations: delegates to the unified
558
+ * pipeline with the KV v2 version assertion enabled.
559
+ * @private
560
+ */
561
+ __v2Only(op, method, path, data) {
562
+ return this.__resolveAndRequest(op, method, path, data, null, { v2Only: true })
563
+ .then(({ body }) => body);
564
+ }
565
+
261
566
  /**
262
567
  * @private
263
568
  */
264
569
  __setupLogger(logger) {
265
570
  if (logger === false) {
266
571
  return {
267
- error: _.noop,
268
- warn: _.noop,
269
- info: _.noop,
270
- debug: _.noop,
271
- trace: _.noop,
572
+ error: noop,
573
+ warn: noop,
574
+ info: noop,
575
+ debug: noop,
576
+ trace: noop,
272
577
  }
273
- } else if (_.intersection(_.functionsIn(logger), ['error', 'warn', 'info', 'debug', 'trace']).length >= 5) {
578
+ } else if (['error', 'warn', 'info', 'debug', 'trace'].every((method) => typeof logger?.[method] === 'function')) {
274
579
  return logger
275
580
  } else {
276
581
  return {
@@ -279,7 +584,7 @@ class VaultClient {
279
584
  info: console.info,
280
585
  trace: console.trace,
281
586
  // avoid output sensitive information
282
- debug: _.noop
587
+ debug: noop
283
588
  };
284
589
  }
285
590
  }
@@ -1,12 +1,52 @@
1
1
  'use strict';
2
2
 
3
3
  const path = require('path');
4
- const Bluebird = require('bluebird');
5
- const _ = require('lodash');
6
- const assignDeep = require('assign-deep');
7
4
 
8
5
  const errors = require('./errors');
9
6
 
7
+ function isObject(value) {
8
+ return value !== null && typeof value === 'object';
9
+ }
10
+
11
+ function isPlainObject(value) {
12
+ if (!isObject(value)) {
13
+ return false;
14
+ }
15
+ const proto = Object.getPrototypeOf(value);
16
+ return proto === null || proto === Object.prototype;
17
+ }
18
+
19
+ /**
20
+ * Recursively merges own enumerable properties of `source` into `target`,
21
+ * mutating and returning `target`. Nested objects and arrays are merged in
22
+ * place, other values overwrite, and `undefined` source values are skipped.
23
+ * Prototype-polluting keys (`__proto__`, `constructor`, `prototype`) are
24
+ * skipped. Covers the subset of `lodash.merge` behavior this client relies on.
25
+ */
26
+ function deepMerge(target, source) {
27
+ for (const key of Object.keys(source)) {
28
+ if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
29
+ continue;
30
+ }
31
+
32
+ const sourceValue = source[key];
33
+ if (sourceValue === undefined) {
34
+ continue;
35
+ }
36
+
37
+ const targetValue = target[key];
38
+ if (isObject(sourceValue) && isObject(targetValue)) {
39
+ deepMerge(targetValue, sourceValue);
40
+ } else if (isObject(sourceValue)) {
41
+ target[key] = structuredClone(sourceValue);
42
+ } else {
43
+ target[key] = sourceValue;
44
+ }
45
+ }
46
+
47
+ return target;
48
+ }
49
+
10
50
  class VaultNodeConfig {
11
51
 
12
52
  constructor(vault) {
@@ -29,45 +69,55 @@ class VaultNodeConfig {
29
69
  let requiredData = {};
30
70
 
31
71
  this.__traverse(substitutionMap, (key, val) => {
32
- const splitRes = val.split('#');
33
- const path = splitRes[0], value = splitRes[1];
34
- if (path === undefined || value === undefined) {
35
- throw new errors.InvalidArgumentsError('Invalid format of substitution value');
36
- }
37
-
38
- requiredData[path] = null;
72
+ const { vaultPath } = this.__parseSubstitutionValue(val);
73
+ requiredData[vaultPath] = null;
39
74
  });
40
75
 
41
- let promises = _.mapValues(requiredData, (value, path) => this.__vault.read(path));
76
+ const vaultPaths = Object.keys(requiredData);
42
77
 
43
- return Bluebird.props(promises).then(results => {
44
- requiredData = _.mapValues(requiredData, (value, path) => results[path].getData());
78
+ return Promise.all(vaultPaths.map((vaultPath) => this.__vault.read(vaultPath))).then((leases) => {
79
+ requiredData = Object.fromEntries(
80
+ vaultPaths.map((vaultPath, index) => [vaultPath, leases[index].getData()])
81
+ );
45
82
 
46
83
  this.__traverse(substitutionMap, (key, val, obj) => {
47
- const splitRes = val.split('#');
48
- const path = splitRes[0], value = splitRes[1];
49
- const res = requiredData[path][value];
84
+ const { vaultPath, value } = this.__parseSubstitutionValue(val);
85
+ const res = requiredData[vaultPath][value];
50
86
  if (res === undefined) {
51
87
  throw new errors.VaultError(`Can't find substitution for "${val}"`);
52
88
  }
53
89
 
54
- obj[key] = requiredData[path][value];
90
+ obj[key] = requiredData[vaultPath][value];
55
91
  });
56
92
 
57
- return assignDeep(this.__nodeConfig, substitutionMap);
93
+ return deepMerge(this.__nodeConfig, substitutionMap);
58
94
  });
59
95
  }
60
96
 
97
+ /**
98
+ * Splits a substitution value of the form "<vaultPath>#<value>".
99
+ *
100
+ * @private
101
+ */
102
+ __parseSubstitutionValue(val) {
103
+ const [vaultPath, value] = val.split('#');
104
+ if (!vaultPath || !value) {
105
+ throw new errors.InvalidArgumentsError('Invalid format of substitution value');
106
+ }
107
+
108
+ return { vaultPath, value };
109
+ }
110
+
61
111
  /**
62
112
  * @private
63
113
  */
64
114
  __getSubstitutionMap() {
65
- let config_dir = this.__nodeConfig.util.initParam('NODE_CONFIG_DIR', path.join( process.cwd(), 'config') );
66
- if (config_dir.indexOf('.') === 0) {
67
- config_dir = path.join(process.cwd() , config_dir);
115
+ let configDir = this.__nodeConfig.util.initParam('NODE_CONFIG_DIR', path.join(process.cwd(), 'config'));
116
+ if (configDir.indexOf('.') === 0) {
117
+ configDir = path.join(process.cwd(), configDir);
68
118
  }
69
119
 
70
- const fullFilename = path.join(config_dir, 'custom-vault-variables.js');
120
+ const fullFilename = path.join(configDir, 'custom-vault-variables.js');
71
121
 
72
122
  let fileContent;
73
123
  try {
@@ -76,20 +126,16 @@ class VaultNodeConfig {
76
126
  throw new errors.VaultError('Config file ' + fullFilename + ' cannot be read');
77
127
  }
78
128
 
79
- if (!_.isPlainObject(fileContent)) {
129
+ if (!isPlainObject(fileContent)) {
80
130
  throw new errors.VaultError('Config file ' + fullFilename + ' should return plain object');
81
131
  }
82
132
 
83
- return _.cloneDeep(fileContent);
133
+ return structuredClone(fileContent);
84
134
  }
85
135
 
86
136
  __traverse(o, func) {
87
- for (let i in o) {
88
- if (!Object.hasOwn(o, i)) {
89
- continue;
90
- }
91
-
92
- if (o[i] !== null && typeof(o[i]) === "object") {
137
+ for (const i of Object.keys(o)) {
138
+ if (o[i] !== null && typeof o[i] === 'object') {
93
139
  //going one step down in the object tree!!
94
140
  this.__traverse(o[i], func);
95
141
  } else if (typeof o[i] === 'string') {
@@ -102,3 +148,6 @@ class VaultNodeConfig {
102
148
  }
103
149
 
104
150
  module.exports = VaultNodeConfig;
151
+ // The prototype-pollution guard in deepMerge is a deliberate security control;
152
+ // the helper is exported so tests can exercise it directly.
153
+ module.exports.deepMerge = deepMerge;
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const VaultBaseAuth = require('./VaultBaseAuth');
4
+ const errors = require('../errors');
4
5
 
5
6
  class VaultAppRoleAuth extends VaultBaseAuth {
6
7
 
@@ -10,15 +11,19 @@ class VaultAppRoleAuth extends VaultBaseAuth {
10
11
  * @param {Object} config
11
12
  * @param {String} config.role_id - RoleID of the AppRole.
12
13
  * @param {String} [config.secret_id] - required when bind_secret_id is enabled SecretID belonging to AppRole.
13
- * @param {String} [config.namespace] - Optional. Vault namespace, sent as the X-Vault-Namespace header.
14
+ * @param {String} [config.namespace] - Optional. Vault namespace. Applied as the X-Vault-Namespace
15
+ * header to every request by {@link VaultApiClient}; see {@link VaultClient#constructor}.
14
16
  * @param {String} mount - Vault's mount point ("approle" by default)
15
17
  */
16
18
  constructor(apiClient, logger, config, mount) {
17
19
  super(apiClient, logger, mount || 'approle');
18
20
 
21
+ if (!config || !config.role_id) {
22
+ throw new errors.InvalidArgumentsError('"role_id" should be provided for VaultAppRoleAuth');
23
+ }
24
+
19
25
  this.__roleId = config.role_id;
20
26
  this.__secretId = config.secret_id;
21
- this.__namespace = config.namespace;
22
27
  }
23
28
 
24
29
  _authenticate() {
@@ -27,18 +32,13 @@ class VaultAppRoleAuth extends VaultBaseAuth {
27
32
  this.__roleId
28
33
  );
29
34
 
30
- const headers = {};
31
- if (this.__namespace) {
32
- headers['X-Vault-Namespace'] = this.__namespace;
33
- }
34
-
35
35
  return this.__apiClient.makeRequest('POST', `/auth/${this._mount}/login`, {
36
36
  role_id: this.__roleId,
37
37
  secret_id: this.__secretId,
38
- }, headers).then(res => {
38
+ }).then(res => {
39
39
  this._log.debug(
40
- 'receive token: %s',
41
- res.auth.client_token
40
+ 'received token (accessor=%s)',
41
+ res.auth.accessor
42
42
  );
43
43
  return this._getTokenEntity(res.auth.client_token);
44
44
  });