From 56116623511859cc15217999548ab1a7a750f20e Mon Sep 17 00:00:00 2001 From: Artur Spatari Date: Mon, 7 Sep 2026 22:53:18 +0300 Subject: [PATCH] feat(vault): address an Infisical folder from the reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An Infisical provider is pinned to one non-recursive `secretPath`, so reading two folders means two providers, two machine identities and two sets of credentials to rotate. This lets a reference name the folder instead: ${{vault.my-provider.external/sentry:SENTRY_DSN}} `:` mirrors the HashiCorp client in this directory, which already documents that exact format. A relative path resolves against the provider's `secretPath`, a leading slash is absolute, and a ref without a colon keeps its current meaning — the whole ref is the secret name at the provider's own path. A dot cannot be the separator here: Infisical accepts dots inside secret names (`A.B.C` is a valid key), so `provider.a.b.C` cannot be split unambiguously and would silently break anyone using such a name. Refs are grouped by resolved path so each path is listed once, and the login happens once per batch rather than once per path. Tests cover the bare ref, relative and absolute paths, a provider at `/`, grouping with a single login, the error naming the path, and a malformed ref. --- apps/dokploy/__test__/env/vault.test.ts | 106 ++++++++++++++++++- packages/server/src/utils/vault/infisical.ts | 82 +++++++++++--- 2 files changed, 171 insertions(+), 17 deletions(-) diff --git a/apps/dokploy/__test__/env/vault.test.ts b/apps/dokploy/__test__/env/vault.test.ts index 871fe5c57..98fd7e4d9 100644 --- a/apps/dokploy/__test__/env/vault.test.ts +++ b/apps/dokploy/__test__/env/vault.test.ts @@ -444,13 +444,22 @@ describe("infisical client", () => { }; const loginResponse = () => jsonResponse({ accessToken: "token-1" }); + const list = (secrets: Record) => + jsonResponse({ + secrets: Object.entries(secrets).map(([secretKey, secretValue]) => ({ + secretKey, + secretValue, + })), + }); + const listPathOf = (callIndex: number) => { + const [url] = mockFetch.mock.calls[callIndex] as [string]; + return new URL(url).searchParams.get("secretPath"); + }; it("asks the list endpoint to expand secret references", async () => { - mockFetch.mockResolvedValueOnce(loginResponse()).mockResolvedValueOnce( - jsonResponse({ - secrets: [{ secretKey: "DB_URL", secretValue: "postgres://real" }], - }), - ); + mockFetch + .mockResolvedValueOnce(loginResponse()) + .mockResolvedValueOnce(list({ DB_URL: "postgres://real" })); const result = await infisicalClient.getSecrets(config, ["DB_URL"]); @@ -480,6 +489,93 @@ describe("infisical client", () => { infisicalClient.getSecrets(config, ["DB_URL"]), ).rejects.toThrow("authentication failed (status 401)"); }); + + it("resolves a relative : ref against the provider path", async () => { + mockFetch + .mockResolvedValueOnce(loginResponse()) + .mockResolvedValueOnce(list({ SENTRY_DSN: "https://key@sentry.io/1" })); + + const result = await infisicalClient.getSecrets(config, [ + "shared/sentry:SENTRY_DSN", + ]); + + expect(result).toEqual({ + "shared/sentry:SENTRY_DSN": "https://key@sentry.io/1", + }); + expect(listPathOf(1)).toBe("/frontend/shared/sentry"); + }); + + it("treats a leading slash as an absolute path", async () => { + mockFetch + .mockResolvedValueOnce(loginResponse()) + .mockResolvedValueOnce(list({ SENTRY_DSN: "https://key@sentry.io/1" })); + + await infisicalClient.getSecrets(config, ["/external/sentry:SENTRY_DSN"]); + + expect(listPathOf(1)).toBe("/external/sentry"); + }); + + it("keeps the root path clean when the provider sits at /", async () => { + mockFetch + .mockResolvedValueOnce(loginResponse()) + .mockResolvedValueOnce(list({ KEY: "value" })); + + await infisicalClient.getSecrets({ ...config, secretPath: "/" }, [ + "external/sentry:KEY", + ]); + + expect(listPathOf(1)).toBe("/external/sentry"); + }); + + it("logs in once and fetches each path once", async () => { + const byPath: Record> = { + "/frontend": { A: "a", B: "b" }, + "/frontend/other": { C: "c" }, + }; + mockFetch.mockImplementation(async (url: string) => { + if (url.includes("/auth/universal-auth/login")) return loginResponse(); + const path = new URL(url).searchParams.get("secretPath") as string; + return list(byPath[path] ?? {}); + }); + + const result = await infisicalClient.getSecrets(config, [ + "A", + "B", + "other:C", + ]); + + expect(result).toEqual({ A: "a", B: "b", "other:C": "c" }); + + const urls = mockFetch.mock.calls.map(([url]) => url as string); + expect(urls.filter((u) => u.includes("/login"))).toHaveLength(1); + expect( + urls + .filter((u) => u.includes("/secrets/raw?")) + .map((u) => new URL(u).searchParams.get("secretPath")) + .sort(), + ).toEqual(["/frontend", "/frontend/other"]); + }); + + it("names the path when a secret is missing from an explicit one", async () => { + mockFetch + .mockResolvedValueOnce(loginResponse()) + .mockResolvedValueOnce(list({})); + + await expect( + infisicalClient.getSecrets(config, ["external/sentry:ABSENT"]), + ).rejects.toThrow( + 'secret "ABSENT" not found at "/frontend/external/sentry"', + ); + }); + + it("rejects a ref with an empty path or key", async () => { + await expect(infisicalClient.getSecrets(config, [":KEY"])).rejects.toThrow( + "expected format :", + ); + await expect( + infisicalClient.getSecrets(config, ["external/sentry:"]), + ).rejects.toThrow("expected format :"); + }); }); describe("doppler client", () => { diff --git a/packages/server/src/utils/vault/infisical.ts b/packages/server/src/utils/vault/infisical.ts index ad3339ff7..9af3bca2f 100644 --- a/packages/server/src/utils/vault/infisical.ts +++ b/packages/server/src/utils/vault/infisical.ts @@ -32,12 +32,47 @@ const login = async (config: InfisicalConfig) => { return body.accessToken; }; -const fetchSecrets = async (config: InfisicalConfig) => { - const accessToken = await login(config); +// A reference may address a folder: `:`, mirroring the HashiCorp +// client in this directory. Without a colon the whole ref is the secret name +// and the provider's own `secretPath` is used, which is the previous +// behaviour. Dots cannot serve as the separator here because Infisical allows +// them inside secret names, so `a.b.C` is genuinely ambiguous. +const parseRef = (ref: string) => { + const separatorIndex = ref.lastIndexOf(":"); + if (separatorIndex === -1) { + return { path: null, key: ref }; + } + const path = ref.slice(0, separatorIndex); + const key = ref.slice(separatorIndex + 1); + if (!path || !key) { + throw new Error( + `Invalid Infisical reference "${ref}": expected format : (e.g. external/sentry:SENTRY_DSN)`, + ); + } + return { path, key }; +}; + +const resolveSecretPath = (config: InfisicalConfig, refPath: string | null) => { + if (!refPath) { + return config.secretPath; + } + if (refPath.startsWith("/")) { + return refPath; + } + const base = config.secretPath.replace(/\/+$/, ""); + return `${base}/${refPath}`; +}; + +// One login serves every path a batch of refs touches. +const readPath = async ( + config: InfisicalConfig, + accessToken: string, + secretPath: string, +) => { const params = new URLSearchParams({ workspaceId: config.projectId, environment: config.environmentSlug, - secretPath: config.secretPath, + secretPath, // Infisical's list endpoint leaves secret references (`${env.folder.KEY}`) // unexpanded unless asked, so without this a referencing secret arrives as // the literal `${...}` string, lands in the generated .env and the deploy @@ -52,7 +87,7 @@ const fetchSecrets = async (config: InfisicalConfig) => { if (!response.ok) { throw new Error( - `Infisical: failed to fetch secrets (status ${response.status})`, + `Infisical: failed to fetch secrets at "${secretPath}" (status ${response.status})`, ); } @@ -67,18 +102,41 @@ const fetchSecrets = async (config: InfisicalConfig) => { return secrets; }; +const fetchSecrets = async ( + config: InfisicalConfig, + secretPath = config.secretPath, +) => readPath(config, await login(config), secretPath); + export const infisicalClient: VaultClient = { async getSecrets(config, refs) { - const secrets = await fetchSecrets(config); - const result: Record = {}; + const byPath = new Map(); for (const ref of refs) { - if (secrets[ref] === undefined) { - throw new Error( - `Infisical: secret "${ref}" not found in environment "${config.environmentSlug}"`, - ); - } - result[ref] = secrets[ref]; + const { path } = parseRef(ref); + const secretPath = resolveSecretPath(config, path); + byPath.set(secretPath, [...(byPath.get(secretPath) ?? []), ref]); } + + const accessToken = await login(config); + const result: Record = {}; + await Promise.all( + [...byPath.entries()].map(async ([secretPath, pathRefs]) => { + const secrets = await readPath(config, accessToken, secretPath); + for (const ref of pathRefs) { + const { path, key } = parseRef(ref); + if (secrets[key] === undefined) { + // The path is only worth naming when the ref asked for one; + // for a bare ref the wording stays as it was, so existing + // error messages don't change for anyone. + throw new Error( + path + ? `Infisical: secret "${key}" not found at "${secretPath}" in environment "${config.environmentSlug}"` + : `Infisical: secret "${key}" not found in environment "${config.environmentSlug}"`, + ); + } + result[ref] = secrets[key]; + } + }), + ); return result; },