Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
146 changes: 146 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -315,6 +315,152 @@ If no name passed all named instances will be cleared.
| --- | --- | --- |
| [name] | <code>String</code> | 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/<path>`. |
| `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/<path>` (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,
Expand Down
17 changes: 15 additions & 2 deletions src/Lease.js
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand All @@ -21,7 +23,8 @@ class Lease {
response.lease_id,
response.lease_duration,
response.renewable,
response.data
response.data,
response.metadata
);
}

Expand Down Expand Up @@ -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;
167 changes: 167 additions & 0 deletions src/MountResolver.js
Original file line number Diff line number Diff line change
@@ -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;
8 changes: 7 additions & 1 deletion src/VaultApiClient.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Loading
Loading