node-vault-client 1.0.2 → 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.
- package/README.md +111 -0
- package/package.json +1 -1
- package/src/Lease.js +16 -3
- package/src/MountResolver.js +167 -0
- package/src/VaultClient.js +115 -12
- package/src/kvTransform.js +90 -0
package/README.md
CHANGED
|
@@ -29,6 +29,114 @@ vaultClient.read('secret/tst').then(v => {
|
|
|
29
29
|
}).catch(e => console.error(e));
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
+
## KV v2 support
|
|
33
|
+
|
|
34
|
+
This release adds transparent support for the [KV v2 secrets engine](https://developer.hashicorp.com/vault/docs/secrets/kv/kv-v2)
|
|
35
|
+
on top of the existing KV v1 / raw passthrough behaviour.
|
|
36
|
+
|
|
37
|
+
**It is fully opt-in and non-breaking.** With no extra configuration the client behaves exactly as
|
|
38
|
+
before: every path is treated as KV v1 / raw passthrough and no extra request is made. KV v2 is
|
|
39
|
+
activated per mount by setting `api.kv.autoDetect: true` **or** by supplying a static `api.engines`
|
|
40
|
+
map. Once enabled, the client rewrites paths (`secret/foo` → `secret/data/foo`) and unwraps the
|
|
41
|
+
nested KV v2 response automatically — callers never deal with the `data/` / `metadata/` segments
|
|
42
|
+
themselves.
|
|
43
|
+
|
|
44
|
+
### Configuration options
|
|
45
|
+
|
|
46
|
+
| Option | Type | Default | Description |
|
|
47
|
+
|---|---|---|---|
|
|
48
|
+
| `api.kv.autoDetect` | `boolean` | `false` | Auto-detect the KV version of each mount on first use via `GET sys/internal/ui/mounts/<path>`. The result is cached per mount for the lifetime of the client. |
|
|
49
|
+
| `api.engines` | `Object` | `{}` | Static mount-to-version map, e.g. `{ secret: 2, legacy: 1 }`. Listed mounts resolve with no detection round-trip; use this when the token lacks permission on `sys/internal/ui/mounts`. |
|
|
50
|
+
|
|
51
|
+
`engines` always takes precedence over detection, so the two can be combined: matching mounts use
|
|
52
|
+
the map while unmatched mounts are auto-detected (when `autoDetect: true`) or treated as v1.
|
|
53
|
+
|
|
54
|
+
### KV v1 (default — nothing to configure)
|
|
55
|
+
|
|
56
|
+
```javascript
|
|
57
|
+
const client = VaultClient.boot('main', {
|
|
58
|
+
api: { url: 'https://vault.example.com:8200/' },
|
|
59
|
+
auth: { type: 'token', config: { token: '...' } },
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
const lease = await client.read('secret/my-app/config'); // GET secret/my-app/config
|
|
63
|
+
console.log(lease.getData()); // the secret object
|
|
64
|
+
console.log(lease.getMetadata()); // undefined on KV v1
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### KV v2
|
|
68
|
+
|
|
69
|
+
Pass **logical** paths only — the client inserts the `data/` (and `metadata/` for `list`) segment
|
|
70
|
+
for you.
|
|
71
|
+
|
|
72
|
+
```javascript
|
|
73
|
+
const client = VaultClient.boot('main', {
|
|
74
|
+
api: {
|
|
75
|
+
url: 'https://vault.example.com:8200/',
|
|
76
|
+
engines: { secret: 2 }, // or: kv: { autoDetect: true }
|
|
77
|
+
},
|
|
78
|
+
auth: { type: 'token', config: { token: '...' } },
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// read → GET secret/data/my-app/config, response.data.data is unwrapped automatically
|
|
82
|
+
const lease = await client.read('secret/my-app/config');
|
|
83
|
+
console.log(lease.getData()); // the secret values
|
|
84
|
+
console.log(lease.getMetadata()); // { version, created_time, ... } on KV v2
|
|
85
|
+
|
|
86
|
+
// write → POST secret/data/my-app/config with { data: { ... } } wrapping done for you
|
|
87
|
+
await client.write('secret/my-app/config', { password: 's3cr3t' });
|
|
88
|
+
|
|
89
|
+
// list → LIST secret/metadata/my-app
|
|
90
|
+
await client.list('secret/my-app');
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### Mixed v1 + v2 mounts on one client
|
|
94
|
+
|
|
95
|
+
Version resolution is **per mount**, so a single client can talk to v1 and v2 mounts at the same
|
|
96
|
+
time. List each mount's version in `api.engines`:
|
|
97
|
+
|
|
98
|
+
```javascript
|
|
99
|
+
const client = VaultClient.boot('main', {
|
|
100
|
+
api: {
|
|
101
|
+
url: 'https://vault.example.com:8200/',
|
|
102
|
+
engines: {
|
|
103
|
+
'secret-v2': 2, // KV v2 mount
|
|
104
|
+
// 'secret' is not listed → treated as KV v1 (passthrough)
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
auth: { type: 'token', config: { token: '...' } },
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
await client.read('secret/legacy/app'); // v1 → GET secret/legacy/app
|
|
111
|
+
await client.read('secret-v2/app/config'); // v2 → GET secret-v2/data/app/config
|
|
112
|
+
await client.read('database/creds/app-role'); // not KV → passthrough, GET as-is
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
Or let the client figure it out with `autoDetect` (requires the token to read
|
|
116
|
+
`sys/internal/ui/mounts`):
|
|
117
|
+
|
|
118
|
+
```javascript
|
|
119
|
+
const client = VaultClient.boot('main', {
|
|
120
|
+
api: { url: 'https://vault.example.com:8200/', kv: { autoDetect: true } },
|
|
121
|
+
auth: { type: 'token', config: { token: '...' } },
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
await client.read('secret/legacy/app'); // detected as v1
|
|
125
|
+
await client.read('secret-v2/app/config'); // detected as v2
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
### Notes & caveats
|
|
129
|
+
|
|
130
|
+
- **Do not include KV v2 segments in the path.** Pass `secret/foo`, not `secret/data/foo` — the
|
|
131
|
+
latter would be rewritten to `secret/data/data/foo` on a v2 mount.
|
|
132
|
+
- **Vault policy must allow the v2 paths.** KV v2 reads hit `<mount>/data/...` (and `list` hits
|
|
133
|
+
`<mount>/metadata/...`), so the auth token's policy must grant capabilities on those paths, not
|
|
134
|
+
on the logical path.
|
|
135
|
+
- Non-KV backends (database, transit, etc.) are always treated as passthrough — leave them out of
|
|
136
|
+
`api.engines`, and `autoDetect` recognises them as non-KV automatically.
|
|
137
|
+
- If detection fails (e.g. permission denied) and no `api.engines` override matches, the read
|
|
138
|
+
rejects with a `VaultError` — set `api.engines` to bypass detection in that case.
|
|
139
|
+
|
|
32
140
|
## Supported Auth Backends
|
|
33
141
|
|
|
34
142
|
* [AWS IAM](https://www.vaultproject.io/docs/auth/aws.html#iam-auth-method)
|
|
@@ -65,6 +173,9 @@ Client constructor function.
|
|
|
65
173
|
| options.api | <code>Object</code> | | |
|
|
66
174
|
| options.api.url | <code>String</code> | | the url of the vault server |
|
|
67
175
|
| [options.api.apiVersion] | <code>String</code> | `v1` | |
|
|
176
|
+
| [options.api.kv] | <code>Object</code> | | KV engine options |
|
|
177
|
+
| [options.api.kv.autoDetect] | <code>boolean</code> | `false` | Auto-detect the KV version per mount via `GET sys/internal/ui/mounts/<path>`. |
|
|
178
|
+
| [options.api.engines] | <code>Object</code> | `{}` | Static mount-to-version map, e.g. `{ secret: 2, legacy: 1 }`. Overrides detection. |
|
|
68
179
|
| options.auth | <code>Object</code> | | |
|
|
69
180
|
| options.auth.type | <code>String</code> | | |
|
|
70
181
|
| options.auth.config | <code>Object</code> | | auth configuration variables |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "node-vault-client",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "A Vault Client implemented in pure javascript for HashiCorp Vault. It supports variety of Auth Backends and performs lease renewal for issued auth token.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"url": "git+https://github.com/namecheap/node-vault-client.git"
|
package/src/Lease.js
CHANGED
|
@@ -8,13 +8,15 @@ class Lease {
|
|
|
8
8
|
leaseId,
|
|
9
9
|
leaseDuration,
|
|
10
10
|
renewable,
|
|
11
|
-
data
|
|
11
|
+
data,
|
|
12
|
+
metadata
|
|
12
13
|
) {
|
|
13
14
|
this.__requestId = requestId;
|
|
14
15
|
this.__leaseId = leaseId;
|
|
15
16
|
this.__leaseDuration = leaseDuration;
|
|
16
17
|
this.__renewable = renewable;
|
|
17
|
-
this.__data = data === undefined ? {} : data;
|
|
18
|
+
this.__data = (data === undefined || data === null) ? {} : data;
|
|
19
|
+
this.__metadata = metadata;
|
|
18
20
|
}
|
|
19
21
|
|
|
20
22
|
static fromResponse(response) {
|
|
@@ -23,7 +25,8 @@ class Lease {
|
|
|
23
25
|
response.lease_id,
|
|
24
26
|
response.lease_duration,
|
|
25
27
|
response.renewable,
|
|
26
|
-
response.data
|
|
28
|
+
response.data,
|
|
29
|
+
response.metadata
|
|
27
30
|
);
|
|
28
31
|
}
|
|
29
32
|
|
|
@@ -52,6 +55,16 @@ class Lease {
|
|
|
52
55
|
isRenewable() {
|
|
53
56
|
return this.__renewable;
|
|
54
57
|
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* KV v2 version metadata object (created_time, version, etc.).
|
|
61
|
+
* Returns undefined for KV v1 / non-KV leases.
|
|
62
|
+
*
|
|
63
|
+
* @returns {Object|undefined}
|
|
64
|
+
*/
|
|
65
|
+
getMetadata() {
|
|
66
|
+
return this.__metadata;
|
|
67
|
+
}
|
|
55
68
|
}
|
|
56
69
|
|
|
57
70
|
module.exports = Lease;
|
|
@@ -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;
|
package/src/VaultClient.js
CHANGED
|
@@ -9,6 +9,8 @@ const VaultTokenAuth = require('./auth/VaultTokenAuth');
|
|
|
9
9
|
const VaultIAMAuth = require('./auth/VaultIAMAuth');
|
|
10
10
|
const VaultNodeConfig = require('./VaultNodeConfig');
|
|
11
11
|
const VaultKubernetesAuth = require('./auth/VaultKubernetesAuth');
|
|
12
|
+
const MountResolver = require('./MountResolver');
|
|
13
|
+
const { rewritePath, normalizeResponse } = require('./kvTransform');
|
|
12
14
|
const vaultInstances = {};
|
|
13
15
|
|
|
14
16
|
class VaultClient {
|
|
@@ -20,6 +22,14 @@ class VaultClient {
|
|
|
20
22
|
* @param {Object} options.api
|
|
21
23
|
* @param {String} options.api.url - the url of the vault server
|
|
22
24
|
* @param {String} [options.api.apiVersion='v1']
|
|
25
|
+
* @param {Object} [options.api.kv] - KV engine options
|
|
26
|
+
* @param {boolean} [options.api.kv.autoDetect=false] - When true, auto-detect the KV version per mount
|
|
27
|
+
* via a `sys/internal/ui/mounts/<path>` round-trip. When false (default) no detection call is
|
|
28
|
+
* ever made and read/list/write behave byte-for-byte as before unless an engine is
|
|
29
|
+
* listed in `options.api.engines`.
|
|
30
|
+
* @param {Object} [options.api.engines] - Static mount-to-version overrides, e.g. `{ secret: 2, legacy: 1 }`.
|
|
31
|
+
* Listed mounts resolve without any detection round-trip (works even with autoDetect off), making
|
|
32
|
+
* this a reliable fallback for Vaults that deny the detection endpoint.
|
|
23
33
|
* @param {Object} options.auth
|
|
24
34
|
* @param {String} options.auth.type
|
|
25
35
|
* @param {Object} options.auth.config - auth configuration variables
|
|
@@ -43,6 +53,25 @@ class VaultClient {
|
|
|
43
53
|
);
|
|
44
54
|
|
|
45
55
|
this.__namespace = options.auth.config.namespace;
|
|
56
|
+
|
|
57
|
+
// KV v2 support (opt-in, non-breaking).
|
|
58
|
+
const kvOpts = (options.api && options.api.kv) || {};
|
|
59
|
+
const autoDetect = kvOpts.autoDetect === true;
|
|
60
|
+
const engines = (options.api && options.api.engines) || {};
|
|
61
|
+
|
|
62
|
+
// Detection (the sys/internal/ui/mounts round-trip) only happens when
|
|
63
|
+
// autoDetect is on. With engines set but autoDetect off, listed mounts use
|
|
64
|
+
// the override and any other mount is passthrough (v1) — no detection call
|
|
65
|
+
// is ever made. This keeps the default byte-for-byte and makes engines a
|
|
66
|
+
// working fallback for Vaults that deny the detection endpoint.
|
|
67
|
+
const resolverDisabled = !autoDetect;
|
|
68
|
+
|
|
69
|
+
// Build detectFn lazily — it uses __auth and __api which are already set above.
|
|
70
|
+
const detectFn = (path) => this.__detectMount(path);
|
|
71
|
+
|
|
72
|
+
this.__resolver = new MountResolver(detectFn, engines, this.__log, {
|
|
73
|
+
disabled: resolverDisabled,
|
|
74
|
+
});
|
|
46
75
|
}
|
|
47
76
|
|
|
48
77
|
/**
|
|
@@ -173,6 +202,81 @@ class VaultClient {
|
|
|
173
202
|
return {'X-Vault-Token': token.getId()}
|
|
174
203
|
}
|
|
175
204
|
|
|
205
|
+
// -------------------------------------------------------------------------
|
|
206
|
+
// Detection helper (used by MountResolver's detectFn)
|
|
207
|
+
// -------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Calls GET sys/internal/ui/mounts/<path> with the current auth token.
|
|
211
|
+
* Returns the parsed Vault response body.
|
|
212
|
+
* @private
|
|
213
|
+
*/
|
|
214
|
+
__detectMount(path) {
|
|
215
|
+
return this.__auth.getAuthToken()
|
|
216
|
+
.then((token) => {
|
|
217
|
+
const detectPath = `sys/internal/ui/mounts/${path}`;
|
|
218
|
+
return this.__api.makeRequest('GET', detectPath, null, this.getHeaders(token));
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// -------------------------------------------------------------------------
|
|
223
|
+
// Internal resolve + request helper
|
|
224
|
+
// -------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Resolve the mount version for a path, rewrite the path, make the request,
|
|
228
|
+
* and return a { apiPath, body, version, mount } object.
|
|
229
|
+
*
|
|
230
|
+
* When the resolver is disabled (autoDetect:false, no matching engine), it
|
|
231
|
+
* preserves the caller's literal path byte-for-byte, keeping behaviour
|
|
232
|
+
* identical to the pre-KV-v2 client.
|
|
233
|
+
*
|
|
234
|
+
* @private
|
|
235
|
+
*/
|
|
236
|
+
__resolveAndRequest(op, method, path, data) {
|
|
237
|
+
return this.__auth.getAuthToken()
|
|
238
|
+
.then((token) => {
|
|
239
|
+
const headers = this.getHeaders(token);
|
|
240
|
+
|
|
241
|
+
return this.__resolver.resolve(path)
|
|
242
|
+
.then((resolved) => {
|
|
243
|
+
const mount = resolved.mount;
|
|
244
|
+
const version = resolved.version;
|
|
245
|
+
let apiPath;
|
|
246
|
+
let requestData = data;
|
|
247
|
+
|
|
248
|
+
if (version === 2) {
|
|
249
|
+
// Split the path into mount + logical sub-path and rewrite for KV v2
|
|
250
|
+
const logicalPath = this.__logicalPath(path, mount);
|
|
251
|
+
apiPath = rewritePath(version, op, mount, logicalPath);
|
|
252
|
+
// Wrap data in { data: ... } on v2 writes
|
|
253
|
+
if (op === 'write' && data !== null && data !== undefined) {
|
|
254
|
+
requestData = { data };
|
|
255
|
+
}
|
|
256
|
+
} else {
|
|
257
|
+
// v1 / disabled: preserve the caller's literal path byte-for-byte,
|
|
258
|
+
// including any trailing slash, to maintain the old wire behaviour.
|
|
259
|
+
apiPath = path;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return this.__api.makeRequest(method, apiPath, requestData, headers)
|
|
263
|
+
.then((body) => ({ body, version, mount, apiPath }));
|
|
264
|
+
});
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Extract the logical path after the mount prefix.
|
|
270
|
+
* e.g. path='secret/foo/bar', mount='secret' => 'foo/bar'
|
|
271
|
+
* @private
|
|
272
|
+
*/
|
|
273
|
+
__logicalPath(path, mount) {
|
|
274
|
+
const prefix = mount.replace(/\/+$/, '');
|
|
275
|
+
if (path === prefix) return '';
|
|
276
|
+
if (path.startsWith(prefix + '/')) return path.slice(prefix.length + 1);
|
|
277
|
+
return path;
|
|
278
|
+
}
|
|
279
|
+
|
|
176
280
|
/**
|
|
177
281
|
* Read secret from Vault
|
|
178
282
|
* @param {string} path - path to the secret
|
|
@@ -180,11 +284,11 @@ class VaultClient {
|
|
|
180
284
|
*/
|
|
181
285
|
read(path) {
|
|
182
286
|
this.__log.debug('read secret %s', path);
|
|
183
|
-
return this.
|
|
184
|
-
.then(
|
|
185
|
-
.then(res => {
|
|
287
|
+
return this.__resolveAndRequest('read', 'GET', path, null)
|
|
288
|
+
.then(({ body, version }) => {
|
|
186
289
|
this.__log.debug('receive secret %s', path);
|
|
187
|
-
|
|
290
|
+
const normalised = normalizeResponse(version, 'read', body);
|
|
291
|
+
return Lease.fromResponse(normalised);
|
|
188
292
|
})
|
|
189
293
|
.catch((reason) => {
|
|
190
294
|
this.__log.error('read secret failed: %s', reason.message);
|
|
@@ -200,11 +304,11 @@ class VaultClient {
|
|
|
200
304
|
*/
|
|
201
305
|
list(path) {
|
|
202
306
|
this.__log.debug('list secrets %s', path);
|
|
203
|
-
return this.
|
|
204
|
-
.then(
|
|
205
|
-
.then(res => {
|
|
307
|
+
return this.__resolveAndRequest('list', 'LIST', path, null)
|
|
308
|
+
.then(({ body, version }) => {
|
|
206
309
|
this.__log.debug('got secrets list %s', path);
|
|
207
|
-
|
|
310
|
+
const normalised = normalizeResponse(version, 'list', body);
|
|
311
|
+
return Lease.fromResponse(normalised);
|
|
208
312
|
})
|
|
209
313
|
.catch((reason) => {
|
|
210
314
|
this.__log.error('list secrets failed: %s', reason.message);
|
|
@@ -221,11 +325,10 @@ class VaultClient {
|
|
|
221
325
|
*/
|
|
222
326
|
write(path, data) {
|
|
223
327
|
this.__log.debug('write secret %s', path);
|
|
224
|
-
return this.
|
|
225
|
-
.then((
|
|
226
|
-
.then((response) => {
|
|
328
|
+
return this.__resolveAndRequest('write', 'POST', path, data)
|
|
329
|
+
.then(({ body }) => {
|
|
227
330
|
this.__log.debug('secret %s was written', path);
|
|
228
|
-
return
|
|
331
|
+
return body;
|
|
229
332
|
})
|
|
230
333
|
.catch((reason) => {
|
|
231
334
|
this.__log.error('write secret failed: %s', reason.message);
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Pure KV path-rewriting and response-normalizing helpers.
|
|
5
|
+
* No I/O; fully testable in isolation.
|
|
6
|
+
*
|
|
7
|
+
* rewritePath(version, op, mount, logicalPath) -> apiPath
|
|
8
|
+
* normalizeResponse(version, op, body) -> normalized body
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
// Map of op -> v2 path segment inserted between mount and logical path.
|
|
12
|
+
const V2_SEGMENT = {
|
|
13
|
+
read: 'data',
|
|
14
|
+
write: 'data',
|
|
15
|
+
delete: 'data',
|
|
16
|
+
update: 'data',
|
|
17
|
+
list: 'metadata',
|
|
18
|
+
readMetadata: 'metadata',
|
|
19
|
+
deleteMetadata: 'metadata',
|
|
20
|
+
deleteVersions: 'delete',
|
|
21
|
+
undeleteVersions:'undelete',
|
|
22
|
+
destroyVersions: 'destroy',
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Rewrite a logical path to the correct API path for the given engine version.
|
|
27
|
+
*
|
|
28
|
+
* @param {number} version - Engine version (2 = KV v2; anything else = passthrough)
|
|
29
|
+
* @param {string} op - Operation name (read, write, list, delete, update, …)
|
|
30
|
+
* @param {string} mount - Mount point (e.g. "secret" or "secret/")
|
|
31
|
+
* @param {string} logicalPath - Path relative to the mount (e.g. "team/svc")
|
|
32
|
+
* @returns {string}
|
|
33
|
+
*/
|
|
34
|
+
function rewritePath(version, op, mount, logicalPath) {
|
|
35
|
+
// Normalise trailing slash on mount
|
|
36
|
+
const m = mount.replace(/\/+$/, '');
|
|
37
|
+
|
|
38
|
+
if (version !== 2) {
|
|
39
|
+
// v1 / non-kv: simple concatenation
|
|
40
|
+
return logicalPath ? `${m}/${logicalPath}` : m;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const segment = V2_SEGMENT[op] || 'data';
|
|
44
|
+
return `${m}/${segment}/${logicalPath}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Normalise a Vault API response body for the given engine version and operation.
|
|
49
|
+
*
|
|
50
|
+
* For v1 / non-kv the body is always returned as-is.
|
|
51
|
+
* For v2:
|
|
52
|
+
* - read: body.data is replaced with body.data.data; body.metadata is set to body.data.metadata
|
|
53
|
+
* - readMetadata: body.data is replaced with the original body.data (unwrapped one level)
|
|
54
|
+
* - write/delete/update/…: body returned as-is
|
|
55
|
+
*
|
|
56
|
+
* @param {number} version
|
|
57
|
+
* @param {string} op
|
|
58
|
+
* @param {*} body
|
|
59
|
+
* @returns {*}
|
|
60
|
+
*/
|
|
61
|
+
function normalizeResponse(version, op, body) {
|
|
62
|
+
if (version !== 2) {
|
|
63
|
+
return body;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (op === 'read') {
|
|
67
|
+
if (!body || !body.data) {
|
|
68
|
+
return body;
|
|
69
|
+
}
|
|
70
|
+
// Promote inner data/metadata to top-level response fields
|
|
71
|
+
const result = Object.assign({}, body);
|
|
72
|
+
result.metadata = body.data.metadata;
|
|
73
|
+
result.data = body.data.data;
|
|
74
|
+
return result;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (op === 'readMetadata') {
|
|
78
|
+
if (!body || !body.data) {
|
|
79
|
+
return body;
|
|
80
|
+
}
|
|
81
|
+
const result = Object.assign({}, body);
|
|
82
|
+
result.data = body.data;
|
|
83
|
+
return result;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// All other v2 ops (write, list, delete, update, deleteVersions, …): passthrough
|
|
87
|
+
return body;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
module.exports = { rewritePath, normalizeResponse };
|