node-vault-client 2.0.1 → 2.1.1
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.
- package/README.md +293 -5
- package/package.json +24 -16
- package/src/Lease.js +16 -5
- package/src/MountResolver.js +220 -0
- package/src/VaultApiClient.js +27 -10
- package/src/VaultClient.js +334 -29
- package/src/VaultNodeConfig.js +52 -6
- package/src/auth/VaultAppRoleAuth.js +10 -10
- package/src/auth/VaultBaseAuth.js +6 -2
- package/src/auth/VaultIAMAuth.js +12 -17
- package/src/auth/VaultKubernetesAuth.js +7 -0
- package/src/auth/VaultTokenAuth.js +1 -1
- package/src/errors.js +32 -2
- package/src/kvTransform.js +90 -0
package/src/VaultClient.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
192
|
-
|
|
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.
|
|
209
|
-
.then(
|
|
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
|
-
|
|
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.
|
|
229
|
-
.then(
|
|
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
|
-
|
|
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.
|
|
250
|
-
.then((
|
|
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
|
|
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:
|
|
268
|
-
warn:
|
|
269
|
-
info:
|
|
270
|
-
debug:
|
|
271
|
-
trace:
|
|
572
|
+
error: noop,
|
|
573
|
+
warn: noop,
|
|
574
|
+
info: noop,
|
|
575
|
+
debug: noop,
|
|
576
|
+
trace: noop,
|
|
272
577
|
}
|
|
273
|
-
} else if (
|
|
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:
|
|
587
|
+
debug: noop
|
|
283
588
|
};
|
|
284
589
|
}
|
|
285
590
|
}
|
package/src/VaultNodeConfig.js
CHANGED
|
@@ -1,10 +1,52 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
3
|
const path = require('path');
|
|
4
|
-
const _ = require('lodash');
|
|
5
4
|
|
|
6
5
|
const errors = require('./errors');
|
|
7
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
|
+
|
|
8
50
|
class VaultNodeConfig {
|
|
9
51
|
|
|
10
52
|
constructor(vault) {
|
|
@@ -34,8 +76,9 @@ class VaultNodeConfig {
|
|
|
34
76
|
const vaultPaths = Object.keys(requiredData);
|
|
35
77
|
|
|
36
78
|
return Promise.all(vaultPaths.map((vaultPath) => this.__vault.read(vaultPath))).then((leases) => {
|
|
37
|
-
|
|
38
|
-
|
|
79
|
+
requiredData = Object.fromEntries(
|
|
80
|
+
vaultPaths.map((vaultPath, index) => [vaultPath, leases[index].getData()])
|
|
81
|
+
);
|
|
39
82
|
|
|
40
83
|
this.__traverse(substitutionMap, (key, val, obj) => {
|
|
41
84
|
const { vaultPath, value } = this.__parseSubstitutionValue(val);
|
|
@@ -47,7 +90,7 @@ class VaultNodeConfig {
|
|
|
47
90
|
obj[key] = requiredData[vaultPath][value];
|
|
48
91
|
});
|
|
49
92
|
|
|
50
|
-
return
|
|
93
|
+
return deepMerge(this.__nodeConfig, substitutionMap);
|
|
51
94
|
});
|
|
52
95
|
}
|
|
53
96
|
|
|
@@ -83,11 +126,11 @@ class VaultNodeConfig {
|
|
|
83
126
|
throw new errors.VaultError('Config file ' + fullFilename + ' cannot be read');
|
|
84
127
|
}
|
|
85
128
|
|
|
86
|
-
if (!
|
|
129
|
+
if (!isPlainObject(fileContent)) {
|
|
87
130
|
throw new errors.VaultError('Config file ' + fullFilename + ' should return plain object');
|
|
88
131
|
}
|
|
89
132
|
|
|
90
|
-
return
|
|
133
|
+
return structuredClone(fileContent);
|
|
91
134
|
}
|
|
92
135
|
|
|
93
136
|
__traverse(o, func) {
|
|
@@ -105,3 +148,6 @@ class VaultNodeConfig {
|
|
|
105
148
|
}
|
|
106
149
|
|
|
107
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
|
|
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
|
-
}
|
|
38
|
+
}).then(res => {
|
|
39
39
|
this._log.debug(
|
|
40
|
-
'
|
|
41
|
-
res.auth.
|
|
40
|
+
'received token (accessor=%s)',
|
|
41
|
+
res.auth.accessor
|
|
42
42
|
);
|
|
43
43
|
return this._getTokenEntity(res.auth.client_token);
|
|
44
44
|
});
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
+
// `long-timeout` is unmaintained (last release 2016) but tiny (~50 LOC), dependency-free and
|
|
4
|
+
// stable: it only works around setTimeout's 32-bit signed-int limit (~24.8 days), which real
|
|
5
|
+
// Vault token TTLs can exceed. Audited and deliberately kept as-is (see issue #111); revisit
|
|
6
|
+
// only if a maintained alternative or a Node.js core fix appears.
|
|
3
7
|
const lt = require('long-timeout');
|
|
4
8
|
const AuthToken = require('./AuthToken');
|
|
5
9
|
const errors = require('../errors');
|
|
@@ -64,8 +68,8 @@ class VaultBaseAuth {
|
|
|
64
68
|
|
|
65
69
|
if (this.__authToken.isRenewable()) {
|
|
66
70
|
this._log.debug(
|
|
67
|
-
'setting refresh timer for token
|
|
68
|
-
authToken.
|
|
71
|
+
'setting refresh timer for token (accessor=%s)',
|
|
72
|
+
authToken.getAccessor()
|
|
69
73
|
);
|
|
70
74
|
this.__setupTokenRefreshTimer(this.__authToken);
|
|
71
75
|
}
|