From 830e6a0b66fd290cc05f46af289296311028ec74 Mon Sep 17 00:00:00 2001 From: Yuriy R <22548029+kurok@users.noreply.github.com> Date: Wed, 17 Jun 2026 10:47:05 +0100 Subject: [PATCH] feat: KV v2 support + generic verbs (opt-in, non-breaking) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add transparent KV v2 handling and generic backend access to the simple client. Opt-in via api.kv.autoDetect (default false) or a static api.engines map; with neither set, read/list/write are byte-for-byte unchanged and no sys/internal/ui/mounts call is made. New: - src/kvTransform.js — pure rewritePath/normalizeResponse per the KV op table - src/MountResolver.js — lazy per-mount version detection with cache + in-flight dedupe; engines override bypasses detection Changes: - VaultClient: read/list/write/delete are version-aware when enabled (passthrough otherwise); new update() (PATCH merge-patch+json), request() (raw literal path, no Lease), and v2 helpers deleteVersions/ undeleteVersions/destroyVersions/readMetadata/deleteMetadata (throw UnsupportedOperationError on non-v2 mounts) - VaultApiClient: only set the default Content-Type when the caller did not supply one (enables application/merge-patch+json) - Lease: optional metadata + getMetadata() (undefined on v1) - errors: add UnsupportedOperationError Detection only runs when autoDetect is on. With engines set but autoDetect off, listed mounts use the override and any other mount is passthrough, so engines is a working fallback for Vaults that deny the detection endpoint. Signed-off-by: Yuriy R <22548029+kurok@users.noreply.github.com> --- README.md | 146 ++++++++++++ src/Lease.js | 17 +- src/MountResolver.js | 167 ++++++++++++++ src/VaultApiClient.js | 8 +- src/VaultClient.js | 322 +++++++++++++++++++++++++- src/errors.js | 2 + src/kvTransform.js | 90 ++++++++ test/MountResolver.test.mjs | 307 +++++++++++++++++++++++++ test/VaultClient.test.mjs | 335 ++++++++++++++++++++++++++++ test/conformance.vault-api.test.mjs | 194 ++++++++++++++++ test/errors.test.mjs | 9 + test/kvTransform.test.mjs | 216 ++++++++++++++++++ 12 files changed, 1798 insertions(+), 15 deletions(-) create mode 100644 src/MountResolver.js create mode 100644 src/kvTransform.js create mode 100644 test/MountResolver.test.mjs create mode 100644 test/kvTransform.test.mjs diff --git a/README.md b/README.md index 6910081..421c959 100644 --- a/README.md +++ b/README.md @@ -315,6 +315,152 @@ If no name passed all named instances will be cleared. | --- | --- | --- | | [name] | String | Vault instance name, all instances will be cleared if no name were passed | +## KV v2 & generic backends + +### Overview + +By default the client behaves exactly as before (KV v1 / raw passthrough). To enable transparent +KV v2 support set `api.kv.autoDetect: true` **or** supply a static `api.engines` map. Either +option activates path-rewriting and response-unwrapping; callers do not need to know the engine +version. + +### Configuration options + +| Option | Type | Default | Description | +|---|---|---|---| +| `api.kv.autoDetect` | `boolean` | `false` | Auto-detect the KV version of each mount on first use via `GET sys/internal/ui/mounts/`. | +| `api.engines` | `Object` | `{}` | Static mount-to-version map, e.g. `{ secret: 2, legacy: 1 }`. Overrides detection; use this when the token lacks permission on `sys/internal/ui/mounts`. | + +Both options can be combined: `engines` acts as an override — matching mounts skip detection +while unmatched mounts are auto-detected (when `autoDetect: true`). + +### Auto-detect example + +```javascript +const client = VaultClient.boot('main', { + api: { + url: 'https://vault.example.com:8200/', + kv: { autoDetect: true }, + }, + auth: { type: 'token', config: { token: '...' } }, +}); + +// Works transparently on both KV v1 and KV v2 mounts +const lease = await client.read('secret/my-app/config'); +console.log(lease.getData()); // the secret object +console.log(lease.getMetadata()); // KV v2 version metadata (undefined on v1) +``` + +### Static engines override example + +```javascript +const client = VaultClient.boot('main', { + api: { + url: 'https://vault.example.com:8200/', + engines: { secret: 2, legacy: 1 }, + }, + auth: { type: 'token', config: { token: '...' } }, +}); +``` + +### KV v2-specific methods + +These methods require a KV v2 mount and throw `UnsupportedOperationError` on v1 / non-KV mounts. + +```javascript +// Soft-delete specific versions +await client.deleteVersions('secret/foo', [1, 2]); + +// Restore soft-deleted versions +await client.undeleteVersions('secret/foo', [1]); + +// Permanently destroy versions +await client.destroyVersions('secret/foo', [1, 2]); + +// Read version metadata (current_version, versions map, etc.) +const meta = await client.readMetadata('secret/foo'); + +// Delete all metadata and version history (permanent) +await client.deleteMetadata('secret/foo'); +``` + +### update() — merge-patch + +```javascript +// PATCH a subset of keys without overwriting others (KV v2) +await client.update('secret/foo', { password: 'new-value' }); +// Sends PATCH secret/data/foo with Content-Type: application/merge-patch+json +``` + +### delete() + +```javascript +// Soft-delete the latest version on KV v2; DELETE on v1/passthrough +await client.delete('secret/foo'); +``` + +### request() — raw escape hatch + +For any Vault backend that does not benefit from KV path rewriting use `request()`. It sends the +literal path with no rewriting or response normalisation and returns the parsed body directly. + +```javascript +// Encrypt with Transit engine — path must not be rewritten +const result = await client.request('POST', 'transit/encrypt/my-key', { + plaintext: Buffer.from('hello').toString('base64'), +}); +console.log(result.data.ciphertext); +``` + +### Lease.getMetadata() + +`getMetadata()` is additive — existing code is unaffected. + +```javascript +const lease = await client.read('secret/my-app/db'); +lease.getData(); // the secret values +lease.getMetadata(); // { version, created_time, deletion_time, destroyed, custom_metadata } + // undefined on KV v1 / passthrough mounts +``` + +### Path requirements when autoDetect / engines are active + +When `autoDetect: true` or `api.engines` is set, the client rewrites logical paths to the +correct KV v2 API paths automatically (e.g. `secret/foo` → `secret/data/foo` for reads). +**Callers must pass logical paths — do not include the internal KV v2 segments** (`data/`, +`metadata/`, `delete/`, `undelete/`, `destroy/`) in the path argument: + +```javascript +// Correct — logical path only +await client.read('secret/my-app/config'); + +// Wrong — double-rewrite: 'secret/data/foo' becomes 'secret/data/data/foo' on the wire +await client.read('secret/data/foo'); +``` + +If you need to send a fully-literal Vault API path (e.g. when working with non-KV backends or +when you have already constructed the complete path), use `request()` which bypasses all path +rewriting: + +```javascript +// Literal path, no rewriting +await client.request('GET', 'secret/data/foo'); +``` + +### Mount detection caching + +- Each canonical mount is detected at most once per `VaultClient` instance. +- Concurrent first-touch requests for the same mount share a single in-flight detection promise. +- The detection endpoint used is `GET sys/internal/ui/mounts/` (readable by any authenticated token). +- When the token lacks permission on that endpoint, set `api.engines` to skip detection. + +### Error classes + +| Class | When thrown | +|---|---| +| `UnsupportedOperationError` | A v2-only method (`deleteVersions`, `undeleteVersions`, `destroyVersions`, `readMetadata`, `deleteMetadata`) is called against a non-v2 mount. | +| `VaultError` | Mount detection fails (e.g. permission denied) and no `api.engines` override was provided. | + ## Contributing Contributions are welcome! Please read the [contributing guide](CONTRIBUTING.md) to get started, diff --git a/src/Lease.js b/src/Lease.js index 43e70bb..6476cfc 100644 --- a/src/Lease.js +++ b/src/Lease.js @@ -6,13 +6,15 @@ class Lease { leaseId, leaseDuration, renewable, - data + data, + metadata ) { this.__requestId = requestId; this.__leaseId = leaseId; this.__leaseDuration = leaseDuration; this.__renewable = renewable; this.__data = data === undefined ? {} : data; + this.__metadata = metadata; } static fromResponse(response) { @@ -21,7 +23,8 @@ class Lease { response.lease_id, response.lease_duration, response.renewable, - response.data + response.data, + response.metadata ); } @@ -50,6 +53,16 @@ class Lease { isRenewable() { return this.__renewable; } + + /** + * KV v2 version metadata object (created_time, version, etc.). + * Returns undefined for KV v1 / non-KV leases. + * + * @returns {Object|undefined} + */ + getMetadata() { + return this.__metadata; + } } module.exports = Lease; diff --git a/src/MountResolver.js b/src/MountResolver.js new file mode 100644 index 0000000..713ef74 --- /dev/null +++ b/src/MountResolver.js @@ -0,0 +1,167 @@ +'use strict'; + +const { VaultError } = require('./errors'); + +/** + * Resolves the KV engine version for a given secret path. + * + * Responsibilities: + * 1. Check engines override map first (longest-prefix match, no I/O). + * 2. Auto-detect via detectFn(path) -> { data: { path, type, options } }. + * 3. Cache by canonical mount path; de-duplicate in-flight detections. + * 4. On failure, throw VaultError with actionable guidance. + * + * @param {Function} detectFn - async (path: string) => Vault mount-info response + * @param {Object} enginesOverride - { [mountPrefix]: version } + * @param {Object} logger - logger with .debug(), .error() etc. + * @param {Object} [opts] - additional options + * @param {boolean} [opts.disabled] - if true, always return passthrough (version 1) + */ +class MountResolver { + constructor(detectFn, enginesOverride, logger, opts) { + this.__detectFn = detectFn; + this.__engines = enginesOverride || {}; + this.__log = logger; + this.__disabled = opts && opts.disabled === true; + + // Cache: canonical mount path (no trailing slash) -> { mount, version, type } + this.__cache = new Map(); + // In-flight promises: canonical mount path -> Promise<{mount,version,type}> + this.__inflight = new Map(); + } + + /** + * Resolve the engine version for the given path. + * + * @param {string} path - full logical path (e.g. "secret/foo/bar") + * @returns {Promise<{mount: string, version: number, type: string}>} + */ + resolve(path) { + // 1. Check engines override (longest-prefix match). This applies even when + // auto-detection is disabled, so listed mounts resolve with no I/O. + const override = this.__enginesOverrideLookup(path); + if (override !== null) { + this.__log.debug('MountResolver: engines override for %s -> v%d', path, override.version); + return Promise.resolve(override); + } + + // 2. When detection is disabled (autoDetect off), an unlisted mount is a + // passthrough (v1) — never issue a sys/internal/ui/mounts round-trip. + // engines is the documented fallback for Vaults that deny detection. + if (this.__disabled) { + return Promise.resolve({ mount: this.__extractMount(path), version: 1, type: 'kv' }); + } + + // 3. Auto-detect: use canonical mount path as cache key once we know it. + // For the in-flight grouping key we use the first path segment so that + // concurrent reads of sub-paths of the SAME mount (e.g. secret/a and + // secret/b) share one detection. After the shared promise resolves we + // verify that the canonical mount returned actually prefixes this path; + // if it does not (two distinct multi-segment mounts share a first segment, + // e.g. team/kvA/* and team/kvB/*), we start a fresh detection using the + // full path as key so each mount gets its own call. + const interimKey = path.split('/')[0]; + return this.__detectWithCache(interimKey, path); + } + + // ------------------------------------------------------------------------- + // Private helpers + // ------------------------------------------------------------------------- + + /** + * Longest-prefix match against the engines override map. + * Returns { mount, version } or null if no match found. + */ + __enginesOverrideLookup(path) { + const entries = Object.entries(this.__engines); + if (entries.length === 0) return null; + + // Sort by descending key length (longest first) for prefix match + entries.sort((a, b) => b[0].length - a[0].length); + + for (const [prefix, version] of entries) { + const normalised = prefix.replace(/\/+$/, ''); + if (path === normalised || path.startsWith(normalised + '/')) { + return { mount: normalised, version: Number(version), type: 'kv' }; + } + } + return null; + } + + /** + * Resolve via cache or detectFn. Groups concurrent calls by interimKey so + * only one detectFn call is in-flight per first-segment at a time. + * + * Followers that join a shared in-flight promise verify that the resolved + * canonical mount actually prefixes their path. If it does not (two distinct + * multi-segment mounts share a first segment, e.g. "team/kvA" and "team/kvB"), + * the follower falls through to start its own fresh detection keyed on its + * full path, so each mount gets its own accurate detection result. + */ + __detectWithCache(interimKey, path) { + // Check persistent cache first (keyed on canonical mount path) + for (const [mountKey, entry] of this.__cache) { + if (path === mountKey || path.startsWith(mountKey + '/')) { + return Promise.resolve(entry); + } + } + + // Check in-flight for this interim key (first segment or full path) + if (this.__inflight.has(interimKey)) { + // Join the existing in-flight promise but verify the result matches our path. + // If the detection was for a different sub-mount, start a fresh detection. + return this.__inflight.get(interimKey).then((entry) => { + if (path === entry.mount || path.startsWith(entry.mount + '/')) { + return entry; + } + // The shared detection resolved to a different mount — start our own. + // Use the full path as key so it won't collide with the first-segment key. + return this.__detectWithCache(path, path); + }); + } + + // Start a new detection + const promise = this.__detectFn(path) + .then((response) => { + const info = response && response.data; + if (!info) { + throw new Error('Unexpected empty detection response'); + } + + const canonicalMount = (info.path || interimKey).replace(/\/+$/, ''); + const type = info.type || 'unknown'; + const optVersion = info.options && info.options.version; + // Only kv v2 gets special treatment; everything else is passthrough + const version = (type === 'kv' && optVersion === '2') ? 2 : 1; + + const entry = { mount: canonicalMount, version, type }; + this.__log.debug('MountResolver: detected %s as type=%s version=%d', canonicalMount, type, version); + + // Store in persistent cache + this.__cache.set(canonicalMount, entry); + // Clear in-flight + this.__inflight.delete(interimKey); + return entry; + }) + .catch((err) => { + // Clear in-flight so callers can retry + this.__inflight.delete(interimKey); + throw new VaultError( + `Failed to detect KV engine version for mount "${interimKey}" (path: ${path}): ${err.message}. ` + + 'Set api.engines or disable autoDetect to bypass detection.' + ); + }); + + this.__inflight.set(interimKey, promise); + return promise; + } + + /** + * Simple first-segment extraction used only for the disabled path. + */ + __extractMount(path) { + return path.split('/')[0]; + } +} + +module.exports = MountResolver; diff --git a/src/VaultApiClient.js b/src/VaultApiClient.js index ffc161a..fa61897 100644 --- a/src/VaultApiClient.js +++ b/src/VaultApiClient.js @@ -73,7 +73,13 @@ class VaultApiClient { ); if (data !== null) { options.body = JSON.stringify(data); - options.headers['Content-Type'] = 'application/json'; + // Only set the default content-type if the caller did not already supply one. + // Case-insensitive check to honour merge-patch+json and other overrides. + const hasContentType = Object.keys(options.headers) + .some((k) => k.toLowerCase() === 'content-type'); + if (!hasContentType) { + options.headers['Content-Type'] = 'application/json'; + } } this._logger.debug( diff --git a/src/VaultClient.js b/src/VaultClient.js index a67a9eb..194523d 100644 --- a/src/VaultClient.js +++ b/src/VaultClient.js @@ -8,6 +8,8 @@ const VaultTokenAuth = require('./auth/VaultTokenAuth'); const VaultIAMAuth = require('./auth/VaultIAMAuth'); const VaultNodeConfig = require('./VaultNodeConfig'); const VaultKubernetesAuth = require('./auth/VaultKubernetesAuth'); +const MountResolver = require('./MountResolver'); +const { rewritePath, normalizeResponse } = require('./kvTransform'); const noop = () => {}; const vaultInstances = {}; @@ -24,6 +26,9 @@ class VaultClient { * @param {Object} [options.api.requestOptions] - extra options merged into every HTTP * request (e.g. an undici `dispatcher` for a proxy/SOCKS agent or custom TLS/CA trust). * See {@link VaultApiClient#constructor}. + * @param {Object} [options.api.kv] - KV engine options + * @param {boolean} [options.api.kv.autoDetect=false] - When true, auto-detect KV version per mount + * @param {Object} [options.api.engines] - Mount-to-version overrides, e.g. { secret: 2, legacy: 1 } * @param {Object} options.auth * @param {String} options.auth.type * @param {Object} options.auth.config - auth configuration variables @@ -47,6 +52,25 @@ class VaultClient { ); this.__namespace = options.auth.config.namespace; + + // KV v2 support + const kvOpts = (options.api && options.api.kv) || {}; + const autoDetect = kvOpts.autoDetect === true; + const engines = (options.api && options.api.engines) || {}; + + // Detection (the sys/internal/ui/mounts round-trip) only happens when + // autoDetect is on. With engines set but autoDetect off, listed mounts use + // the override and any other mount is passthrough (v1) — no detection call + // is ever made. This keeps the default byte-for-byte and makes engines a + // working fallback for Vaults that deny the detection endpoint. + const resolverDisabled = !autoDetect; + + // Build detectFn lazily — it uses __auth and __api which are already set above. + const detectFn = (path) => this.__detectMount(path); + + this.__resolver = new MountResolver(detectFn, engines, this.__log, { + disabled: resolverDisabled, + }); } /** @@ -199,6 +223,82 @@ class VaultClient { return {'X-Vault-Token': token.getId()} } + // ------------------------------------------------------------------------- + // Detection helper (used by MountResolver's detectFn) + // ------------------------------------------------------------------------- + + /** + * Calls GET sys/internal/ui/mounts/ with the current auth token. + * Returns the parsed Vault response body. + * @private + */ + __detectMount(path) { + return this.__auth.getAuthToken() + .then((token) => { + const detectPath = `sys/internal/ui/mounts/${path}`; + return this.__api.makeRequest('GET', detectPath, null, this.getHeaders(token)); + }); + } + + // ------------------------------------------------------------------------- + // Internal resolve + request helper + // ------------------------------------------------------------------------- + + /** + * Resolve the mount version for a path, rewrite the path, make the request, + * and normalise the response. Returns a { apiPath, body, version, mount } object. + * + * When the resolver is disabled (autoDetect:false, no engines), it skips + * resolution and passes through raw, preserving identical behavior to before. + * + * @private + */ + __resolveAndRequest(op, method, path, data, extraHeaders) { + return this.__auth.getAuthToken() + .then((token) => { + const headers = Object.assign({}, this.getHeaders(token), extraHeaders || {}); + + return this.__resolver.resolve(path) + .then(({ mount, version }) => { + let apiPath; + let requestData = data; + + if (version === 2) { + // Split the path into mount + logical sub-path and rewrite for KV v2 + const logicalPath = this.__logicalPath(path, mount); + apiPath = rewritePath(version, op, mount, logicalPath); + // Wrap data in { data: ... } on v2 write/update + if ((op === 'write' || op === 'update') && data !== null && data !== undefined) { + requestData = { data }; + } + } else { + // v1 / disabled: preserve the caller's literal path byte-for-byte, + // including any trailing slash, to maintain the old wire behavior. + apiPath = path; + } + + return this.__api.makeRequest(method, apiPath, requestData, headers) + .then((body) => ({ body, version, mount, apiPath })); + }); + }); + } + + /** + * Extract the logical path after the mount prefix. + * e.g. path='secret/foo/bar', mount='secret' => 'foo/bar' + * @private + */ + __logicalPath(path, mount) { + const prefix = mount.replace(/\/+$/, ''); + if (path === prefix) return ''; + if (path.startsWith(prefix + '/')) return path.slice(prefix.length + 1); + return path; + } + + // ------------------------------------------------------------------------- + // Public API — existing methods (with KV v2 awareness) + // ------------------------------------------------------------------------- + /** * Read secret from Vault * @param {string} path - path to the secret @@ -206,11 +306,11 @@ class VaultClient { */ read(path) { this.__log.debug('read secret %s', path); - return this.__auth.getAuthToken() - .then(token => this.__api.makeRequest('GET', path, null, this.getHeaders(token))) - .then(res => { + return this.__resolveAndRequest('read', 'GET', path, null) + .then(({ body, version }) => { this.__log.debug('receive secret %s', path); - return Lease.fromResponse(res); + const normalised = normalizeResponse(version, 'read', body); + return Lease.fromResponse(normalised); }) .catch((reason) => { this.__log.error('read secret failed: %s', reason.message); @@ -226,11 +326,11 @@ class VaultClient { */ list(path) { this.__log.debug('list secrets %s', path); - return this.__auth.getAuthToken() - .then(token => this.__api.makeRequest('LIST', path, null, this.getHeaders(token))) - .then(res => { + return this.__resolveAndRequest('list', 'LIST', path, null) + .then(({ body, version }) => { this.__log.debug('got secrets list %s', path); - return Lease.fromResponse(res); + const normalised = normalizeResponse(version, 'list', body); + return Lease.fromResponse(normalised); }) .catch((reason) => { this.__log.error('list secrets failed: %s', reason.message); @@ -247,11 +347,10 @@ class VaultClient { */ write(path, data) { this.__log.debug('write secret %s', path); - return this.__auth.getAuthToken() - .then((token) => this.__api.makeRequest('POST', path, data, this.getHeaders(token))) - .then((response) => { + return this.__resolveAndRequest('write', 'POST', path, data) + .then(({ body }) => { this.__log.debug('secret %s was written', path); - return response; + return body; }) .catch((reason) => { this.__log.error('write secret failed: %s', reason.message); @@ -259,6 +358,205 @@ class VaultClient { }); } + /** + * Delete (soft-delete latest version) a secret. + * + * On KV v1 / non-kv mounts this sends DELETE to the raw path. + * On KV v2 mounts this sends DELETE to the data/ path, soft-deleting the latest version. + * + * @param {string} path + * @returns {Promise} + */ + delete(path) { + this.__log.debug('delete secret %s', path); + return this.__resolveAndRequest('delete', 'DELETE', path, null) + .then(({ body }) => { + this.__log.debug('secret %s was deleted', path); + return body; + }) + .catch((reason) => { + this.__log.error('delete secret failed: %s', reason.message); + throw reason; + }); + } + + // ------------------------------------------------------------------------- + // New methods + // ------------------------------------------------------------------------- + + /** + * PATCH (update) a KV v2 secret using merge-patch semantics. + * Sends the data wrapped in { data: ... } with Content-Type: application/merge-patch+json. + * + * @param {string} path + * @param {object} data + * @returns {Promise} + */ + update(path, data) { + this.__log.debug('update (patch) secret %s', path); + const patchHeaders = { 'Content-Type': 'application/merge-patch+json' }; + return this.__auth.getAuthToken() + .then((token) => { + const headers = Object.assign({}, this.getHeaders(token), patchHeaders); + + return this.__resolver.resolve(path) + .then(({ mount, version }) => { + let apiPath; + if (version === 2) { + const logicalPath = this.__logicalPath(path, mount); + apiPath = rewritePath(version, 'update', mount, logicalPath); + } else { + // v1: preserve the caller's literal path byte-for-byte + apiPath = path; + } + + // Always wrap in { data } for PATCH (update() is a KV v2 merge-patch operation; + // v1 mounts do not support PATCH and Vault will return 405) + const requestData = { data }; + + return this.__api.makeRequest('PATCH', apiPath, requestData, headers); + }); + }) + .catch((reason) => { + this.__log.error('update secret failed: %s', reason.message); + throw reason; + }); + } + + /** + * Raw request — literal path, no path rewriting or response unwrapping. + * Returns the parsed body directly. + * + * @param {string} method - HTTP method + * @param {string} path - literal API path + * @param {object} [data] - request body + * @returns {Promise} + */ + request(method, path, data) { + this.__log.debug('raw request %s %s', method, path); + return this.__auth.getAuthToken() + .then((token) => this.__api.makeRequest(method, path, data === undefined ? null : data, this.getHeaders(token))) + .catch((reason) => { + this.__log.error('raw request failed: %s', reason.message); + throw reason; + }); + } + + // ------------------------------------------------------------------------- + // KV v2-only helpers + // ------------------------------------------------------------------------- + + /** + * Soft-delete specific versions of a KV v2 secret. + * Throws UnsupportedOperationError on non-v2 mounts. + * + * @param {string} path + * @param {number[]} versions + * @returns {Promise} + */ + deleteVersions(path, versions) { + this.__log.debug('deleteVersions %s %j', path, versions); + return this.__v2Only('deleteVersions', 'POST', path, { versions }) + .catch((reason) => { + this.__log.error('deleteVersions failed: %s', reason.message); + throw reason; + }); + } + + /** + * Undelete (restore) specific versions of a KV v2 secret. + * Throws UnsupportedOperationError on non-v2 mounts. + * + * @param {string} path + * @param {number[]} versions + * @returns {Promise} + */ + undeleteVersions(path, versions) { + this.__log.debug('undeleteVersions %s %j', path, versions); + return this.__v2Only('undeleteVersions', 'POST', path, { versions }) + .catch((reason) => { + this.__log.error('undeleteVersions failed: %s', reason.message); + throw reason; + }); + } + + /** + * Permanently destroy specific versions of a KV v2 secret. + * Throws UnsupportedOperationError on non-v2 mounts. + * + * @param {string} path + * @param {number[]} versions + * @returns {Promise} + */ + destroyVersions(path, versions) { + this.__log.debug('destroyVersions %s %j', path, versions); + return this.__v2Only('destroyVersions', 'POST', path, { versions }) + .catch((reason) => { + this.__log.error('destroyVersions failed: %s', reason.message); + throw reason; + }); + } + + /** + * Read KV v2 metadata for a secret. + * Throws UnsupportedOperationError on non-v2 mounts. + * + * @param {string} path + * @returns {Promise} + */ + readMetadata(path) { + this.__log.debug('readMetadata %s', path); + return this.__v2Only('readMetadata', 'GET', path, null) + .then((body) => { + return normalizeResponse(2, 'readMetadata', body); + }) + .catch((reason) => { + this.__log.error('readMetadata failed: %s', reason.message); + throw reason; + }); + } + + /** + * Delete all metadata and version history for a KV v2 secret (permanent). + * Throws UnsupportedOperationError on non-v2 mounts. + * + * @param {string} path + * @returns {Promise} + */ + deleteMetadata(path) { + this.__log.debug('deleteMetadata %s', path); + return this.__v2Only('deleteMetadata', 'DELETE', path, null) + .catch((reason) => { + this.__log.error('deleteMetadata failed: %s', reason.message); + throw reason; + }); + } + + /** + * Shared implementation for v2-only operations. + * Resolves the mount, verifies it is v2, rewrites the path, makes the request. + * @private + */ + __v2Only(op, method, path, data) { + return this.__auth.getAuthToken() + .then((token) => { + const headers = this.getHeaders(token); + + return this.__resolver.resolve(path) + .then(({ mount, version }) => { + if (version !== 2) { + throw new errors.UnsupportedOperationError( + `Operation "${op}" is only supported on KV v2 mounts. ` + + `Mount "${mount}" is not a KV v2 engine.` + ); + } + const logicalPath = this.__logicalPath(path, mount); + const apiPath = rewritePath(version, op, mount, logicalPath); + return this.__api.makeRequest(method, apiPath, data, headers); + }); + }); + } + /** * @private */ diff --git a/src/errors.js b/src/errors.js index 1660e36..444ddbd 100644 --- a/src/errors.js +++ b/src/errors.js @@ -12,10 +12,12 @@ class VaultError extends Error { class InvalidArgumentsError extends VaultError {} class InvalidAWSCredentialsError extends InvalidArgumentsError {} class AuthTokenExpiredError extends VaultError {} +class UnsupportedOperationError extends VaultError {} module.exports = { VaultError, InvalidArgumentsError, InvalidAWSCredentialsError, AuthTokenExpiredError, + UnsupportedOperationError, }; diff --git a/src/kvTransform.js b/src/kvTransform.js new file mode 100644 index 0000000..01c40ca --- /dev/null +++ b/src/kvTransform.js @@ -0,0 +1,90 @@ +'use strict'; + +/** + * Pure KV path-rewriting and response-normalizing helpers. + * No I/O; fully testable in isolation. + * + * rewritePath(version, op, mount, logicalPath) -> apiPath + * normalizeResponse(version, op, body) -> normalized body + */ + +// Map of op -> v2 path segment inserted between mount and logical path. +const V2_SEGMENT = { + read: 'data', + write: 'data', + delete: 'data', + update: 'data', + list: 'metadata', + readMetadata: 'metadata', + deleteMetadata: 'metadata', + deleteVersions: 'delete', + undeleteVersions:'undelete', + destroyVersions: 'destroy', +}; + +/** + * Rewrite a logical path to the correct API path for the given engine version. + * + * @param {number} version - Engine version (2 = KV v2; anything else = passthrough) + * @param {string} op - Operation name (read, write, list, delete, update, …) + * @param {string} mount - Mount point (e.g. "secret" or "secret/") + * @param {string} logicalPath - Path relative to the mount (e.g. "team/svc") + * @returns {string} + */ +function rewritePath(version, op, mount, logicalPath) { + // Normalise trailing slash on mount + const m = mount.replace(/\/+$/, ''); + + if (version !== 2) { + // v1 / non-kv: simple concatenation + return logicalPath ? `${m}/${logicalPath}` : m; + } + + const segment = V2_SEGMENT[op] || 'data'; + return `${m}/${segment}/${logicalPath}`; +} + +/** + * Normalise a Vault API response body for the given engine version and operation. + * + * For v1 / non-kv the body is always returned as-is. + * For v2: + * - read: body.data is replaced with body.data.data; body.metadata is set to body.data.metadata + * - readMetadata: body.data is replaced with the original body.data (unwrapped one level) + * - write/delete/update/…: body returned as-is + * + * @param {number} version + * @param {string} op + * @param {*} body + * @returns {*} + */ +function normalizeResponse(version, op, body) { + if (version !== 2) { + return body; + } + + if (op === 'read') { + if (!body || !body.data) { + return body; + } + // Promote inner data/metadata to top-level response fields + const result = Object.assign({}, body); + result.metadata = body.data.metadata; + result.data = body.data.data; + return result; + } + + if (op === 'readMetadata') { + if (!body || !body.data) { + return body; + } + const result = Object.assign({}, body); + result.data = body.data; + return result; + } + + // All other v2 ops (write, list, delete, update, deleteVersions, …): passthrough + return body; +} + +module.exports = { rewritePath, normalizeResponse }; diff --git a/test/MountResolver.test.mjs b/test/MountResolver.test.mjs new file mode 100644 index 0000000..2b0b1d0 --- /dev/null +++ b/test/MountResolver.test.mjs @@ -0,0 +1,307 @@ +/** + * Unit tests for MountResolver. + * Uses a mock detectFn so no real HTTP is performed. + */ + +import sinon from 'sinon'; +import { expect, use } from 'chai'; +import sinonChai from 'sinon-chai'; +import MountResolver from '../src/MountResolver.js'; +import errors from '../src/errors.js'; + +use(sinonChai); + +// Helper that builds a detection response for a given version +function mkDetect(mount, version, type = 'kv') { + return Promise.resolve({ + data: { + path: mount.endsWith('/') ? mount : mount + '/', + type, + options: { version: String(version) }, + }, + }); +} + +describe('MountResolver', function () { + const logger = { + debug: sinon.stub(), + info: sinon.stub(), + warn: sinon.stub(), + error: sinon.stub(), + trace: sinon.stub(), + }; + + beforeEach(function () { + // Reset call counts between tests + sinon.resetHistory(); + }); + + // ------------------------------------------------------------------------- + // engines override — must skip detectFn entirely + // ------------------------------------------------------------------------- + describe('engines override', function () { + it('returns version from engines map without calling detectFn', async function () { + const detectFn = sinon.stub().rejects(new Error('should not be called')); + const resolver = new MountResolver(detectFn, { secret: 2, legacy: 1 }, logger); + + const result = await resolver.resolve('secret/foo'); + expect(detectFn).to.not.have.been.called; + expect(result.version).to.equal(2); + expect(result.mount).to.equal('secret'); + }); + + it('returns version 1 for a v1 engine override', async function () { + const detectFn = sinon.stub().rejects(new Error('should not be called')); + const resolver = new MountResolver(detectFn, { legacy: 1 }, logger); + + const result = await resolver.resolve('legacy/path'); + expect(detectFn).to.not.have.been.called; + expect(result.version).to.equal(1); + expect(result.mount).to.equal('legacy'); + }); + + it('longest-prefix wins when multiple engines match', async function () { + const detectFn = sinon.stub().rejects(new Error('should not be called')); + // "secret/team" is more specific than "secret" + const resolver = new MountResolver(detectFn, { secret: 1, 'secret/team': 2 }, logger); + + const r1 = await resolver.resolve('secret/team/svc'); + expect(r1.version).to.equal(2); + expect(r1.mount).to.equal('secret/team'); + + const r2 = await resolver.resolve('secret/other/svc'); + expect(r2.version).to.equal(1); + expect(r2.mount).to.equal('secret'); + }); + }); + + // ------------------------------------------------------------------------- + // auto-detection via detectFn + // ------------------------------------------------------------------------- + describe('auto-detection', function () { + it('detects KV v2 by calling detectFn and returns correct result', async function () { + const detectFn = sinon.stub().callsFake((p) => mkDetect('secret', 2)); + const resolver = new MountResolver(detectFn, {}, logger); + + const result = await resolver.resolve('secret/foo'); + expect(detectFn).to.have.been.calledOnce; + expect(result.version).to.equal(2); + expect(result.mount).to.equal('secret'); + expect(result.type).to.equal('kv'); + }); + + it('detects KV v1 by calling detectFn', async function () { + const detectFn = sinon.stub().callsFake(() => mkDetect('kvv1', 1)); + const resolver = new MountResolver(detectFn, {}, logger); + + const result = await resolver.resolve('kvv1/foo'); + expect(result.version).to.equal(1); + expect(result.mount).to.equal('kvv1'); + }); + + it('detects non-kv mounts as passthrough (version 1)', async function () { + const detectFn = sinon.stub().resolves({ + data: { path: 'transit/', type: 'transit', options: {} }, + }); + const resolver = new MountResolver(detectFn, {}, logger); + + const result = await resolver.resolve('transit/encrypt/key'); + expect(result.version).to.equal(1); + expect(result.type).to.equal('transit'); + }); + }); + + // ------------------------------------------------------------------------- + // caching — detectFn must be called only once per canonical mount + // ------------------------------------------------------------------------- + describe('caching', function () { + it('calls detectFn only once for the same mount', async function () { + const detectFn = sinon.stub().callsFake(() => mkDetect('secret', 2)); + const resolver = new MountResolver(detectFn, {}, logger); + + await resolver.resolve('secret/foo'); + await resolver.resolve('secret/bar'); + await resolver.resolve('secret/baz'); + + expect(detectFn).to.have.been.calledOnce; + }); + + it('caches independently for different mounts', async function () { + const detectFn = sinon.stub().callsFake((p) => { + if (p.startsWith('secret/')) return mkDetect('secret', 2); + if (p.startsWith('legacy/')) return mkDetect('legacy', 1); + return Promise.reject(new Error('unexpected')); + }); + const resolver = new MountResolver(detectFn, {}, logger); + + const r1 = await resolver.resolve('secret/foo'); + const r2 = await resolver.resolve('legacy/bar'); + + expect(detectFn).to.have.been.calledTwice; + expect(r1.version).to.equal(2); + expect(r2.version).to.equal(1); + + // Further calls must not re-detect + await resolver.resolve('secret/baz'); + await resolver.resolve('legacy/qux'); + expect(detectFn).to.have.been.calledTwice; + }); + }); + + // ------------------------------------------------------------------------- + // in-flight dedupe — concurrent first-touch must only call detectFn once + // ------------------------------------------------------------------------- + describe('in-flight dedupe', function () { + it('does not issue concurrent detectFn calls for the same mount', async function () { + let resolve; + const pending = new Promise((res) => { resolve = res; }); + const detectFn = sinon.stub().callsFake(() => + pending.then(() => ({ + data: { path: 'secret/', type: 'kv', options: { version: '2' } }, + })) + ); + const resolver = new MountResolver(detectFn, {}, logger); + + // Fire three concurrent resolves before detection completes + const p1 = resolver.resolve('secret/a'); + const p2 = resolver.resolve('secret/b'); + const p3 = resolver.resolve('secret/c'); + + // Unblock the detection + resolve(); + const [r1, r2, r3] = await Promise.all([p1, p2, p3]); + + // detectFn must have been called exactly once + expect(detectFn).to.have.been.calledOnce; + expect(r1.version).to.equal(2); + expect(r2.version).to.equal(2); + expect(r3.version).to.equal(2); + }); + + it('concurrent resolutions of distinct multi-segment mounts sharing a first segment each get the correct version (regression)', async function () { + // "team/kvA" is v2; "team/kvB" is v1. Both share first segment "team". + // Previously, interimKey = "team" for both, collapsing them into one detection call + // and returning the wrong version for the second mount. + let resolveA, resolveB; + const pendingA = new Promise((res) => { resolveA = res; }); + const pendingB = new Promise((res) => { resolveB = res; }); + + let callIndex = 0; + const detectFn = sinon.stub().callsFake((p) => { + callIndex++; + if (p.startsWith('team/kvA')) { + return pendingA.then(() => ({ + data: { path: 'team/kvA/', type: 'kv', options: { version: '2' } }, + })); + } + return pendingB.then(() => ({ + data: { path: 'team/kvB/', type: 'kv', options: { version: '1' } }, + })); + }); + + const resolver = new MountResolver(detectFn, {}, logger); + + const pA = resolver.resolve('team/kvA/secret1'); + const pB = resolver.resolve('team/kvB/secret2'); + + // Unblock both detections + resolveA(); + resolveB(); + const [rA, rB] = await Promise.all([pA, pB]); + + // Each mount must resolve to its own correct version + expect(rA.mount).to.equal('team/kvA'); + expect(rA.version).to.equal(2); + expect(rB.mount).to.equal('team/kvB'); + expect(rB.version).to.equal(1); + }); + }); + + // ------------------------------------------------------------------------- + // failure handling + // ------------------------------------------------------------------------- + describe('failure handling', function () { + it('throws VaultError when detectFn rejects', async function () { + const detectFn = sinon.stub().rejects(new Error('403 Forbidden')); + const resolver = new MountResolver(detectFn, {}, logger); + + try { + await resolver.resolve('secret/foo'); + throw new Error('expected rejection'); + } catch (err) { + expect(err).to.be.instanceOf(errors.VaultError); + expect(err.message).to.include('secret'); + } + }); + + it('clears the in-flight entry on failure so future calls retry', async function () { + let callCount = 0; + const detectFn = sinon.stub().callsFake(() => { + callCount++; + if (callCount === 1) return Promise.reject(new Error('transient')); + return mkDetect('secret', 2); + }); + const resolver = new MountResolver(detectFn, {}, logger); + + // First call fails + await resolver.resolve('secret/foo').catch(() => {}); + + // Second call should succeed (retry after failure) + const result = await resolver.resolve('secret/foo'); + expect(result.version).to.equal(2); + expect(detectFn).to.have.been.calledTwice; + }); + }); + + // ------------------------------------------------------------------------- + // longest-prefix detection with auto-detect + // ------------------------------------------------------------------------- + describe('longest-prefix matching from detection response', function () { + it('uses the canonical mount path from data.path for cache key', async function () { + // Vault returns canonical path with trailing slash + const detectFn = sinon.stub().resolves({ + data: { path: 'secret/', type: 'kv', options: { version: '2' } }, + }); + const resolver = new MountResolver(detectFn, {}, logger); + + await resolver.resolve('secret/foo'); + await resolver.resolve('secret/bar'); + + expect(detectFn).to.have.been.calledOnce; + }); + }); + + // ------------------------------------------------------------------------- + // disabled resolver (no autoDetect, no engines) + // ------------------------------------------------------------------------- + describe('disabled resolver', function () { + it('resolve() returns passthrough (version 1) without calling detectFn when disabled', async function () { + const detectFn = sinon.stub().rejects(new Error('should not be called')); + const resolver = new MountResolver(detectFn, {}, logger, { disabled: true }); + + const result = await resolver.resolve('secret/foo'); + expect(detectFn).to.not.have.been.called; + expect(result.version).to.equal(1); + }); + }); + + // ------------------------------------------------------------------------- + // engines-only mode (autoDetect off, engines set) — how VaultClient wires it + // ------------------------------------------------------------------------- + describe('engines-only mode (autoDetect off, engines set)', function () { + it('applies the engines override for listed mounts but passes unlisted mounts through without detection', async function () { + const detectFn = sinon.stub().rejects(new Error('should not be called')); + // VaultClient sets disabled:true whenever autoDetect is off, but still + // passes the engines map so listed mounts resolve without a detection call. + const resolver = new MountResolver(detectFn, { secret: 2 }, logger, { disabled: true }); + + const listed = await resolver.resolve('secret/foo'); + expect(listed.version).to.equal(2); + expect(listed.mount).to.equal('secret'); + + const unlisted = await resolver.resolve('other/bar'); + expect(unlisted.version).to.equal(1); + expect(detectFn).to.not.have.been.called; + }); + }); +}); diff --git a/test/VaultClient.test.mjs b/test/VaultClient.test.mjs index 99894f9..0a5b2be 100644 --- a/test/VaultClient.test.mjs +++ b/test/VaultClient.test.mjs @@ -10,6 +10,7 @@ import VaultAppRoleAuth from '../src/auth/VaultAppRoleAuth.js'; import VaultIAMAuth from '../src/auth/VaultIAMAuth.js'; import VaultKubernetesAuth from '../src/auth/VaultKubernetesAuth.js'; import errors from '../src/errors.js'; +import MountResolver from '../src/MountResolver.js'; use(sinonChai); @@ -227,4 +228,338 @@ describe('VaultClient', function () { } }); }); + + // ------------------------------------------------------------------------- + // KV v2 — autoDetect:false (default) — zero behavior change + // ------------------------------------------------------------------------- + describe('KV v2 — autoDetect:false (default passthrough)', function () { + let client; + const token = { getId: () => 'tid' }; + + beforeEach(function () { + client = new VaultClient(bootOpts()); + client.__auth = { getAuthToken: sinon.stub().resolves(token) }; + }); + + it('read() passes path through unchanged and wraps in Lease', function () { + client.__api = { makeRequest: sinon.stub().resolves({ request_id: 'r', data: { k: 'v' } }) }; + return client.read('secret/x').then((lease) => { + expect(lease).to.be.instanceOf(Lease); + expect(lease.getData()).to.deep.equal({ k: 'v' }); + expect(client.__api.makeRequest).to.have.been.calledWith('GET', 'secret/x', null, { 'X-Vault-Token': 'tid' }); + }); + }); + + it('write() passes path and data through unchanged', function () { + const response = { data: { version: 1 } }; + client.__api = { makeRequest: sinon.stub().resolves(response) }; + return client.write('secret/x', { a: 1 }).then((res) => { + expect(res).to.equal(response); + expect(client.__api.makeRequest).to.have.been.calledWith('POST', 'secret/x', { a: 1 }, { 'X-Vault-Token': 'tid' }); + }); + }); + + it('list() passes path through unchanged', function () { + client.__api = { makeRequest: sinon.stub().resolves({ data: { keys: ['a'] } }) }; + return client.list('secret').then((lease) => { + expect(lease).to.be.instanceOf(Lease); + expect(client.__api.makeRequest).to.have.been.calledWith('LIST', 'secret', null, { 'X-Vault-Token': 'tid' }); + }); + }); + + it('does NOT call sys/internal/ui/mounts when resolver is disabled', function () { + client.__api = { makeRequest: sinon.stub().resolves({ data: { k: 'v' } }) }; + return client.read('secret/x').then(() => { + // Should have called makeRequest exactly once (the actual read), not the detection endpoint + expect(client.__api.makeRequest).to.have.been.calledOnce; + const [, path] = client.__api.makeRequest.getCall(0).args; + expect(path).to.not.include('sys/internal'); + }); + }); + + it('list() preserves trailing slash on the wire (regression: byte-for-byte passthrough)', function () { + client.__api = { makeRequest: sinon.stub().resolves({ data: { keys: ['a'] } }) }; + return client.list('secret/').then(() => { + // Trailing slash must be preserved — wire path must match caller's path exactly + expect(client.__api.makeRequest).to.have.been.calledWith('LIST', 'secret/', null, { 'X-Vault-Token': 'tid' }); + }); + }); + + it('read() preserves trailing slash on the wire (regression: byte-for-byte passthrough)', function () { + client.__api = { makeRequest: sinon.stub().resolves({ data: { k: 'v' } }) }; + return client.read('path/').then(() => { + expect(client.__api.makeRequest).to.have.been.calledWith('GET', 'path/', null, { 'X-Vault-Token': 'tid' }); + }); + }); + }); + + // ------------------------------------------------------------------------- + // KV v2 — autoDetect:true — path rewriting + response unwrapping + // ------------------------------------------------------------------------- + describe('KV v2 — autoDetect:true', function () { + let client; + const token = { getId: () => 'tid' }; + + function bootV2Client() { + return new VaultClient(bootOpts({ + api: { kv: { autoDetect: true } }, + })); + } + + // Stub the resolver so we control version without real HTTP + function stubResolver(c, version) { + c.__resolver = new MountResolver( + sinon.stub().resolves({ + data: { path: 'secret/', type: 'kv', options: { version: String(version) } }, + }), + {}, + { debug: _.noop, error: _.noop, info: _.noop, warn: _.noop, trace: _.noop } + ); + } + + beforeEach(function () { + client = bootV2Client(); + client.__auth = { getAuthToken: sinon.stub().resolves(token) }; + }); + + it('read() rewrites path to data/ segment on v2', function () { + stubResolver(client, 2); + client.__api = { makeRequest: sinon.stub().resolves({ + data: { data: { username: 'admin' }, metadata: { version: 1 } }, + }) }; + return client.read('secret/foo').then((lease) => { + expect(lease).to.be.instanceOf(Lease); + expect(lease.getData()).to.deep.equal({ username: 'admin' }); + expect(lease.getMetadata()).to.deep.equal({ version: 1 }); + expect(client.__api.makeRequest).to.have.been.calledWith('GET', 'secret/data/foo', null, { 'X-Vault-Token': 'tid' }); + }); + }); + + it('write() wraps data in { data } and rewrites path on v2', function () { + stubResolver(client, 2); + client.__api = { makeRequest: sinon.stub().resolves({ data: { version: 3 } }) }; + return client.write('secret/foo', { password: 'pw' }).then(() => { + expect(client.__api.makeRequest).to.have.been.calledWith( + 'POST', 'secret/data/foo', { data: { password: 'pw' } }, { 'X-Vault-Token': 'tid' } + ); + }); + }); + + it('list() rewrites path to metadata/ segment on v2', function () { + stubResolver(client, 2); + client.__api = { makeRequest: sinon.stub().resolves({ data: { keys: ['foo', 'bar/'] } }) }; + return client.list('secret').then((lease) => { + expect(client.__api.makeRequest).to.have.been.calledWith('LIST', 'secret/metadata/', null, { 'X-Vault-Token': 'tid' }); + expect(lease.getData()).to.deep.equal({ keys: ['foo', 'bar/'] }); + }); + }); + + it('delete() sends DELETE to data/ path on v2', function () { + stubResolver(client, 2); + client.__api = { makeRequest: sinon.stub().resolves(null) }; + return client.delete('secret/foo').then(() => { + expect(client.__api.makeRequest).to.have.been.calledWith('DELETE', 'secret/data/foo', null, { 'X-Vault-Token': 'tid' }); + }); + }); + + it('read() passthrough on v1 mount (no path rewriting)', function () { + // Stub resolver to return v1 for the 'secret' mount prefix + client.__resolver = new MountResolver( + sinon.stub().resolves({ + data: { path: 'secret/', type: 'kv', options: { version: '1' } }, + }), + {}, + { debug: _.noop, error: _.noop, info: _.noop, warn: _.noop, trace: _.noop } + ); + client.__api = { makeRequest: sinon.stub().resolves({ data: { k: 'v' } }) }; + return client.read('secret/foo').then((lease) => { + expect(lease.getData()).to.deep.equal({ k: 'v' }); + expect(client.__api.makeRequest).to.have.been.calledWith('GET', 'secret/foo', null, { 'X-Vault-Token': 'tid' }); + }); + }); + + it('Lease.getMetadata() returns undefined for v1 (no metadata in response)', function () { + client.__resolver = new MountResolver( + sinon.stub().resolves({ + data: { path: 'secret/', type: 'kv', options: { version: '1' } }, + }), + {}, + { debug: _.noop, error: _.noop, info: _.noop, warn: _.noop, trace: _.noop } + ); + client.__api = { makeRequest: sinon.stub().resolves({ data: { k: 'v' } }) }; + return client.read('secret/foo').then((lease) => { + expect(lease.getMetadata()).to.equal(undefined); + }); + }); + }); + + // ------------------------------------------------------------------------- + // KV v2 — engines override + // ------------------------------------------------------------------------- + describe('KV v2 — engines override (no autoDetect)', function () { + let client; + const token = { getId: () => 'tid' }; + + beforeEach(function () { + client = new VaultClient(bootOpts({ + api: { engines: { secret: 2, legacy: 1 } }, + })); + client.__auth = { getAuthToken: sinon.stub().resolves(token) }; + }); + + it('uses engines override to rewrite path without detection', function () { + client.__api = { makeRequest: sinon.stub().resolves({ + data: { data: { x: 1 }, metadata: { version: 1 } }, + }) }; + return client.read('secret/foo').then((lease) => { + expect(lease.getData()).to.deep.equal({ x: 1 }); + expect(client.__api.makeRequest).to.have.been.calledWith('GET', 'secret/data/foo', null, { 'X-Vault-Token': 'tid' }); + }); + }); + + it('legacy mount (v1) passes through unchanged', function () { + client.__api = { makeRequest: sinon.stub().resolves({ data: { a: 'b' } }) }; + return client.read('legacy/bar').then((lease) => { + expect(lease.getData()).to.deep.equal({ a: 'b' }); + expect(client.__api.makeRequest).to.have.been.calledWith('GET', 'legacy/bar', null, { 'X-Vault-Token': 'tid' }); + }); + }); + }); + + // ------------------------------------------------------------------------- + // request() — raw passthrough + // ------------------------------------------------------------------------- + describe('#request()', function () { + let client; + const token = { getId: () => 'tid' }; + + beforeEach(function () { + client = new VaultClient(bootOpts()); + client.__auth = { getAuthToken: sinon.stub().resolves(token) }; + }); + + it('sends the literal path and returns the parsed body', function () { + const body = { ciphertext: 'vault:v1:xyz' }; + client.__api = { makeRequest: sinon.stub().resolves(body) }; + return client.request('POST', 'transit/encrypt/mykey', { plaintext: 'dGVzdA==' }).then((res) => { + expect(res).to.equal(body); + expect(client.__api.makeRequest).to.have.been.calledWith( + 'POST', 'transit/encrypt/mykey', { plaintext: 'dGVzdA==' }, { 'X-Vault-Token': 'tid' } + ); + }); + }); + + it('passes null when data is omitted', function () { + client.__api = { makeRequest: sinon.stub().resolves({}) }; + return client.request('GET', 'sys/health').then(() => { + expect(client.__api.makeRequest).to.have.been.calledWith('GET', 'sys/health', null, { 'X-Vault-Token': 'tid' }); + }); + }); + }); + + // ------------------------------------------------------------------------- + // update() — PATCH with merge-patch+json + // ------------------------------------------------------------------------- + describe('#update()', function () { + let client; + const token = { getId: () => 'tid' }; + + beforeEach(function () { + client = new VaultClient(bootOpts({ + api: { engines: { secret: 2 } }, + })); + client.__auth = { getAuthToken: sinon.stub().resolves(token) }; + }); + + it('sends PATCH with application/merge-patch+json and data envelope on v2', function () { + client.__api = { makeRequest: sinon.stub().resolves({ data: { version: 4 } }) }; + return client.update('secret/foo', { password: 'new' }).then(() => { + const [method, path, data, headers] = client.__api.makeRequest.getCall(0).args; + expect(method).to.equal('PATCH'); + expect(path).to.equal('secret/data/foo'); + expect(data).to.deep.equal({ data: { password: 'new' } }); + expect(headers['Content-Type']).to.equal('application/merge-patch+json'); + }); + }); + }); + + // ------------------------------------------------------------------------- + // v2-only helpers + // ------------------------------------------------------------------------- + describe('v2-only helpers', function () { + let client; + const token = { getId: () => 'tid' }; + + beforeEach(function () { + client = new VaultClient(bootOpts({ + api: { engines: { secret: 2 } }, + })); + client.__auth = { getAuthToken: sinon.stub().resolves(token) }; + }); + + it('deleteVersions() sends POST to delete/ path with versions body', function () { + client.__api = { makeRequest: sinon.stub().resolves({}) }; + return client.deleteVersions('secret/foo', [1, 2]).then(() => { + expect(client.__api.makeRequest).to.have.been.calledWith( + 'POST', 'secret/delete/foo', { versions: [1, 2] }, { 'X-Vault-Token': 'tid' } + ); + }); + }); + + it('undeleteVersions() sends POST to undelete/ path', function () { + client.__api = { makeRequest: sinon.stub().resolves({}) }; + return client.undeleteVersions('secret/foo', [1]).then(() => { + expect(client.__api.makeRequest).to.have.been.calledWith( + 'POST', 'secret/undelete/foo', { versions: [1] }, { 'X-Vault-Token': 'tid' } + ); + }); + }); + + it('destroyVersions() sends POST to destroy/ path', function () { + client.__api = { makeRequest: sinon.stub().resolves({}) }; + return client.destroyVersions('secret/foo', [3]).then(() => { + expect(client.__api.makeRequest).to.have.been.calledWith( + 'POST', 'secret/destroy/foo', { versions: [3] }, { 'X-Vault-Token': 'tid' } + ); + }); + }); + + it('readMetadata() sends GET to metadata/ path and normalises response', function () { + client.__api = { makeRequest: sinon.stub().resolves({ + request_id: 'r', + data: { current_version: 2, versions: { '1': {}, '2': {} } }, + }) }; + return client.readMetadata('secret/foo').then((body) => { + expect(client.__api.makeRequest).to.have.been.calledWith( + 'GET', 'secret/metadata/foo', null, { 'X-Vault-Token': 'tid' } + ); + expect(body.data).to.deep.equal({ current_version: 2, versions: { '1': {}, '2': {} } }); + }); + }); + + it('deleteMetadata() sends DELETE to metadata/ path', function () { + client.__api = { makeRequest: sinon.stub().resolves(null) }; + return client.deleteMetadata('secret/foo').then(() => { + expect(client.__api.makeRequest).to.have.been.calledWith( + 'DELETE', 'secret/metadata/foo', null, { 'X-Vault-Token': 'tid' } + ); + }); + }); + + it('v2-only helpers throw UnsupportedOperationError on a v1 mount', function () { + const v1Client = new VaultClient(bootOpts({ + api: { engines: { secret: 1 } }, + })); + v1Client.__auth = { getAuthToken: sinon.stub().resolves(token) }; + v1Client.__api = { makeRequest: sinon.stub().resolves({}) }; + + return v1Client.deleteVersions('secret/foo', [1]).then( + () => { throw new Error('expected rejection'); }, + (err) => { + expect(err).to.be.instanceOf(errors.UnsupportedOperationError); + expect(err.message).to.include('deleteVersions'); + } + ); + }); + }); }); diff --git a/test/conformance.vault-api.test.mjs b/test/conformance.vault-api.test.mjs index 8ddcfbf..75eae90 100644 --- a/test/conformance.vault-api.test.mjs +++ b/test/conformance.vault-api.test.mjs @@ -17,6 +17,7 @@ import VaultKubernetesAuth from '../src/auth/VaultKubernetesAuth.js'; import VaultTokenAuth from '../src/auth/VaultTokenAuth.js'; import AuthToken from '../src/auth/AuthToken.js'; import Lease from '../src/Lease.js'; +import errors from '../src/errors.js'; use(sinonChai); @@ -248,4 +249,197 @@ describe('Vault API conformance', function () { expect(token.isExpired()).to.equal(false); }); }); + + // ------------------------------------------------------------------------- + // KV v2 transport conformance + // ------------------------------------------------------------------------- + describe('KV v2 verbs (VaultClient)', function () { + let client; + const token = { getId: () => 'tid' }; + const headers = { 'X-Vault-Token': 'tid' }; + + function makeV2Client(engines) { + const c = new VaultClient({ + api: { url: 'https://vault.example/', engines }, + logger: false, + auth: { type: 'token', config: { token: 't' } }, + }); + c.__auth = { getAuthToken: sinon.stub().resolves(token) }; + return c; + } + + beforeEach(function () { + client = makeV2Client({ secret: 2 }); + }); + + afterEach(function () { + VaultClient.clear(); + }); + + it('read rewrites path to data/ and unwraps inner data', function () { + client.__api = { makeRequest: sinon.stub().resolves({ + data: { data: { pw: 'abc' }, metadata: { version: 1 } }, + }) }; + return client.read('secret/foo').then((lease) => { + expect(client.__api.makeRequest).to.have.been.calledWith('GET', 'secret/data/foo', null, headers); + expect(lease.getData()).to.deep.equal({ pw: 'abc' }); + expect(lease.getMetadata()).to.deep.equal({ version: 1 }); + }); + }); + + it('write wraps payload in { data } and rewrites path to data/', function () { + client.__api = { makeRequest: sinon.stub().resolves({ data: { version: 1 } }) }; + return client.write('secret/foo', { pw: 'abc' }).then(() => { + expect(client.__api.makeRequest).to.have.been.calledWith( + 'POST', 'secret/data/foo', { data: { pw: 'abc' } }, headers + ); + }); + }); + + it('list rewrites path to metadata/', function () { + client.__api = { makeRequest: sinon.stub().resolves({ data: { keys: ['foo'] } }) }; + return client.list('secret').then(() => { + expect(client.__api.makeRequest).to.have.been.calledWith('LIST', 'secret/metadata/', null, headers); + }); + }); + + it('delete soft-deletes latest version via DELETE to data/', function () { + client.__api = { makeRequest: sinon.stub().resolves(null) }; + return client.delete('secret/foo').then(() => { + expect(client.__api.makeRequest).to.have.been.calledWith('DELETE', 'secret/data/foo', null, headers); + }); + }); + + it('update sends PATCH with merge-patch+json and { data } envelope to data/', function () { + client.__api = { makeRequest: sinon.stub().resolves({ data: { version: 2 } }) }; + return client.update('secret/foo', { pw: 'new' }).then(() => { + const [method, path, data, hdrs] = client.__api.makeRequest.getCall(0).args; + expect(method).to.equal('PATCH'); + expect(path).to.equal('secret/data/foo'); + expect(data).to.deep.equal({ data: { pw: 'new' } }); + expect(hdrs['Content-Type']).to.equal('application/merge-patch+json'); + }); + }); + + it('deleteVersions sends POST to delete/ with { versions }', function () { + client.__api = { makeRequest: sinon.stub().resolves({}) }; + return client.deleteVersions('secret/foo', [1, 2]).then(() => { + expect(client.__api.makeRequest).to.have.been.calledWith( + 'POST', 'secret/delete/foo', { versions: [1, 2] }, headers + ); + }); + }); + + it('undeleteVersions sends POST to undelete/', function () { + client.__api = { makeRequest: sinon.stub().resolves({}) }; + return client.undeleteVersions('secret/foo', [1]).then(() => { + expect(client.__api.makeRequest).to.have.been.calledWith( + 'POST', 'secret/undelete/foo', { versions: [1] }, headers + ); + }); + }); + + it('destroyVersions sends POST to destroy/', function () { + client.__api = { makeRequest: sinon.stub().resolves({}) }; + return client.destroyVersions('secret/foo', [2]).then(() => { + expect(client.__api.makeRequest).to.have.been.calledWith( + 'POST', 'secret/destroy/foo', { versions: [2] }, headers + ); + }); + }); + + it('readMetadata sends GET to metadata/ and unwraps data', function () { + client.__api = { makeRequest: sinon.stub().resolves({ + request_id: 'r', + data: { current_version: 3, versions: {} }, + }) }; + return client.readMetadata('secret/foo').then((body) => { + expect(client.__api.makeRequest).to.have.been.calledWith('GET', 'secret/metadata/foo', null, headers); + expect(body.data).to.deep.equal({ current_version: 3, versions: {} }); + }); + }); + + it('deleteMetadata sends DELETE to metadata/', function () { + client.__api = { makeRequest: sinon.stub().resolves(null) }; + return client.deleteMetadata('secret/foo').then(() => { + expect(client.__api.makeRequest).to.have.been.calledWith('DELETE', 'secret/metadata/foo', null, headers); + }); + }); + + it('request() sends literal path without rewriting', function () { + const body = { ciphertext: 'vault:v1:xyz' }; + client.__api = { makeRequest: sinon.stub().resolves(body) }; + return client.request('POST', 'transit/encrypt/mykey', { plaintext: 'dA==' }).then((res) => { + expect(res).to.equal(body); + expect(client.__api.makeRequest).to.have.been.calledWith( + 'POST', 'transit/encrypt/mykey', { plaintext: 'dA==' }, headers + ); + }); + }); + + it('v2-only helpers throw UnsupportedOperationError on a v1 mount', function () { + const v1Client = makeV2Client({ secret: 1 }); + v1Client.__api = { makeRequest: sinon.stub().resolves({}) }; + return v1Client.deleteVersions('secret/foo', [1]).then( + () => { throw new Error('expected rejection'); }, + (err) => { + expect(err).to.be.instanceOf(errors.UnsupportedOperationError); + } + ); + }); + + it('VaultApiClient preserves caller Content-Type (no override for merge-patch)', function () { + // Regression: VaultApiClient must NOT override Content-Type when already set + let server; + let seen = {}; + return new Promise((resolve, reject) => { + server = http.createServer((req, res) => { + seen.contentType = req.headers['content-type']; + res.statusCode = 200; + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify({ ok: true })); + }); + server.listen(0, '127.0.0.1', () => resolve(server.address().port)); + }).then((port) => { + const api = new VaultApiClient({ url: `http://127.0.0.1:${port}` }, logger); + return api.makeRequest('PATCH', '/secret/data/foo', { data: { k: 'v' } }, { + 'Content-Type': 'application/merge-patch+json', + }).then(() => { + expect(seen.contentType).to.equal('application/merge-patch+json'); + }).finally(() => { + server.closeAllConnections(); + server.close(); + }); + }); + }); + + it('detects mount via sys/internal/ui/mounts when autoDetect:true', function () { + const autoClient = new VaultClient({ + api: { url: 'https://vault.example/', kv: { autoDetect: true } }, + logger: false, + auth: { type: 'token', config: { token: 't' } }, + }); + autoClient.__auth = { getAuthToken: sinon.stub().resolves(token) }; + + // First call returns the mount info; second call returns the actual secret + const makeRequest = sinon.stub(); + makeRequest.onFirstCall().resolves({ + data: { path: 'secret/', type: 'kv', options: { version: '2' } }, + }); + makeRequest.onSecondCall().resolves({ + data: { data: { pw: 'x' }, metadata: { version: 1 } }, + }); + autoClient.__api = { makeRequest }; + + return autoClient.read('secret/foo').then((lease) => { + // First call: detection endpoint + const [, detectPath] = makeRequest.getCall(0).args; + expect(detectPath).to.include('sys/internal/ui/mounts'); + // Second call: actual read at v2 path + expect(makeRequest).to.have.been.calledWith('GET', 'secret/data/foo', null, headers); + expect(lease.getData()).to.deep.equal({ pw: 'x' }); + autoClient.close(); + }); + }); + }); }); diff --git a/test/errors.test.mjs b/test/errors.test.mjs index 3d1563f..78373b9 100644 --- a/test/errors.test.mjs +++ b/test/errors.test.mjs @@ -8,6 +8,7 @@ describe('errors', function () { 'InvalidArgumentsError', 'InvalidAWSCredentialsError', 'AuthTokenExpiredError', + 'UnsupportedOperationError', ]); }); @@ -48,5 +49,13 @@ describe('errors', function () { expect(err).to.not.be.instanceOf(errors.InvalidArgumentsError); expect(err.name).to.equal('AuthTokenExpiredError'); }); + + it('UnsupportedOperationError extends VaultError', function () { + const err = new errors.UnsupportedOperationError('not supported'); + expect(err).to.be.instanceOf(errors.VaultError); + expect(err).to.be.instanceOf(Error); + expect(err.name).to.equal('UnsupportedOperationError'); + expect(err.message).to.equal('not supported'); + }); }); }); diff --git a/test/kvTransform.test.mjs b/test/kvTransform.test.mjs new file mode 100644 index 0000000..548eea9 --- /dev/null +++ b/test/kvTransform.test.mjs @@ -0,0 +1,216 @@ +/** + * Exhaustive pure-unit tests for kvTransform: rewritePath + normalizeResponse. + * Every op x {v1, v2}. + */ + +import { expect } from 'chai'; +import { rewritePath, normalizeResponse } from '../src/kvTransform.js'; + +describe('kvTransform', function () { + + // ------------------------------------------------------------------------- + // rewritePath(version, op, mount, logicalPath) + // ------------------------------------------------------------------------- + describe('rewritePath', function () { + + // v1 (or non-kv) — always passthrough: mount + logicalPath + describe('v1 / non-kv (version !== 2)', function () { + const cases = [ + ['read', 'secret', 'foo', 'secret/foo'], + ['read', 'secret', 'foo/bar', 'secret/foo/bar'], + ['list', 'secret', 'foo', 'secret/foo'], + ['list', 'secret', '', 'secret'], + ['write', 'secret', 'foo', 'secret/foo'], + ['delete', 'secret', 'foo', 'secret/foo'], + ['update', 'secret', 'foo', 'secret/foo'], + ]; + cases.forEach(([op, mount, lp, expected]) => { + it(`${op}('${mount}','${lp}') => '${expected}'`, function () { + expect(rewritePath(1, op, mount, lp)).to.equal(expected); + }); + }); + + // v2-only ops on v1 => still return a path (caller throws; rewritePath is pure) + it('deleteVersions on v1 still produces a path (caller decides to throw)', function () { + expect(rewritePath(1, 'deleteVersions', 'secret', 'foo')).to.equal('secret/foo'); + }); + }); + + // v2 rewriting + describe('v2 path rewriting', function () { + it('read: inserts data/ segment', function () { + expect(rewritePath(2, 'read', 'secret', 'foo')).to.equal('secret/data/foo'); + }); + + it('read: nested path', function () { + expect(rewritePath(2, 'read', 'secret', 'team/svc')).to.equal('secret/data/team/svc'); + }); + + it('read: empty logical path', function () { + expect(rewritePath(2, 'read', 'secret', '')).to.equal('secret/data/'); + }); + + it('write: inserts data/ segment', function () { + expect(rewritePath(2, 'write', 'secret', 'foo')).to.equal('secret/data/foo'); + }); + + it('list: inserts metadata/ segment', function () { + expect(rewritePath(2, 'list', 'secret', 'foo')).to.equal('secret/metadata/foo'); + }); + + it('list: empty logical path', function () { + expect(rewritePath(2, 'list', 'secret', '')).to.equal('secret/metadata/'); + }); + + it('delete: inserts data/ segment (soft-delete latest)', function () { + expect(rewritePath(2, 'delete', 'secret', 'foo')).to.equal('secret/data/foo'); + }); + + it('update: inserts data/ segment', function () { + expect(rewritePath(2, 'update', 'secret', 'foo')).to.equal('secret/data/foo'); + }); + + it('deleteVersions: inserts delete/ segment', function () { + expect(rewritePath(2, 'deleteVersions', 'secret', 'foo')).to.equal('secret/delete/foo'); + }); + + it('undeleteVersions: inserts undelete/ segment', function () { + expect(rewritePath(2, 'undeleteVersions', 'secret', 'foo')).to.equal('secret/undelete/foo'); + }); + + it('destroyVersions: inserts destroy/ segment', function () { + expect(rewritePath(2, 'destroyVersions', 'secret', 'foo')).to.equal('secret/destroy/foo'); + }); + + it('readMetadata: inserts metadata/ segment', function () { + expect(rewritePath(2, 'readMetadata', 'secret', 'foo')).to.equal('secret/metadata/foo'); + }); + + it('deleteMetadata: inserts metadata/ segment', function () { + expect(rewritePath(2, 'deleteMetadata', 'secret', 'foo')).to.equal('secret/metadata/foo'); + }); + + it('mount with trailing slash is handled', function () { + expect(rewritePath(2, 'read', 'secret/', 'foo')).to.equal('secret/data/foo'); + }); + + it('mount without trailing slash, nested lp', function () { + expect(rewritePath(2, 'read', 'kv', 'a/b/c')).to.equal('kv/data/a/b/c'); + }); + }); + }); + + // ------------------------------------------------------------------------- + // normalizeResponse(version, op, body) + // ------------------------------------------------------------------------- + describe('normalizeResponse', function () { + + // v1 — always return body unchanged + describe('v1 / non-kv (version !== 2)', function () { + const ops = ['read', 'list', 'write', 'delete', 'update', + 'deleteVersions', 'undeleteVersions', 'destroyVersions', + 'readMetadata', 'deleteMetadata']; + + ops.forEach((op) => { + it(`${op} on v1 returns body unchanged`, function () { + const body = { data: { k: 'v' }, request_id: 'r' }; + expect(normalizeResponse(1, op, body)).to.equal(body); + }); + }); + }); + + // v2 responses + describe('v2 response normalization', function () { + + it('read: returns body with data replaced by body.data.data and metadata added', function () { + const body = { + request_id: 'rid', + data: { + data: { username: 'admin', password: 'secret' }, + metadata: { version: 3, created_time: '2024-01-01' }, + }, + }; + const result = normalizeResponse(2, 'read', body); + // Should look like a standard Vault read response with data = inner data + expect(result.data).to.deep.equal({ username: 'admin', password: 'secret' }); + expect(result.metadata).to.deep.equal({ version: 3, created_time: '2024-01-01' }); + expect(result.request_id).to.equal('rid'); + }); + + it('read: handles missing inner data gracefully', function () { + const body = { data: {} }; + const result = normalizeResponse(2, 'read', body); + expect(result.data).to.be.undefined; + expect(result.metadata).to.be.undefined; + }); + + it('write: returns raw body unchanged', function () { + const body = { data: { version: 1 }, request_id: 'r' }; + const result = normalizeResponse(2, 'write', body); + expect(result).to.equal(body); + }); + + it('list: returns body unchanged (keys already at body.data.keys)', function () { + const body = { data: { keys: ['foo', 'bar/'] } }; + const result = normalizeResponse(2, 'list', body); + expect(result).to.equal(body); + }); + + it('delete: returns body unchanged', function () { + const body = null; + const result = normalizeResponse(2, 'delete', body); + expect(result).to.equal(null); + }); + + it('update: returns body unchanged', function () { + const body = { data: { version: 2 } }; + const result = normalizeResponse(2, 'update', body); + expect(result).to.equal(body); + }); + + it('deleteVersions: returns body unchanged', function () { + const body = {}; + const result = normalizeResponse(2, 'deleteVersions', body); + expect(result).to.equal(body); + }); + + it('undeleteVersions: returns body unchanged', function () { + const body = {}; + const result = normalizeResponse(2, 'undeleteVersions', body); + expect(result).to.equal(body); + }); + + it('destroyVersions: returns body unchanged', function () { + const body = {}; + const result = normalizeResponse(2, 'destroyVersions', body); + expect(result).to.equal(body); + }); + + it('readMetadata: unwraps body.data', function () { + const body = { + request_id: 'r', + data: { current_version: 2, versions: { '1': {}, '2': {} } }, + }; + const result = normalizeResponse(2, 'readMetadata', body); + expect(result.data).to.deep.equal({ current_version: 2, versions: { '1': {}, '2': {} } }); + expect(result.request_id).to.equal('r'); + }); + + it('deleteMetadata: returns body unchanged', function () { + const body = null; + const result = normalizeResponse(2, 'deleteMetadata', body); + expect(result).to.equal(null); + }); + }); + + // null/undefined body edge cases + describe('null/undefined body edge cases', function () { + it('v1 read with null body returns null', function () { + expect(normalizeResponse(1, 'read', null)).to.equal(null); + }); + it('v2 delete with undefined body returns undefined', function () { + expect(normalizeResponse(2, 'delete', undefined)).to.equal(undefined); + }); + }); + }); +});