@qrvey/system-key-manager
v0.2.0-1369
Published
Keeps API_KEY in sync with the qrvey-system-key Kubernetes secret mounted as a read-only volume
Readme
@qrvey/system-key-manager
@qrvey/system-key-manager keeps the platform's API key environment
variable aligned with the system key, the internal key used for
backend-to-backend calls. That variable is named by the exported ENV.API_KEY,
and is written <api-key-env> throughout this document.
Today the key reaches each service as an environment variable injected from a
Kubernetes ConfigMap, generated once at environment creation. That makes the
value visible in the pod spec and impossible to rotate without a redeploy. This
package moves the source of truth to a read-only Kubernetes Secret mounted as a
volume, and refreshes <api-key-env> in place when the Secret changes.
Consumers that read <api-key-env> per call need no change. Code that
captures it once at import time — export const key = process.env[ENV.API_KEY] —
does: it keeps the value it read at load time and never observes a rotation. See
Usage for the ordering this implies.
Installation
npm install @qrvey/system-key-managerThe secret
Names and paths are environment-specific and are not reproduced here; the placeholders below stand in for the values your environment defines.
| | |
| ----------- | --------------------- |
| Secret name | <system-key-secret> |
| Mount path | <mount-path> |
| Mount mode | read-only |
A Secret volume mounts as a directory with one file per data key, and this package reads either of the two layouts that can produce:
One data key per slot. previous, current and next each appear as a
file inside the mount directory, named after the data key and holding the key
value. Slot values are trimmed, so a slot written with a trailing newline — what
openssl rand -base64 32 produces — still matches the value callers send. This
is the platform layout, and SYSTEM_KEY_PATH only has to name the mount
directory:
<mount-path>/previous
<mount-path>/current
<mount-path>/nextOne data key holding the whole document. The entry appears as a single file
holding the JSON below, and SYSTEM_KEY_PATH has to name that file rather than
the directory it sits in. This is also the shape a plain file takes in
development.
{
"previous": "<base64-encoded-key>",
"current": "<base64-encoded-key>",
"next": "<base64-encoded-key>"
}The layout is decided on every read from stat, so one build serves both and
moving between them needs no configuration change.
Three slots exist so a rotation can converge without downtime: callers always
send current, and the authorizer accepts current and next so a rotation
is accepted from either side of the promotion. previous is deliberately not
accepted — it is retained for audit, and a leaked key has to stop working the
moment it is rotated out. Only current is required; previous and next are
optional and are dropped when empty.
An example volume mount (defaultMode: 292 is 0444 — pods may run under
different uids, so the file has to be readable by all of them):
volumes:
- name: system-key
secret:
secretName: <system-key-secret>
defaultMode: 292
containers:
- name: <service>
volumeMounts:
- name: system-key
mountPath: <mount-path>
readOnly: trueUsage
Start the manager once during bootstrap, before anything reads
<api-key-env>:
// system-key.bootstrap.ts — side-effect only, no exports
import { startSystemKeyManagerSync } from '@qrvey/system-key-manager';
startSystemKeyManagerSync();// main.ts — this import must come first
import './system-key.bootstrap';
import { key } from './config';
// From here on, the API key variable tracks the secret's `current` slot.The separate module is not ceremony. Imports are hoisted: in the CommonJS
output every require runs before any statement in the file, so a bare
startSystemKeyManagerSync() in the entry point executes after the modules it
was meant to precede. A side-effect-only module imported first is what actually
lands the key before anything reads it, because CommonJS evaluates imports in
source order. (node -r ./system-key.bootstrap does the same job from outside.)
That ordering only matters for modules that capture the key at import time. Code
that reads <api-key-env> per call is unaffected and needs no preload.
The first read is blocking, so <api-key-env> holds the active key by the time
the call returns. After that the mount is re-read every 5 seconds and
<api-key-env> is rewritten whenever it drifts from current.
Services that already have an async bootstrap can await the manager there
instead. Do not lift this to module top level: the platform compiles to
CommonJS, where top-level await is a syntax error.
import { startSystemKeyManager } from '@qrvey/system-key-manager';
async function bootstrap() {
await startSystemKeyManager();
// ... start the server
}
void bootstrap();Code that needs the key explicitly — for example to validate an incoming
x-api-key, where the other rotation slots matter — can read the document
directly:
import {
getSystemKey,
getSystemKeyDefinition,
} from '@qrvey/system-key-manager';
getSystemKey(); // the active key, falling back to the environment
getSystemKeyDefinition(); // { previous?, current, next? } | nullAPI
Functions
These operate on one process-wide instance, because the environment is process state.
startSystemKeyManager(options?): Promise<SystemKeyManagerService>— reads the secret once, then starts the poller. Idempotent.startSystemKeyManagerSync(options?): SystemKeyManagerService— the same, with a blocking first read. For CommonJS entry points that have noawaitto give:<api-key-env>is in place before it returns.stopSystemKeyManager(): void— stops the poller and drops the instance.<api-key-env>keeps whatever value it holds.getSystemKeyManager(options?): SystemKeyManagerService— the shared instance.optionsonly apply the first time, when the instance is created.getSystemKey(): string | undefined— the active key, falling back to<api-key-env>before the first successful read.getSystemKeyDefinition(): ISystemKeyDefinition | null— the last document read, ornullif the secret was never readable.
Validating an inbound key
isAcceptedSystemKey(candidate?, options?): boolean— the platform's inboundx-api-keyrule. Acceptscurrentandnextfrom the mounted secret; then, only while legacy mode is on, the pre-migration key and anyoptions.compatKeysthe caller supplies.previousis never accepted — it is retained for audit, and a key must stop working the moment it is rotated out. This mirrorssystemKeyManagement.ValidateSystemKeyinqrvey_admin_securityso the Go and Node validators cannot drift. Use this instead of comparing against<api-key-env>yourself.isLegacyModeEnabled(): boolean— whether<legacy-mode-env>is exactly'true'. No other value enables it.getLegacyApiKey(): string | undefined— the value<api-key-env>carried before this package replaced it.secureEquals(candidate?, expected?): boolean— constant-time comparison. An absent expected value never matches, so an unset key cannot authenticate a request that carries no key at all.
options.compatKeys values belong in the consuming repository, never in this
package: it publishes with access: public, and npm ships README.md regardless
of the files allowlist.
The pre-migration key
This package replaces <api-key-env> with the secret's current slot, so a
validator that compares an inbound key against <api-key-env> becomes
system-key-only from that moment. The value it replaced is preserved two ways:
getLegacyApiKey(), for consumers that import this package.<legacy-api-key-env>, written once at module evaluation, for consumers that cannot — embedded libraries that never declare a dependency on it, or services resolving an older copy.
Both return the same value. Do not capture <api-key-env> into a
module-scope constant in your own service to preserve it: importing anything
that starts the manager first — qrvey-security does, at module scope — makes
that capture record the system key and reject every legacy caller. The capture
here happens at this package's own module evaluation, which no require order can
get ahead of.
SystemKeyManagerService
Exported for tests and for the rare consumer that needs its own instance.
| Method | Description |
| -------------------------------------------------------- | --------------------------------------------------------------- |
| start(): Promise<void> | First read plus poller. No-op when already running or disabled. |
| startSync(): void | As start(), with a blocking first read. |
| stop(): void | Stops the poller. |
| refresh(): Promise<ISystemKeyDefinition \| null> | One read-and-apply cycle. |
| refreshSync(): ISystemKeyDefinition \| null | As refresh(), blocking. |
| getSystemKey(): string \| undefined | Active key, falling back to <api-key-env>. |
| getSystemKeyDefinition(): ISystemKeyDefinition \| null | Last document read. |
| getStatus(): ISystemKeyStatus | Resolved configuration and current state, for health checks. |
ISystemKeyManagerOptions
| Option | Type | Default |
| ---------------- | --------- | -------------------------------------------- |
| path | string | SYSTEM_KEY_PATH, then the built-in default |
| pollIntervalMs | number | SYSTEM_KEY_POLL_INTERVAL_MS, then 5000 |
| disabled | boolean | SYSTEM_KEY_MANAGER_DISABLED |
Explicit options always win over the environment.
Environment
| Variable | Values | Default | Description |
| ----------------------------- | -------------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| <api-key-env> | string | — | The managed value, named by the exported ENV.API_KEY. Read by the rest of the platform; written by this package. |
| <legacy-api-key-env> | string | — | The value <api-key-env> held before this package replaced it, named by ENV.LEGACY_API_KEY. Written once at module evaluation; an existing value is kept. |
| <legacy-mode-env> | 'true' | unset | unset | Named by ENV.API_KEY_LEGACY_MODE. Exactly 'true' keeps the pre-migration key and any compat keys accepted. '1' does not enable it. |
| SYSTEM_KEY_MANAGER_DISABLED | 'true' | '1' | unset | unset | Kill switch. When on, the poller never starts and <api-key-env> is never replaced. |
| SYSTEM_KEY_PATH | string | built-in default | The mount to read: a directory holding one file per slot, or a single file holding the JSON document. Only needed when the mount is not at the built-in default. |
| SYSTEM_KEY_POLL_INTERVAL_MS | positive number | 5000 | Overrides the polling cadence. An invalid value warns and falls back to the default. |
Setting SYSTEM_KEY_MANAGER_DISABLED is the escape hatch for an environment
that has not been migrated yet, or for rolling back a bad rotation: the service
falls back to whatever value the ConfigMap injected, with no code change.
Failure behaviour
The manager never throws into the host process and never removes a key that is already in place.
| Situation | Behaviour |
| ------------------------------------------- | --------------------------------------------------------------------- |
| Secret file absent | Warns that the secret was not found. <api-key-env> keeps its value. |
| Secret unreadable (permissions, I/O) | Warns with the underlying reason. <api-key-env> keeps its value. |
| Document malformed (not JSON, no current) | Warns. The last valid definition stays in effect. |
| Mount carries no current entry | Warns. The last valid definition stays in effect. |
| One slot exists but cannot be read | Warns and treats that slot as empty; the other slots still apply. |
| Secret valid again after a failure | Picked up on the next poll; the warning state resets. |
| A poll throws unexpectedly | Caught and logged. The poller keeps running. |
Warnings are emitted on state change rather than on every poll, so a missing secret produces one line instead of one line every 5 seconds.
Implementation notes
- Polling, not
fs.watch. The kubelet propagates Secret updates by atomically swapping the symlink tree behind the mount.fs.watchstops firing after the first swap, silently, so the mount is re-read on a timer instead. - Cheap steady state. A single-file secret is stat-fingerprinted (mtime, size, inode) and only re-read when it changes; the atomic-writer swap always lands on a new inode, so a rotation is never missed. A directory mount is re-read every poll instead — a directory's own stat does not change reliably when the kubelet swaps the tree underneath it, and three reads from tmpfs cost less than the risk of missing a rotation.
<api-key-env>is also re-checked on every poll, not just when the mount changes, so a value overwritten elsewhere in the process converges back.- Slots are read through the kubelet's
..datalink, resolved once per poll, so all three come from one consistent snapshot even when a rotation lands between two of the reads. A mount without that link — a subPath projection, or a plain directory — is read directly. - The mount is never listed. The three slots are read by name, because the mount also holds the kubelet's own bookkeeping entries.
- The poller is
unref'd and will not hold the event loop open on its own. - Key values are never logged.
