ADR-030: Specialized Services Authenticate Through nr-vault
- Status
-
Accepted
- Date
-
2026-06-09
- Authors
-
Netresearch DTT GmbH
Context
The database-backed LLM providers have authenticated through the nr-vault
secure HTTP client since ADR-012: API key encryption at application level — they store a vault identifier
(a UUID) rather than a plaintext key, and Abstract
returns $vault->http()->withAuthentication(...) so the secret is resolved,
injected, audited, and memory-scrubbed inside the vault. The plaintext key
never surfaces in this extension's code.
The five specialised single-task services — DALL-E and FAL (image), Whisper
and TTS (speech), and DeepL (translation), all built on
Abstract (see REC #7) — predated that posture. Each
read a plaintext apiKey from extension configuration into a
protected string $apiKey property and assembled its own Authorization
header via a buildAuthHeaders() hook, sending the request through a plain
PSR-18 client. This contradicted ADR-012: API key encryption at application level and the project rule that
API keys MUST be stored as nr-vault UUID identifiers, never as plaintext.
Two of the services do not use the Bearer scheme: FAL expects
Authorization: Key <secret> and DeepL expects
Authorization: DeepL-Auth-Key <secret>. The secure client's Header
placement could previously inject only the bare secret as a header value, so
these schemes could not be expressed through it at all — which is why they had
remained on the plaintext path. nr-vault 0.8.0 added a prefix option to
withAuthentication() for Header placement, removing that blocker.
Decision
Migrate every keyed specialised service onto the vault secure HTTP client,
mirroring Abstract:
- Identifier, not key.
AbstracttakesSpecialized Service Vaultas its first constructor argument and storesService Interface $apiKeyIdentifier(the vault UUID) instead of$apiKey.isbecomesAvailable () $apiKeyIdentifier !== '' && $vault->exists($apiKeyIdentifier). -
Placement hooks replace
buildAuthHeaders(). The base exposesget(defaultSecret Placement () Secret),Placement:: Bearer get(defaultSecret Placement Options () []), andget(non-auth headers only, e.g. DeepL'sAdditional Headers () User-Agent).getbuildsSecure Client () $vault->http()->withAuthentication($id, placement, options)->withReason(...)andexecuteRequest()sends through it. Per-service placement:- DALL-E, Whisper, TTS —
Bearer(OpenAI family). - FAL —
Header+{headerName: Authorization, prefix: 'Key '}. - DeepL —
Header+{headerName: Authorization, prefix: 'DeepL-Auth-Key '}.
- DALL-E, Whisper, TTS —
- DeepL Free/Pro routing stays automatic. DeepL selects the
api-free.deepl.comhost for keys ending in:fxandapi.deepl.comotherwise. Since the key is no longer held as plaintext, the host is resolved lazily on the first request: the secret is retrieved from the vault exactly once, tested for the:fxsuffix, and immediatelysodium_memzero-d. An explicitbaseUrloverride still wins. The request itself always authenticates through the audited secure client, never that transient copy. - Configuration. The ext_conf keys become identifiers:
providers.openai.apiKeyIdentifier(DALL-E/Whisper/TTS),image.fal.apiKeyIdentifier, andtranslators.deepl.apiKeyIdentifier.
A setHttpClient() test seam — identical to the providers' — lets unit
tests inject a plain client and assert request/response plumbing without the
vault; the placement hooks are asserted directly.
Consequences
- No specialised service holds a plaintext API key; every upstream call is audited and the secret is scrubbed inside the vault, satisfying ADR-012: API key encryption at application level uniformly across providers and specialised services.
- Requires nr-vault
^0.8.0(theprefixoption). A0.7install would silently drop the prefix and send a brokenAuthorizationheader for FAL/DeepL, so the composer floor is raised. - Host applications that previously wrote
providers.openai.apiKey(and the FAL/DeepL plaintext keys) into nr_llm's extension configuration must store a vault secret and write its identifier instead. - DeepL incurs one extra vault read per service instance the first time it sends a request (to choose Free/Pro); the result is cached for the instance lifetime.