Merge pull request #5375 from aspatari/feat/infisical-path-in-reference
Some checks are pending
Auto PR to main when version changes / create-pr (push) Waiting to run
Build Docker images / build-and-push-cloud-image (push) Waiting to run
Build Docker images / build-and-push-schedule-image (push) Waiting to run
Build Docker images / build-and-push-server-image (push) Waiting to run
Dokploy Docker Build / docker-amd (push) Waiting to run
Dokploy Docker Build / docker-arm (push) Waiting to run
Dokploy Docker Build / combine-manifests (push) Blocked by required conditions
Dokploy Docker Build / generate-release (push) Blocked by required conditions
Dokploy Docker Build / sync-version (push) Blocked by required conditions
autofix.ci / format (push) Waiting to run
Dokploy Monitoring Build / docker-amd (push) Waiting to run
Dokploy Monitoring Build / docker-arm (push) Waiting to run
Dokploy Monitoring Build / combine-manifests (push) Blocked by required conditions
Generate and Sync OpenAPI / Generate OpenAPI and commit to Dokploy repo (push) Waiting to run

feat(vault): address an Infisical folder from the reference
This commit is contained in:
Narciso E. Núñez Arias 2026-09-08 16:54:01 -04:00 committed by GitHub
commit e99a98e4fe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 171 additions and 17 deletions

View File

@ -444,13 +444,22 @@ describe("infisical client", () => {
};
const loginResponse = () => jsonResponse({ accessToken: "token-1" });
const list = (secrets: Record<string, string>) =>
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 <path>:<KEY> 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<string, Record<string, string>> = {
"/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 <path>:<KEY>",
);
await expect(
infisicalClient.getSecrets(config, ["external/sentry:"]),
).rejects.toThrow("expected format <path>:<KEY>");
});
});
describe("doppler client", () => {

View File

@ -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: `<path>:<KEY>`, 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 <path>:<KEY> (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<InfisicalConfig> = {
async getSecrets(config, refs) {
const secrets = await fetchSecrets(config);
const result: Record<string, string> = {};
const byPath = new Map<string, string[]>();
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<string, string> = {};
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;
},