node-vault-client 1.0.1 → 1.0.3

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.
@@ -0,0 +1,167 @@
1
+ 'use strict';
2
+
3
+ const { VaultError } = require('./errors');
4
+
5
+ /**
6
+ * Resolves the KV engine version for a given secret path.
7
+ *
8
+ * Responsibilities:
9
+ * 1. Check engines override map first (longest-prefix match, no I/O).
10
+ * 2. Auto-detect via detectFn(path) -> { data: { path, type, options } }.
11
+ * 3. Cache by canonical mount path; de-duplicate in-flight detections.
12
+ * 4. On failure, throw VaultError with actionable guidance.
13
+ *
14
+ * @param {Function} detectFn - async (path: string) => Vault mount-info response
15
+ * @param {Object} enginesOverride - { [mountPrefix]: version }
16
+ * @param {Object} logger - logger with .debug(), .error() etc.
17
+ * @param {Object} [opts] - additional options
18
+ * @param {boolean} [opts.disabled] - if true, always return passthrough (version 1)
19
+ */
20
+ class MountResolver {
21
+ constructor(detectFn, enginesOverride, logger, opts) {
22
+ this.__detectFn = detectFn;
23
+ this.__engines = enginesOverride || {};
24
+ this.__log = logger;
25
+ this.__disabled = opts && opts.disabled === true;
26
+
27
+ // Cache: canonical mount path (no trailing slash) -> { mount, version, type }
28
+ this.__cache = new Map();
29
+ // In-flight promises: canonical mount path -> Promise<{mount,version,type}>
30
+ this.__inflight = new Map();
31
+ }
32
+
33
+ /**
34
+ * Resolve the engine version for the given path.
35
+ *
36
+ * @param {string} path - full logical path (e.g. "secret/foo/bar")
37
+ * @returns {Promise<{mount: string, version: number, type: string}>}
38
+ */
39
+ resolve(path) {
40
+ // 1. Check engines override (longest-prefix match). This applies even when
41
+ // auto-detection is disabled, so listed mounts resolve with no I/O.
42
+ const override = this.__enginesOverrideLookup(path);
43
+ if (override !== null) {
44
+ this.__log.debug('MountResolver: engines override for %s -> v%d', path, override.version);
45
+ return Promise.resolve(override);
46
+ }
47
+
48
+ // 2. When detection is disabled (autoDetect off), an unlisted mount is a
49
+ // passthrough (v1) — never issue a sys/internal/ui/mounts round-trip.
50
+ // engines is the documented fallback for Vaults that deny detection.
51
+ if (this.__disabled) {
52
+ return Promise.resolve({ mount: this.__extractMount(path), version: 1, type: 'kv' });
53
+ }
54
+
55
+ // 3. Auto-detect: use canonical mount path as cache key once we know it.
56
+ // For the in-flight grouping key we use the first path segment so that
57
+ // concurrent reads of sub-paths of the SAME mount (e.g. secret/a and
58
+ // secret/b) share one detection. After the shared promise resolves we
59
+ // verify that the canonical mount returned actually prefixes this path;
60
+ // if it does not (two distinct multi-segment mounts share a first segment,
61
+ // e.g. team/kvA/* and team/kvB/*), we start a fresh detection using the
62
+ // full path as key so each mount gets its own call.
63
+ const interimKey = path.split('/')[0];
64
+ return this.__detectWithCache(interimKey, path);
65
+ }
66
+
67
+ // -------------------------------------------------------------------------
68
+ // Private helpers
69
+ // -------------------------------------------------------------------------
70
+
71
+ /**
72
+ * Longest-prefix match against the engines override map.
73
+ * Returns { mount, version } or null if no match found.
74
+ */
75
+ __enginesOverrideLookup(path) {
76
+ const entries = Object.entries(this.__engines);
77
+ if (entries.length === 0) return null;
78
+
79
+ // Sort by descending key length (longest first) for prefix match
80
+ entries.sort((a, b) => b[0].length - a[0].length);
81
+
82
+ for (const [prefix, version] of entries) {
83
+ const normalised = prefix.replace(/\/+$/, '');
84
+ if (path === normalised || path.startsWith(normalised + '/')) {
85
+ return { mount: normalised, version: Number(version), type: 'kv' };
86
+ }
87
+ }
88
+ return null;
89
+ }
90
+
91
+ /**
92
+ * Resolve via cache or detectFn. Groups concurrent calls by interimKey so
93
+ * only one detectFn call is in-flight per first-segment at a time.
94
+ *
95
+ * Followers that join a shared in-flight promise verify that the resolved
96
+ * canonical mount actually prefixes their path. If it does not (two distinct
97
+ * multi-segment mounts share a first segment, e.g. "team/kvA" and "team/kvB"),
98
+ * the follower falls through to start its own fresh detection keyed on its
99
+ * full path, so each mount gets its own accurate detection result.
100
+ */
101
+ __detectWithCache(interimKey, path) {
102
+ // Check persistent cache first (keyed on canonical mount path)
103
+ for (const [mountKey, entry] of this.__cache) {
104
+ if (path === mountKey || path.startsWith(mountKey + '/')) {
105
+ return Promise.resolve(entry);
106
+ }
107
+ }
108
+
109
+ // Check in-flight for this interim key (first segment or full path)
110
+ if (this.__inflight.has(interimKey)) {
111
+ // Join the existing in-flight promise but verify the result matches our path.
112
+ // If the detection was for a different sub-mount, start a fresh detection.
113
+ return this.__inflight.get(interimKey).then((entry) => {
114
+ if (path === entry.mount || path.startsWith(entry.mount + '/')) {
115
+ return entry;
116
+ }
117
+ // The shared detection resolved to a different mount — start our own.
118
+ // Use the full path as key so it won't collide with the first-segment key.
119
+ return this.__detectWithCache(path, path);
120
+ });
121
+ }
122
+
123
+ // Start a new detection
124
+ const promise = this.__detectFn(path)
125
+ .then((response) => {
126
+ const info = response && response.data;
127
+ if (!info) {
128
+ throw new Error('Unexpected empty detection response');
129
+ }
130
+
131
+ const canonicalMount = (info.path || interimKey).replace(/\/+$/, '');
132
+ const type = info.type || 'unknown';
133
+ const optVersion = info.options && info.options.version;
134
+ // Only kv v2 gets special treatment; everything else is passthrough
135
+ const version = (type === 'kv' && Number(optVersion) === 2) ? 2 : 1;
136
+
137
+ const entry = { mount: canonicalMount, version, type };
138
+ this.__log.debug('MountResolver: detected %s as type=%s version=%d', canonicalMount, type, version);
139
+
140
+ // Store in persistent cache
141
+ this.__cache.set(canonicalMount, entry);
142
+ // Clear in-flight
143
+ this.__inflight.delete(interimKey);
144
+ return entry;
145
+ })
146
+ .catch((err) => {
147
+ // Clear in-flight so callers can retry
148
+ this.__inflight.delete(interimKey);
149
+ throw new VaultError(
150
+ `Failed to detect KV engine version for mount "${interimKey}" (path: ${path}): ${err.message}. ` +
151
+ 'Set api.engines or disable autoDetect to bypass detection.'
152
+ );
153
+ });
154
+
155
+ this.__inflight.set(interimKey, promise);
156
+ return promise;
157
+ }
158
+
159
+ /**
160
+ * Simple first-segment extraction used only for the disabled path.
161
+ */
162
+ __extractMount(path) {
163
+ return path.split('/')[0];
164
+ }
165
+ }
166
+
167
+ module.exports = MountResolver;
@@ -1,55 +1,55 @@
1
- 'use strict';
2
-
3
- const rp = require('request-promise');
4
- const urljoin = require('url-join');
5
- const _ = require('lodash');
6
-
7
- class VaultApiClient {
8
-
9
- /**
10
- * @param {Object} config
11
- * @param {String} config.url - the url of the vault server
12
- * @param {String} [config.apiVersion='v1']
13
- * @param {Object} logger
14
- */
15
- constructor(config, logger) {
16
- this.__config = _.defaultsDeep(_.cloneDeep(config), {
17
- apiVersion: 'v1',
18
- });
19
-
20
- this._logger = logger;
21
- }
22
-
23
- makeRequest(method, path, data, headers) {
24
- data = data === undefined ? null : data;
25
- headers = headers === undefined ? {} : headers;
26
-
27
- const requestOptions = {
28
- method: method,
29
- body: data === null ? undefined : data,
30
- uri: urljoin(this.__config.url, this.__config.apiVersion, path),
31
- followRedirects: true,
32
- followAllRedirects: true,
33
- headers,
34
- json: true,
35
- };
36
-
37
- this._logger.debug(
38
- 'making request: %s %s',
39
- requestOptions.method,
40
- requestOptions.uri
41
- );
42
-
43
- return rp(requestOptions)
44
- .then((response) => {
45
- this._logger.debug('%s %s response body:\n%s',
46
- requestOptions.method,
47
- requestOptions.uri,
48
- JSON.stringify(response, null, ' ')
49
- );
50
- return response;
51
- });
52
- }
53
- }
54
-
55
- module.exports = VaultApiClient;
1
+ 'use strict';
2
+
3
+ const rp = require('request-promise');
4
+ const urljoin = require('url-join');
5
+ const _ = require('lodash');
6
+
7
+ class VaultApiClient {
8
+
9
+ /**
10
+ * @param {Object} config
11
+ * @param {String} config.url - the url of the vault server
12
+ * @param {String} [config.apiVersion='v1']
13
+ * @param {Object} logger
14
+ */
15
+ constructor(config, logger) {
16
+ this.__config = _.defaultsDeep(_.cloneDeep(config), {
17
+ apiVersion: 'v1',
18
+ });
19
+
20
+ this._logger = logger;
21
+ }
22
+
23
+ makeRequest(method, path, data, headers) {
24
+ data = data === undefined ? null : data;
25
+ headers = headers === undefined ? {} : headers;
26
+
27
+ const requestOptions = {
28
+ method: method,
29
+ body: data === null ? undefined : data,
30
+ uri: urljoin(this.__config.url, this.__config.apiVersion, path),
31
+ followRedirects: true,
32
+ followAllRedirects: true,
33
+ headers,
34
+ json: true,
35
+ };
36
+
37
+ this._logger.debug(
38
+ 'making request: %s %s',
39
+ requestOptions.method,
40
+ requestOptions.uri
41
+ );
42
+
43
+ return rp(requestOptions)
44
+ .then((response) => {
45
+ this._logger.debug('%s %s response body:\n%s',
46
+ requestOptions.method,
47
+ requestOptions.uri,
48
+ JSON.stringify(response, null, ' ')
49
+ );
50
+ return response;
51
+ });
52
+ }
53
+ }
54
+
55
+ module.exports = VaultApiClient;