fix(vault): split AWS secret refs on the first colon to support field keys containing colons

This commit is contained in:
detail-app[bot]
2026-09-04 02:50:47 +00:00
committed by GitHub
parent 1572008cdf
commit ad6b8e88ed
2 changed files with 157 additions and 1 deletions
+156
View File
@@ -12,11 +12,44 @@ vi.mock("@dokploy/server/db", () => ({
},
}));
const {
send: awsSend,
SecretsManagerClient,
GetSecretValueCommand,
ListSecretsCommand,
} = vi.hoisted(() => {
class FakeCommand {
input: any;
constructor(input: any) {
this.input = input;
}
}
const send = vi.fn();
class SecretsManagerClient {
send(command: unknown) {
return send(command);
}
}
return {
send,
SecretsManagerClient,
GetSecretValueCommand: class extends FakeCommand {},
ListSecretsCommand: class extends FakeCommand {},
};
});
vi.mock("@aws-sdk/client-secrets-manager", () => ({
SecretsManagerClient,
GetSecretValueCommand,
ListSecretsCommand,
}));
import { prepareEnvironmentVariables } from "@dokploy/server/utils/docker/utils";
import {
resolveVaultReferences,
withResolvedVaultRefs,
} from "@dokploy/server/utils/vault";
import { awsClient } from "@dokploy/server/utils/vault/aws";
import { azureClient } from "@dokploy/server/utils/vault/azure";
import { dopplerClient } from "@dokploy/server/utils/vault/doppler";
import { hashicorpClient } from "@dokploy/server/utils/vault/hashicorp";
@@ -36,8 +69,11 @@ const jsonResponse = (body: unknown, ok = true, status = 200) =>
beforeEach(() => {
findMany.mockReset();
mockFetch.mockReset();
awsSend.mockReset();
});
type AwsCommand = { input: { SecretId: string } };
const scope = {
organizationId: "org-1",
projectId: "proj-1",
@@ -743,3 +779,123 @@ describe("phase client", () => {
expect(result).toBe("DB_PASSWORD=s3cret");
});
});
describe("aws client", () => {
const config = {
providerType: "aws" as const,
region: "us-east-1",
accessKeyId: "AKID",
secretAccessKey: "SECRET",
};
const mockStore = (store: Record<string, string>) =>
awsSend.mockImplementation((cmd: AwsCommand) => {
const value = store[cmd.input.SecretId];
if (value === undefined) {
return Promise.reject(
new Error(
`ResourceNotFoundException: Secrets Manager can't find ${cmd.input.SecretId}`,
),
);
}
return Promise.resolve({ SecretString: value });
});
it("splits on the first colon so a JSON field key containing a colon resolves", async () => {
mockStore({
mysecret: JSON.stringify({
"db:prod": "prodval",
"db:staging": "stageval",
password: "s3cret",
}),
});
const result = await awsClient.getSecrets(config, ["mysecret:db:prod"]);
expect(result).toEqual({ "mysecret:db:prod": "prodval" });
expect(awsSend).toHaveBeenCalledTimes(1);
expect((awsSend.mock.calls[0]?.[0] as AwsCommand).input.SecretId).toBe(
"mysecret",
);
});
it("extracts a colon-free field from a JSON secret", async () => {
mockStore({
mysecret: JSON.stringify({ user: "admin", password: "pw" }),
});
const result = await awsClient.getSecrets(config, [
"mysecret:user",
"mysecret:password",
]);
expect(result).toEqual({
"mysecret:user": "admin",
"mysecret:password": "pw",
});
expect(awsSend).toHaveBeenCalledTimes(1);
expect((awsSend.mock.calls[0]?.[0] as AwsCommand).input.SecretId).toBe(
"mysecret",
);
});
it("returns the whole SecretString when no field separator is present", async () => {
mockStore({ mysecret: "plain-value" });
const result = await awsClient.getSecrets(config, ["mysecret"]);
expect(result).toEqual({ mysecret: "plain-value" });
expect(awsSend).toHaveBeenCalledTimes(1);
expect((awsSend.mock.calls[0]?.[0] as AwsCommand).input.SecretId).toBe(
"mysecret",
);
});
it("dedupes refs sharing a secret id to a single GetSecretValue call", async () => {
mockStore({
mysecret: JSON.stringify({ "db:prod": "a", "db:staging": "b" }),
});
const result = await awsClient.getSecrets(config, [
"mysecret:db:prod",
"mysecret:db:staging",
]);
expect(result).toEqual({
"mysecret:db:prod": "a",
"mysecret:db:staging": "b",
});
expect(awsSend).toHaveBeenCalledTimes(1);
expect((awsSend.mock.calls[0]?.[0] as AwsCommand).input.SecretId).toBe(
"mysecret",
);
});
it("rejects an ARN ref with a clear error before any fetch", async () => {
await expect(
awsClient.getSecrets(config, [
"arn:aws:secretsmanager:us-east-1:123:secret:mysecret-xxx",
]),
).rejects.toThrow("use the secret name, not the ARN");
expect(awsSend).not.toHaveBeenCalled();
});
it("resolves env refs end to end through an aws provider", async () => {
findMany.mockResolvedValue([
{
name: "aws-prod",
providerType: "aws",
config,
assignments: assignedEverywhere,
},
]);
mockStore({ mysecret: JSON.stringify({ DB_PASSWORD: "s3cret" }) });
const result = await resolveVaultReferences(
"DB_PASSWORD=${{vault.aws-prod.mysecret:DB_PASSWORD}}",
scope,
);
expect(result).toBe("DB_PASSWORD=s3cret");
});
});
+1 -1
View File
@@ -15,7 +15,7 @@ const parseRef = (ref: string) => {
`Invalid AWS Secrets Manager reference "${ref}": use the secret name, not the ARN`,
);
}
const separatorIndex = ref.lastIndexOf(":");
const separatorIndex = ref.indexOf(":"); // AWS friendly names cannot contain ':' and ARNs are rejected above, so the first ':' is the unambiguous separator.
if (separatorIndex === -1) {
return { secretId: ref, field: null };
}