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; },