@achs/azure-key-vault
v5.0.0
Published
Wrapper for @azure/keyvault-secrets for ease secrets handler in JSON files.
Readme
Azure Key Vault
Node library to handle Azure Key Vault secrets by project, environment and group (when a vault is shared), as nested JSON. Every failure is classified and logged at its level, and the library says who it signed in as, so a missing vault, an identity without access and a missing secret never look the same.
ESM only, Node.js 22.12 or later.
How To Use 💡
import { AzureKeyVault } from '@achs/azure-key-vault';
// DefaultAzureCredential: environment variables, workload or
// managed identity, Azure CLI ("az login")...
const keyVault = new AzureKeyVault({
vaultUrl: 'https://my-key-vault.vault.azure.net',
project: 'my-project',
group: 'web',
env: 'dev',
});
await keyVault.setAll({
SECRET1: 'my secret 1',
otherConfig: { SECRET2: 'my secret 2' },
});
const secrets = await keyVault.getFor({
SECRET1: null,
otherConfig: { SECRET2: null },
SECRET3: 'default for secret 3',
});
// { SECRET1: 'my secret 1', otherConfig: { SECRET2: 'my secret 2' }, SECRET3: 'default for secret 3' }
// typed with the shape of the input: secrets.otherConfig.SECRET2Options
| Option | Description |
| ------------------- | ---------------------------------------------------------------------------------------- |
| vaultUrl | (string) vault URL, i.e. https://my-key-vault.vault.azure.net |
| project | (string) project the secrets belong to |
| group | (string) secrets group |
| env | (string) environment (dev, qa, prod...) |
| credential | (TokenCredential) any @azure/identity credential, wins over credentials |
| credentials | (object) service principal { clientId, clientSecret, tenantId }: all three, or none |
| logger | (object) logger with trace, debug, info, warn and error (console, tslog...) |
| logLevel | (string) lowest level written: trace, debug, info, warn, error or silent |
| strict | (boolean, default false) a vault or access failure throws instead of being logged only |
| concurrency | (number, default 8) requests at once in getFor, getAll and the *All functions |
| timeoutMs | (number) time limit of each operation, retries included |
| dnsCheck | (boolean, default true) resolve the vault host before the first request (see below) |
| expiryWarningDays | (number, default 30) warn about secrets expiring within these days; 0 turns it off |
| clientOptions | (SecretClientOptions) options of the secrets client: retries, proxy, user agent... |
| client | (SecretClient) secrets client, for tests |
A service principal with only some of its values throws a TypeError, naming the missing ones, and
so do invalid concurrency, timeoutMs or expiryWarningDays values. The library never writes
process.env.
Secret names
A key maps to the secret project-group-env-key, lowercase, with _ and spaces turned into -
(toSecretName(scope, key) gives it).
:(or--) nests a key:car:props:nameis{ car: { props: { name } } }.- A last segment starting with
$is shared by every group (app) of the project, in the same environment:parent:$global_varmaps toproject-env-parent--global-var. It is not shared with other projects. Without a group,KEYand$KEYmap to the same secret. - A key that does not map to a valid secret name (letters, digits and dashes, up to 127) fails
before any request; a scoped package name as group (
@scope/name) does too, so setAZURE_GROUP. - The scope (project, group, environment) compares like names do, whatever case or spaces it was
written with (
normalizeScope):My Appandmy-appare one group, and tags are written normalized.
Tags
Every secret carries tags that rebuild its key and scope (project, group, env, name, path),
plus:
| Tag | Value |
| ------------ | ---------------------------------------------------------------------------------- |
| managed-by | @achs/azure-key-vault/<version> that wrote it |
| updated-by | who wrote it: a user's upn, service-principal:<appId>, managed-identity:<name> |
| serialized | 1 for a JSON value (kept for versions before contentType) |
A JSON value (anything but a string) also has contentType: application/json, and is parsed when
either says so. updated-by comes from the identity the library signed in with (it asks for a token
before its first write); with an injected client it is left out.
Logging and traceability 🔎
Every failure is classified (AzureKeyVaultErrorKind) and logged at its level, naming the vault and
the secrets it affects. Values are never logged, only secret names.
| Kind | Meaning | Level |
| --------------------- | ------------------------------------------------------------------------- | ----- |
| vault-not-found | the vault host does not resolve (wrong URL, or the DNS/VPN that has it) | error |
| vault-unreachable | no answer from the vault (network, proxy, TLS) | error |
| timeout | an operation took longer than timeoutMs | error |
| unauthenticated | no usable identity (not signed in, wrong service principal, wrong tenant) | error |
| forbidden | the identity lacks the permission, or the vault firewall rejects it | error |
| throttled | the vault throttles requests (429) and retries are exhausted | error |
| invalid-secret-name | a key that does not map to a valid secret name | error |
| invalid-value | a secret tagged as JSON whose value does not parse, or an undefined value | error |
| unknown | anything else | error |
| secret-not-found | the secret does not exist | warn |
| secret-disabled | the secret is disabled | warn |
What each level shows:
- error / warn: one line per cause, with every secret it affects. A
forbiddenline names the identity the vault saw (appid/oid), the permission and role the operation needs (Key Vault Secrets Userto read and list,Key Vault Secrets Officerto write) and who the library signed in as. - warn also: secrets read that are expired, not valid yet (Key Vault gives them anyway) or about
to expire (
expiryWarningDays). - info: who the library signed in as (
authenticated as user [email protected] (tenant ...), from the first token: user, service principal or managed identity), the summary of every operation over many secrets (read 3 of 5 secrets, 1 not found or disabled, 1 failed), and the result ofcheck(). - debug: each secret read, written, deleted, purged or restored.
- trace: the original Azure SDK error of each cause.
keyVault.identity gives the identity once known (only when the library builds the client).
Where the logs go
- An injected
loggerreceives every level and filters itself, so a host application keeps one log level for everything.@achs/envpasses its own tslog logger this way. SetlogLevelto filter it here too. - Without a
logger, the library writes to the console with a[akv]prefix, fromlogLevel, else theAKV_LOG_LEVELenvironment variable, elsewarn.
const keyVault = new AzureKeyVault({
vaultUrl,
project: 'my-project',
env: 'dev',
logger: parentLogger.getSubLogger({ name: 'akv' }),
});Failures and strict
- Reads and maintenance (
get,getFor,getAll,keys,versions,delete,purge,restoreand the*Allfunctions) log their failures and go on: a value that could not be read keeps its default (ornull). Withstrict: true, an error level failure throws anAzureKeyVaultErroronce everything is logged; a missing or disabled secret only warns. - Writes (
set,setAll,rollback) andgetInfoalways throw anAzureKeyVaultError.
import { AzureKeyVaultError } from '@achs/azure-key-vault';
try {
await keyVault.getFor(schema);
} catch (error) {
if (error instanceof AzureKeyVaultError)
console.error(error.kind, error.vaultUrl, error.secretNames);
}classifyError(error) gives the kind of any error the Azure SDK throws.
check()
Diagnoses a vault with a single request for a secret that should not exist: it passes when the vault host resolves and answers, the identity authenticates and may read secrets, and it says who signed in. It never throws.
const { ok, kind, message, identity } = await keyVault.check();
// { ok: false, kind: 'forbidden', identity: { type: 'service-principal', appId: '...' }, message: 'https://...: the identity appid=... lacks the "get" secret permission (...), signed in as service principal appid=...' }Network: DNS check, VPN and proxies
Before its first request, the library resolves the vault host once with the system DNS, the one
every request uses (a VPN's DNS included): a host that does not resolve fails at once as
vault-not-found, instead of after the retries of each request. It never contradicts the requests,
unlike a check against public DNS servers, which a VPN may block. It is skipped when a proxy resolves
the host instead (clientOptions.proxyOptions, or HTTPS_PROXY/HTTP_PROXY/ALL_PROXY), with an
injected client, and with dnsCheck: false.
concurrency caps the requests at once (a shared vault throttles all of its callers), timeoutMs
bounds each operation, and clientOptions reaches the Azure client (retries, proxy). The user agent
carries @achs/azure-key-vault/<version>, so the vault's diagnostic logs tell who called.
Functions 🧰
| Function | Description |
| ------------------------------- | ----------------------------------------------------------------------------------- |
| get(key, { version }?) | secret value (parsed when stored as JSON), null when not read; a version if given |
| getInfo(key) | secret with its properties; throws on failure |
| set(key, value) | inserts or updates a secret; a value that is not a string is stored as JSON |
| delete(key) | deletes a secret: the deleted secret, null on failure |
| purge(key) | purges a deleted secret: whether it was purged |
| restore(key) | restores a deleted secret: its properties, null on failure |
| versions(key) | versions of a secret, newest first, without values; null on failure |
| rollback(key, version) | writes a previous version (value and tags) as the current one |
| keys() | keys of the project group, sorted, without values (needs list) |
| getFor(secrets, override?) | reads the secrets of an object whose values are defaults, typed with its shape |
| getAll() | every secret of the project group (lists the whole vault: needs list) |
| setAll(secrets) | inserts or updates the secrets of an object, in order |
| deleteAll(skipShared = true) | deletes every secret of the project group |
| purgeAll(skipShared = true) | purges every deleted secret of the project group |
| restoreAll(skipShared = true) | restores every deleted secret of the project group |
| check() | diagnoses the vault (see above) |
| audit({ allProjects }?) | audits the project (or the whole vault) without reading values (see below) |
getFor reads only the keys whose default is empty (null, '', 0, false), or every key with
override; a secret stored as JSON (an array, an object, a number) comes back parsed.
const secrets = await keyVault.getFor({
'$global-var': null,
'my-secret': null,
'my-secret-2': 'default value',
'my-secret-group1': { 'my-secret-3': null },
'my-array-secret': null,
});Audit
audit() lists the vault once (names, tags and dates; never values) and reports, for the project or
with allProjects for the whole vault, a summary (secrets, managed by the library, tagged, untagged,
projects, groups, by environment) and these findings, logged at their level:
| Finding | Level | Meaning |
| -------------------- | ----- | --------------------------------------------------------------------------- |
| untagged | warn | secrets without the library tags (invisible to getAll and keys) |
| shadowed | warn | a key both own and shared ($): @achs/env injects them as one variable |
| scope-variants | warn | one scope written in different ways (case, spaces): its secrets share names |
| name-mismatch | warn | a name that is not the one its tags give (renamed, or tagged by hand) |
| expired | warn | expired secrets |
| expiring | info | secrets expiring within expiryWarningDays |
| not-yet-valid | info | secrets whose notBefore is in the future |
| disabled | info | disabled secrets |
| unnormalized-scope | info | scope tags with capitals or spaces (normalized on their next write) |
| other-env | info | project audit with an environment: the project's secrets in other ones |
auditSecrets(properties, options) runs the same checks over secret properties listed elsewhere.
Testing with the mock
createAzureKeyVaultMock(options, { faults }) gives a handler over an in-memory vault shared by every
mock, that fails like Azure does (a missing secret is a secret-not-found). faults injects failures
by key, or for every request with '*'; resetAzureKeyVaultMock() empties the vault.
const vault = createAzureKeyVaultMock(
{ project: 'app', env: 'dev' },
{ faults: { DB_PASS: 'forbidden', '*': 'timeout' } },
);Command 💻
The akv command runs the same operations. Logs go to stderr (at info unless --log says
otherwise) and output to stdout.
| Option | Description |
| ---------------- | --------------------------------------------------------------- |
| --uri | (required) vault URL |
| --project | (required) project name |
| --group | secrets group |
| --env | environment |
| --spn | service principal (application) id |
| --password | service principal secret |
| --tenant | tenant id |
| --log | trace, debug, info (default), warn, error or silent |
| --strict | a vault or access failure fails the command |
| --concurrency | requests at once |
| --timeout | time limit of each operation, in milliseconds |
| --no-dns-check | skips resolving the vault host before the first request |
| --key | secret key (get, set, versions, rollback) |
| --value | secret value of set (else read from stdin) |
| --version | secret version (get, rollback) |
| --file | input JSON file (relative to the working directory) |
| --output | output JSON file (relative to the working directory) |
| --override | getfor reads keys with a default value too |
| --json | check and audit print their result as JSON |
| --all | audit covers the whole vault (no --project needed) |
| Command | Description |
| ---------- | ---------------------------------------------------------------------------------- |
| check | diagnoses the vault and who signed in |
| audit | audits the project, or the vault with --all; --strict fails on warnings |
| get | prints a secret value (JSON when it is not a string) |
| set | writes a secret; pipe the value (--value would stay in the shell history) |
| list | prints the keys of the project group, one per line |
| versions | prints the versions of a secret as JSON, newest first |
| rollback | writes a previous version as the current one |
| getfor | writes --output with the secrets of the --file structure (values are defaults) |
| getall | writes --output with every secret of the project group |
| publish | creates or updates the secrets of --file |
| clear | deletes every secret of the project group |
| restore | restores every deleted secret of the project group |
Exit codes: 0 success, 2 vault not found, unreachable or timed out, 3 no usable identity, 4
permission denied, 1 anything else. Reads only fail the command with --strict (or when get,
list or versions read nothing).
akv check --uri=https://my-key-vault.vault.azure.net --project=my-project --env=dev --json
printf '%s' "$DB_PASS" | akv set --uri=... --project=my-project --env=dev --key=DB_PASS
akv getfor --uri=... --project=my-project --env=dev --file=structure.json --output=secrets.json --log=debugrunCli(argv) runs the same command from code, and exitCode(kind) maps a failure kind to its code.
Development 🧿
pnpm test:cov # Vitest with coverage (100% thresholds)
pnpm lint # ESLint
pnpm typecheck # TypeScript
pnpm format # Prettier
pnpm build # Vite library build into dist/Changelog 📄
For last changes see CHANGELOG.md.
Built with 🛠️
- @azure/identity - Azure identity provider.
- @azure/keyvault-secrets - Azure Key Vault secrets client.
