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.
- package/README.md +293 -5
- package/package.json +17 -12
- package/src/Lease.js +16 -5
- package/src/MountResolver.js +220 -0
- package/src/VaultApiClient.js +52 -14
- package/src/VaultClient.js +334 -29
- package/src/VaultNodeConfig.js +79 -30
- package/src/auth/VaultAppRoleAuth.js +10 -10
- package/src/auth/VaultBaseAuth.js +24 -4
- package/src/auth/VaultIAMAuth.js +12 -17
- package/src/auth/VaultKubernetesAuth.js +10 -6
- package/src/auth/VaultTokenAuth.js +1 -1
- package/src/errors.js +32 -2
- package/src/kvTransform.js +90 -0
|
@@ -0,0 +1,220 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { VaultError, InvalidArgumentsError } = require('./errors');
|
|
4
|
+
|
|
5
|
+
// Default cap for the mount cache. Real deployments talk to a handful of static
|
|
6
|
+
// mounts, so this is generous headroom; the cap exists so that a long-lived
|
|
7
|
+
// service pointed at many/dynamic mounts (e.g. multi-tenant) cannot grow the
|
|
8
|
+
// cache — and the per-resolve lookup cost — without bound (issue #108).
|
|
9
|
+
const DEFAULT_MAX_CACHE_SIZE = 500;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Resolves the KV engine version for a given secret path.
|
|
13
|
+
*
|
|
14
|
+
* Responsibilities:
|
|
15
|
+
* 1. Check engines override map first (longest-prefix match, no I/O).
|
|
16
|
+
* 2. Auto-detect via detectFn(path) -> { data: { path, type, options } }.
|
|
17
|
+
* 3. Cache by canonical mount path (bounded LRU); de-duplicate in-flight detections.
|
|
18
|
+
* 4. On failure, throw VaultError with actionable guidance.
|
|
19
|
+
*
|
|
20
|
+
* @param {Function} detectFn - async (path: string) => Vault mount-info response
|
|
21
|
+
* @param {Object} enginesOverride - { [mountPrefix]: version }; snapshotted at construction
|
|
22
|
+
* @param {Object} logger - logger with .debug(), .error() etc.
|
|
23
|
+
* @param {Object} [opts] - additional options
|
|
24
|
+
* @param {boolean} [opts.disabled] - if true, always return passthrough (version 1)
|
|
25
|
+
* @param {number} [opts.maxCacheSize] - LRU cap for the mount cache (default 500)
|
|
26
|
+
*/
|
|
27
|
+
class MountResolver {
|
|
28
|
+
constructor(detectFn, enginesOverride, logger, opts) {
|
|
29
|
+
this.__detectFn = detectFn;
|
|
30
|
+
this.__log = logger;
|
|
31
|
+
this.__disabled = opts && opts.disabled === true;
|
|
32
|
+
// Validate before use: a non-positive cap would make the eviction loop in
|
|
33
|
+
// __cacheStore spin forever (size can never drop below the cap), and a
|
|
34
|
+
// non-integer cap makes the bound meaningless.
|
|
35
|
+
const maxCacheSize = opts && opts.maxCacheSize;
|
|
36
|
+
if (maxCacheSize !== undefined
|
|
37
|
+
&& (!Number.isInteger(maxCacheSize) || maxCacheSize <= 0)) {
|
|
38
|
+
throw new InvalidArgumentsError(
|
|
39
|
+
`MountResolver: opts.maxCacheSize must be a positive integer, got ${String(maxCacheSize)}`
|
|
40
|
+
);
|
|
41
|
+
}
|
|
42
|
+
this.__maxCacheSize = maxCacheSize !== undefined ? maxCacheSize : DEFAULT_MAX_CACHE_SIZE;
|
|
43
|
+
|
|
44
|
+
// The engines map is immutable after construction: normalize and sort the
|
|
45
|
+
// entries once (longest raw prefix first, matching the previous per-call
|
|
46
|
+
// sort) instead of re-sorting on every resolve().
|
|
47
|
+
this.__engines = Object.entries(enginesOverride || {})
|
|
48
|
+
.sort((a, b) => b[0].length - a[0].length)
|
|
49
|
+
.map(([prefix, version]) => [prefix.replace(/\/+$/, ''), Number(version)]);
|
|
50
|
+
|
|
51
|
+
// LRU cache: canonical mount path (no trailing slash) -> { mount, version, type }.
|
|
52
|
+
// Map iteration order is insertion order; hits are re-inserted to refresh
|
|
53
|
+
// recency, and inserts evict the oldest entries beyond __maxCacheSize.
|
|
54
|
+
this.__cache = new Map();
|
|
55
|
+
// In-flight promises: canonical mount path -> Promise<{mount,version,type}>
|
|
56
|
+
this.__inflight = new Map();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Resolve the engine version for the given path.
|
|
61
|
+
*
|
|
62
|
+
* @param {string} path - full logical path (e.g. "secret/foo/bar")
|
|
63
|
+
* @returns {Promise<{mount: string, version: number, type: string}>}
|
|
64
|
+
*/
|
|
65
|
+
resolve(path) {
|
|
66
|
+
// 1. Check engines override (longest-prefix match). This applies even when
|
|
67
|
+
// auto-detection is disabled, so listed mounts resolve with no I/O.
|
|
68
|
+
const override = this.__enginesOverrideLookup(path);
|
|
69
|
+
if (override !== null) {
|
|
70
|
+
this.__log.debug('MountResolver: engines override for %s -> v%d', path, override.version);
|
|
71
|
+
return Promise.resolve(override);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// 2. When detection is disabled (autoDetect off), an unlisted mount is a
|
|
75
|
+
// passthrough (v1) — never issue a sys/internal/ui/mounts round-trip.
|
|
76
|
+
// engines is the documented fallback for Vaults that deny detection.
|
|
77
|
+
if (this.__disabled) {
|
|
78
|
+
return Promise.resolve({ mount: this.__extractMount(path), version: 1, type: 'kv' });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// 3. Auto-detect: use canonical mount path as cache key once we know it.
|
|
82
|
+
// For the in-flight grouping key we use the first path segment so that
|
|
83
|
+
// concurrent reads of sub-paths of the SAME mount (e.g. secret/a and
|
|
84
|
+
// secret/b) share one detection. After the shared promise resolves we
|
|
85
|
+
// verify that the canonical mount returned actually prefixes this path;
|
|
86
|
+
// if it does not (two distinct multi-segment mounts share a first segment,
|
|
87
|
+
// e.g. team/kvA/* and team/kvB/*), we start a fresh detection using the
|
|
88
|
+
// full path as key so each mount gets its own call.
|
|
89
|
+
const interimKey = path.split('/')[0];
|
|
90
|
+
return this.__detectWithCache(interimKey, path);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// -------------------------------------------------------------------------
|
|
94
|
+
// Private helpers
|
|
95
|
+
// -------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Longest-prefix match against the engines entries precomputed (normalized
|
|
99
|
+
* and sorted longest-first) in the constructor.
|
|
100
|
+
* Returns { mount, version } or null if no match found.
|
|
101
|
+
*/
|
|
102
|
+
__enginesOverrideLookup(path) {
|
|
103
|
+
for (const [prefix, version] of this.__engines) {
|
|
104
|
+
if (path === prefix || path.startsWith(prefix + '/')) {
|
|
105
|
+
return { mount: prefix, version, type: 'kv' };
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Find the cached entry whose canonical mount is a segment-boundary prefix
|
|
113
|
+
* of the path. Vault forbids nested mounts, so at most one entry can match;
|
|
114
|
+
* the path's segment prefixes are probed deepest-first as exact keys
|
|
115
|
+
* (O(path depth) instead of a scan over the whole cache). A hit is
|
|
116
|
+
* re-inserted to refresh its LRU recency.
|
|
117
|
+
*/
|
|
118
|
+
__cacheLookup(path) {
|
|
119
|
+
let candidate = path;
|
|
120
|
+
while (candidate !== '') {
|
|
121
|
+
const entry = this.__cache.get(candidate);
|
|
122
|
+
if (entry !== undefined) {
|
|
123
|
+
this.__cache.delete(candidate);
|
|
124
|
+
this.__cache.set(candidate, entry);
|
|
125
|
+
return entry;
|
|
126
|
+
}
|
|
127
|
+
const slash = candidate.lastIndexOf('/');
|
|
128
|
+
candidate = slash === -1 ? '' : candidate.slice(0, slash);
|
|
129
|
+
}
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Insert (or refresh) a cache entry, evicting the least-recently-used
|
|
135
|
+
* entries once the cap is exceeded.
|
|
136
|
+
*/
|
|
137
|
+
__cacheStore(canonicalMount, entry) {
|
|
138
|
+
this.__cache.delete(canonicalMount);
|
|
139
|
+
this.__cache.set(canonicalMount, entry);
|
|
140
|
+
while (this.__cache.size > this.__maxCacheSize) {
|
|
141
|
+
this.__cache.delete(this.__cache.keys().next().value);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Resolve via cache or detectFn. Groups concurrent calls by interimKey so
|
|
147
|
+
* only one detectFn call is in-flight per first-segment at a time.
|
|
148
|
+
*
|
|
149
|
+
* Followers that join a shared in-flight promise verify that the resolved
|
|
150
|
+
* canonical mount actually prefixes their path. If it does not (two distinct
|
|
151
|
+
* multi-segment mounts share a first segment, e.g. "team/kvA" and "team/kvB"),
|
|
152
|
+
* the follower falls through to start its own fresh detection keyed on its
|
|
153
|
+
* full path, so each mount gets its own accurate detection result.
|
|
154
|
+
*/
|
|
155
|
+
__detectWithCache(interimKey, path) {
|
|
156
|
+
// Check persistent cache first (keyed on canonical mount path)
|
|
157
|
+
const cached = this.__cacheLookup(path);
|
|
158
|
+
if (cached !== null) {
|
|
159
|
+
return Promise.resolve(cached);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Check in-flight for this interim key (first segment or full path)
|
|
163
|
+
if (this.__inflight.has(interimKey)) {
|
|
164
|
+
// Join the existing in-flight promise but verify the result matches our path.
|
|
165
|
+
// If the detection was for a different sub-mount, start a fresh detection.
|
|
166
|
+
return this.__inflight.get(interimKey).then((entry) => {
|
|
167
|
+
if (path === entry.mount || path.startsWith(entry.mount + '/')) {
|
|
168
|
+
return entry;
|
|
169
|
+
}
|
|
170
|
+
// The shared detection resolved to a different mount — start our own.
|
|
171
|
+
// Use the full path as key so it won't collide with the first-segment key.
|
|
172
|
+
return this.__detectWithCache(path, path);
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Start a new detection
|
|
177
|
+
const promise = this.__detectFn(path)
|
|
178
|
+
.then((response) => {
|
|
179
|
+
const info = response && response.data;
|
|
180
|
+
if (!info) {
|
|
181
|
+
throw new Error('Unexpected empty detection response');
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const canonicalMount = (info.path || interimKey).replace(/\/+$/, '');
|
|
185
|
+
const type = info.type || 'unknown';
|
|
186
|
+
const optVersion = info.options && info.options.version;
|
|
187
|
+
// Only kv v2 gets special treatment; everything else is passthrough
|
|
188
|
+
const version = (type === 'kv' && optVersion === '2') ? 2 : 1;
|
|
189
|
+
|
|
190
|
+
const entry = { mount: canonicalMount, version, type };
|
|
191
|
+
this.__log.debug('MountResolver: detected %s as type=%s version=%d', canonicalMount, type, version);
|
|
192
|
+
|
|
193
|
+
// Store in persistent cache (bounded LRU)
|
|
194
|
+
this.__cacheStore(canonicalMount, entry);
|
|
195
|
+
// Clear in-flight
|
|
196
|
+
this.__inflight.delete(interimKey);
|
|
197
|
+
return entry;
|
|
198
|
+
})
|
|
199
|
+
.catch((err) => {
|
|
200
|
+
// Clear in-flight so callers can retry
|
|
201
|
+
this.__inflight.delete(interimKey);
|
|
202
|
+
throw new VaultError(
|
|
203
|
+
`Failed to detect KV engine version for mount "${interimKey}" (path: ${path}): ${err.message}. ` +
|
|
204
|
+
'Set api.engines or disable autoDetect to bypass detection.'
|
|
205
|
+
);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
this.__inflight.set(interimKey, promise);
|
|
209
|
+
return promise;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* Simple first-segment extraction used only for the disabled path.
|
|
214
|
+
*/
|
|
215
|
+
__extractMount(path) {
|
|
216
|
+
return path.split('/')[0];
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
module.exports = MountResolver;
|
package/src/VaultApiClient.js
CHANGED
|
@@ -1,7 +1,26 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const
|
|
4
|
-
|
|
3
|
+
const errors = require('./errors');
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Joins URL segments with single slashes while preserving the leading
|
|
7
|
+
* protocol (e.g. "https://"). Replaces the former `url-join` dependency.
|
|
8
|
+
*/
|
|
9
|
+
function joinUrl(...parts) {
|
|
10
|
+
return parts
|
|
11
|
+
.map((part, i) => {
|
|
12
|
+
let segment = String(part);
|
|
13
|
+
if (i > 0) {
|
|
14
|
+
segment = segment.replace(/^\/+/, '');
|
|
15
|
+
}
|
|
16
|
+
if (i < parts.length - 1) {
|
|
17
|
+
segment = segment.replace(/\/+$/, '');
|
|
18
|
+
}
|
|
19
|
+
return segment;
|
|
20
|
+
})
|
|
21
|
+
.filter((segment) => segment.length > 0)
|
|
22
|
+
.join('/');
|
|
23
|
+
}
|
|
5
24
|
|
|
6
25
|
class VaultApiClient {
|
|
7
26
|
|
|
@@ -16,18 +35,26 @@ class VaultApiClient {
|
|
|
16
35
|
* per-request headers winning. Stored by reference (not deep-cloned) so live
|
|
17
36
|
* objects such as a Dispatcher keep their prototype and remain usable.
|
|
18
37
|
* @param {Object} logger
|
|
38
|
+
* @param {String} [namespace] - Vault namespace. When set, `X-Vault-Namespace` is added to
|
|
39
|
+
* every request (login, token lookup/renewal, and secret operations) so namespacing is
|
|
40
|
+
* owned in one place instead of being reapplied per auth backend. An explicit
|
|
41
|
+
* `X-Vault-Namespace` passed to {@link VaultApiClient#makeRequest} overrides it.
|
|
19
42
|
*/
|
|
20
|
-
constructor(config, logger) {
|
|
43
|
+
constructor(config, logger, namespace) {
|
|
21
44
|
const requestOptions = config && config.requestOptions;
|
|
22
45
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
46
|
+
const baseConfig = { ...(config || {}) };
|
|
47
|
+
delete baseConfig.requestOptions;
|
|
48
|
+
this.__config = structuredClone(baseConfig);
|
|
49
|
+
if (this.__config.apiVersion === undefined) {
|
|
50
|
+
this.__config.apiVersion = 'v1';
|
|
51
|
+
}
|
|
26
52
|
|
|
27
53
|
if (requestOptions !== undefined) {
|
|
28
54
|
this.__config.requestOptions = requestOptions;
|
|
29
55
|
}
|
|
30
56
|
|
|
57
|
+
this.__namespace = namespace !== undefined ? namespace : this.__config.namespace;
|
|
31
58
|
this._logger = logger;
|
|
32
59
|
}
|
|
33
60
|
|
|
@@ -35,10 +62,15 @@ class VaultApiClient {
|
|
|
35
62
|
data = data === undefined ? null : data;
|
|
36
63
|
headers = headers === undefined ? {} : headers;
|
|
37
64
|
|
|
38
|
-
const uri =
|
|
65
|
+
const uri = joinUrl(this.__config.url, this.__config.apiVersion, path);
|
|
39
66
|
|
|
40
67
|
const requestOptions = this.__config.requestOptions || {};
|
|
41
68
|
|
|
69
|
+
// Inject the namespace as a low-priority default so every request (login, token
|
|
70
|
+
// lookup/renewal, secret ops) is namespaced from a single place, while an explicit
|
|
71
|
+
// per-request or requestOptions X-Vault-Namespace still wins.
|
|
72
|
+
const namespaceHeader = this.__namespace ? { 'X-Vault-Namespace': this.__namespace } : {};
|
|
73
|
+
|
|
42
74
|
const options = Object.assign(
|
|
43
75
|
{ redirect: 'follow' },
|
|
44
76
|
requestOptions,
|
|
@@ -46,6 +78,7 @@ class VaultApiClient {
|
|
|
46
78
|
method: method,
|
|
47
79
|
headers: Object.assign(
|
|
48
80
|
{ Accept: 'application/json' },
|
|
81
|
+
namespaceHeader,
|
|
49
82
|
requestOptions.headers,
|
|
50
83
|
headers
|
|
51
84
|
),
|
|
@@ -53,7 +86,13 @@ class VaultApiClient {
|
|
|
53
86
|
);
|
|
54
87
|
if (data !== null) {
|
|
55
88
|
options.body = JSON.stringify(data);
|
|
56
|
-
|
|
89
|
+
// Only set the default content-type if the caller did not already supply one.
|
|
90
|
+
// Case-insensitive check to honour merge-patch+json and other overrides.
|
|
91
|
+
const hasContentType = Object.keys(options.headers)
|
|
92
|
+
.some((k) => k.toLowerCase() === 'content-type');
|
|
93
|
+
if (!hasContentType) {
|
|
94
|
+
options.headers['Content-Type'] = 'application/json';
|
|
95
|
+
}
|
|
57
96
|
}
|
|
58
97
|
|
|
59
98
|
this._logger.debug(
|
|
@@ -74,16 +113,15 @@ class VaultApiClient {
|
|
|
74
113
|
}
|
|
75
114
|
|
|
76
115
|
if (!response.ok) {
|
|
77
|
-
|
|
78
|
-
error.statusCode = response.status;
|
|
79
|
-
error.error = body;
|
|
80
|
-
throw error;
|
|
116
|
+
throw new errors.VaultHttpError(response.status, text, body);
|
|
81
117
|
}
|
|
82
118
|
|
|
83
|
-
|
|
119
|
+
// Do not log the response body: Vault responses carry secret
|
|
120
|
+
// material (auth tokens, secret reads). Log status only.
|
|
121
|
+
this._logger.debug('%s %s -> %d',
|
|
84
122
|
method,
|
|
85
123
|
uri,
|
|
86
|
-
|
|
124
|
+
response.status
|
|
87
125
|
);
|
|
88
126
|
return body;
|
|
89
127
|
});
|