Files
openclaw/extensions/vault/vault-secret-ref-resolver.js
Sally O'Malley e595a8c0ac Add Vault SecretRef plugin (#89255)
* Add Vault SecretRef plugin

Signed-off-by: sallyom <somalley@redhat.com>

* expand Vault setup to registered SecretRef targets

Signed-off-by: sallyom <somalley@redhat.com>

* fix(vault): use sdk secret target seam

* fix(vault): preserve auth profile target paths

* docs(vault): document plugin enable step

* fix(vault): make status provider-alias aware

* fix(vault): reject noncanonical secret ids

* fix(vault): separate resolver timeout deadlines

* fix(vault): forward private CA trust settings

* fix(secrets): preserve plugin policy boundaries

---------

Signed-off-by: sallyom <somalley@redhat.com>
Co-authored-by: joshavant <830519+joshavant@users.noreply.github.com>
2026-07-09 05:30:12 -05:00

295 lines
8.2 KiB
JavaScript

#!/usr/bin/env node
import { readFile } from "node:fs/promises";
import { parseVaultSecretId } from "./vault-secret-id.js";
const KUBERNETES_SERVICE_ACCOUNT_TOKEN_PATH = "/var/run/secrets/kubernetes.io/serviceaccount/token";
const VAULT_FETCH_TIMEOUT_MS = 5000;
function readStdin() {
return new Promise((resolve, reject) => {
let input = "";
process.stdin.setEncoding("utf8");
process.stdin.on("data", (chunk) => {
input += String(chunk);
});
process.stdin.on("error", reject);
process.stdin.on("end", () => resolve(input));
});
}
function writeResponse(response) {
process.stdout.write(`${JSON.stringify(response)}\n`);
}
function parseRequest(input) {
const parsed = JSON.parse(input);
if (!parsed || typeof parsed !== "object" || !Array.isArray(parsed.ids)) {
throw new Error("invalid exec SecretRef request");
}
return {
protocolVersion: 1,
ids: parsed.ids.filter((id) => typeof id === "string" && id.length > 0),
};
}
function normalizeVaultAddress() {
const raw = process.env.VAULT_ADDR?.trim();
if (!raw) {
throw new Error("VAULT_ADDR is required.");
}
const address = raw.replace(/\/+$/u, "");
let parsed;
try {
parsed = new URL(address);
} catch {
throw new Error("VAULT_ADDR must be a valid http or https URL.");
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error("VAULT_ADDR must be a valid http or https URL.");
}
return address;
}
function normalizeOptionalString(value) {
return value?.trim() || undefined;
}
function resolveVaultAuthMethod() {
const method = normalizeOptionalString(process.env.OPENCLAW_VAULT_AUTH_METHOD) ?? "token";
if (
method === "token" ||
method === "token_file" ||
method === "jwt" ||
method === "kubernetes"
) {
return method;
}
throw new Error("OPENCLAW_VAULT_AUTH_METHOD must be token, token_file, jwt, or kubernetes.");
}
function resolveVaultTokenEnv() {
const token = process.env.VAULT_TOKEN?.trim();
if (!token) {
throw new Error("VAULT_TOKEN is required.");
}
return token;
}
async function resolveVaultTokenFile() {
const tokenFile = normalizeOptionalString(process.env.VAULT_TOKEN_FILE);
if (!tokenFile) {
throw new Error("VAULT_TOKEN_FILE is required.");
}
const token = (await readFile(tokenFile, "utf8")).trim();
if (!token) {
throw new Error("VAULT_TOKEN_FILE did not contain a token.");
}
return token;
}
function resolveKvMount() {
return process.env.OPENCLAW_VAULT_KV_MOUNT?.trim().replace(/^\/+|\/+$/gu, "") || "secret";
}
function resolveKvVersion() {
const raw = process.env.OPENCLAW_VAULT_KV_VERSION?.trim();
if (!raw || raw === "2") {
return 2;
}
if (raw === "1") {
return 1;
}
throw new Error("OPENCLAW_VAULT_KV_VERSION must be 1 or 2.");
}
function encodePath(pathValue) {
return pathValue
.split("/")
.map((segment) => encodeURIComponent(segment))
.join("/");
}
function buildVaultUrl(baseUrl, params) {
const mount = encodePath(resolveKvMount());
const secretPath = encodePath(params.secretPath);
if (resolveKvVersion() === 2) {
return `${baseUrl}/v1/${mount}/data/${secretPath}`;
}
return `${baseUrl}/v1/${mount}/${secretPath}`;
}
function assertVaultRequestUrl(baseUrl, requestUrl) {
const base = new URL(baseUrl);
const target = new URL(requestUrl);
if (target.protocol !== "http:" && target.protocol !== "https:") {
throw new Error("Vault request URL must be a valid http or https URL.");
}
if (target.origin !== base.origin) {
throw new Error("Vault request URL must stay on the configured VAULT_ADDR origin.");
}
}
async function fetchVault(baseUrl, url, init) {
assertVaultRequestUrl(baseUrl, url);
const abortController = new AbortController();
const timeout = setTimeout(() => abortController.abort(), VAULT_FETCH_TIMEOUT_MS);
try {
return await fetch(url, {
...init,
redirect: "manual",
signal: abortController.signal,
});
} finally {
clearTimeout(timeout);
}
}
function addVaultNamespaceHeader(headers) {
const namespace = process.env.VAULT_NAMESPACE?.trim();
if (namespace) {
headers["X-Vault-Namespace"] = namespace;
}
}
function resolveVaultAuthMount(method) {
return process.env.OPENCLAW_VAULT_AUTH_MOUNT?.trim().replace(/^\/+|\/+$/gu, "") || method;
}
function resolveVaultAuthRole(method) {
const role = normalizeOptionalString(process.env.OPENCLAW_VAULT_AUTH_ROLE);
if (!role) {
throw new Error(`OPENCLAW_VAULT_AUTH_ROLE is required for ${method} auth.`);
}
return role;
}
async function resolveVaultJwt(method) {
const jwtFile =
normalizeOptionalString(process.env.OPENCLAW_VAULT_JWT_FILE) ??
(method === "kubernetes" ? KUBERNETES_SERVICE_ACCOUNT_TOKEN_PATH : undefined);
if (!jwtFile) {
throw new Error("OPENCLAW_VAULT_JWT_FILE is required for jwt auth.");
}
const jwt = (await readFile(jwtFile, "utf8")).trim();
if (!jwt) {
throw new Error("OPENCLAW_VAULT_JWT_FILE did not contain a JWT.");
}
return jwt;
}
function readVaultLoginToken(payload, method) {
const token = payload?.auth?.client_token;
if (typeof token !== "string" || !token.trim()) {
throw new Error(`Vault ${method} login response did not include auth.client_token.`);
}
return token;
}
async function resolveVaultTokenFromJwt(baseUrl, method) {
const mount = encodePath(resolveVaultAuthMount(method));
const headers = {
"Content-Type": "application/json",
};
addVaultNamespaceHeader(headers);
const response = await fetchVault(baseUrl, `${baseUrl}/v1/auth/${mount}/login`, {
method: "POST",
headers,
body: JSON.stringify({
role: resolveVaultAuthRole(method),
jwt: await resolveVaultJwt(method),
}),
});
if (!response.ok) {
throw new Error(`Vault ${method} login failed (${response.status}).`);
}
return readVaultLoginToken(await response.json(), method);
}
async function resolveVaultClientToken(baseUrl) {
switch (resolveVaultAuthMethod()) {
case "token":
return resolveVaultTokenEnv();
case "token_file":
return await resolveVaultTokenFile();
case "jwt":
return await resolveVaultTokenFromJwt(baseUrl, "jwt");
case "kubernetes":
return await resolveVaultTokenFromJwt(baseUrl, "kubernetes");
}
throw new Error("Unsupported Vault auth method.");
}
function readStringField(payload, parsedId) {
const record = payload;
const data = resolveKvVersion() === 2 ? record?.data?.data : record?.data;
const value = data?.[parsedId.field];
if (typeof value !== "string") {
throw new Error(
`Vault secret "${parsedId.secretPath}/${parsedId.field}" did not contain a string field "${parsedId.field}".`,
);
}
return value;
}
async function readVaultSecret(baseUrl, vaultToken, id) {
const parsedId = parseVaultSecretId(id);
const headers = {
"X-Vault-Token": vaultToken,
};
addVaultNamespaceHeader(headers);
const response = await fetchVault(baseUrl, buildVaultUrl(baseUrl, parsedId), { headers });
if (!response.ok) {
throw new Error(`Vault read failed for "${id}" (${response.status}).`);
}
return readStringField(await response.json(), parsedId);
}
async function resolveFromVault(ids) {
const response = { protocolVersion: 1, values: {}, errors: {} };
if (ids.length === 0) {
return response;
}
const contextPromise = Promise.resolve().then(async () => {
const baseUrl = normalizeVaultAddress();
return {
baseUrl,
vaultToken: await resolveVaultClientToken(baseUrl),
};
});
await Promise.all(
ids.map(async (id) => {
try {
const { baseUrl, vaultToken } = await contextPromise;
response.values[id] = await readVaultSecret(baseUrl, vaultToken, id);
} catch (error) {
response.errors[id] = {
message: error instanceof Error ? error.message : String(error),
};
}
}),
);
return response;
}
async function main() {
const input = await readStdin();
const request = parseRequest(input);
writeResponse(await resolveFromVault(request.ids));
}
/** @param {unknown} error */
function handleFatalError(error) {
writeResponse({
protocolVersion: 1,
values: {},
errors: {
request: {
message: error instanceof Error ? error.message : String(error),
},
},
});
}
main().catch(handleFatalError);