Merge pull request #5266 from its-felix/codex/5265-aws-parameter-store

feat(vault): add AWS Parameter Store provider
This commit is contained in:
Mauricio Siu
2026-09-08 00:09:07 -06:00
committed by GitHub
14 changed files with 9633 additions and 9 deletions
+195
View File
@@ -0,0 +1,195 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
type HasInput = { input: Record<string, unknown> };
const findMany = vi.hoisted(() => vi.fn());
const {
send,
paginate,
SSMClient,
GetParametersCommand,
DescribeParametersCommand,
} = vi.hoisted(() => {
class FakeCommand {
input: Record<string, unknown>;
constructor(input: Record<string, unknown>) {
this.input = input;
}
}
const send = vi.fn();
const paginate = vi.fn();
class SSMClient {
send(command: unknown) {
return send(command);
}
}
return {
send,
paginate,
SSMClient,
GetParametersCommand: class extends FakeCommand {},
DescribeParametersCommand: class extends FakeCommand {},
};
});
vi.mock("@aws-sdk/client-ssm", () => ({
SSMClient,
GetParametersCommand,
DescribeParametersCommand,
paginateDescribeParameters: paginate,
}));
vi.mock("@dokploy/server/db", () => ({
db: {
query: {
vaultProvider: {
findMany: (...args: unknown[]) => findMany(...args),
},
},
},
}));
import { resolveVaultReferences } from "@dokploy/server/utils/vault";
import { awsParameterStoreClient } from "@dokploy/server/utils/vault/aws-parameter-store";
const config = {
providerType: "aws-parameter-store" as const,
region: "eu-central-1",
accessKeyId: "AKIA_TEST",
secretAccessKey: "secret",
};
beforeEach(() => {
send.mockReset();
paginate.mockReset();
findMany.mockReset();
});
describe("awsParameterStoreClient", () => {
it("decrypts parameters in batches of ten and preserves selectors", async () => {
const refs = [
"/prod/database:CURRENT",
...Array.from({ length: 10 }, (_, index) => `/prod/secret-${index}`),
];
send.mockImplementation(async (command: HasInput) => ({
Parameters: (command.input.Names as string[]).map((ref) => {
if (ref === "/prod/database:CURRENT") {
return {
Name: "/prod/database",
Selector: ":CURRENT",
Value: "selected-value",
};
}
return { Name: ref, Value: `value-for-${ref}` };
}),
}));
const result = await awsParameterStoreClient.getSecrets(config, refs);
expect(send).toHaveBeenCalledTimes(2);
expect((send.mock.calls[0]?.[0] as HasInput).input).toMatchObject({
WithDecryption: true,
});
expect(
((send.mock.calls[0]?.[0] as HasInput).input.Names as string[]).length,
).toBe(10);
expect(
((send.mock.calls[1]?.[0] as HasInput).input.Names as string[]).length,
).toBe(1);
expect(result["/prod/database:CURRENT"]).toBe("selected-value");
expect(result["/prod/secret-9"]).toBe("value-for-/prod/secret-9");
});
it("reports a missing parameter without exposing other values", async () => {
send.mockResolvedValue({ Parameters: [], InvalidParameters: ["/missing"] });
await expect(
awsParameterStoreClient.getSecrets(config, ["/missing"]),
).rejects.toThrow('AWS Parameter Store: parameter "/missing" not found');
});
it("tests the connection within the configured hierarchy", async () => {
send.mockResolvedValue({ Parameters: [] });
await awsParameterStoreClient.testConnection({
...config,
parameterPath: "/production/my-app/",
});
expect(send).toHaveBeenCalledTimes(1);
expect((send.mock.calls[0]?.[0] as HasInput).input).toEqual({
ParameterFilters: [
{
Key: "Path",
Option: "Recursive",
Values: ["/production/my-app"],
},
],
MaxResults: 1,
});
});
it("explains the discovery permission when connection testing is denied", async () => {
const error = new Error("not authorized");
error.name = "AccessDeniedException";
send.mockRejectedValue(error);
await expect(
awsParameterStoreClient.testConnection(config),
).rejects.toThrow("ssm:DescribeParameters");
});
it("lists parameter names across pages within the configured hierarchy", async () => {
paginate.mockReturnValue(
(async function* () {
yield { Parameters: [{ Name: "/prod/db" }] };
yield { Parameters: [{ Name: "/prod/api" }] };
})(),
);
const names = await awsParameterStoreClient.listSecretNames?.({
...config,
parameterPath: " /production/my-app/ ",
});
expect(names).toEqual(["/prod/db", "/prod/api"]);
expect(paginate).toHaveBeenCalledWith(
expect.objectContaining({ pageSize: 50 }),
{
ParameterFilters: [
{
Key: "Path",
Option: "Recursive",
Values: ["/production/my-app"],
},
],
},
);
});
it("resolves a vault reference through the registered provider", async () => {
findMany.mockResolvedValue([
{
name: "ssm-prod",
providerType: "aws-parameter-store",
config,
assignments: [{ projectId: "project-1", environmentIds: [] }],
},
]);
send.mockResolvedValue({
Parameters: [{ Name: "/prod/database-password", Value: "resolved" }],
});
const result = await resolveVaultReferences(
"DB_PASSWORD=${{vault.ssm-prod./prod/database-password}}",
{
organizationId: "organization-1",
projectId: "project-1",
environmentId: "environment-1",
},
);
expect(result).toBe("DB_PASSWORD=resolved");
});
});
@@ -40,6 +40,7 @@ const providerLabels = {
hashicorp: "HashiCorp Vault / OpenBao",
infisical: "Infisical",
aws: "AWS Secrets Manager",
"aws-parameter-store": "AWS Parameter Store",
doppler: "Doppler",
azure: "Azure Key Vault",
scaleway: "Scaleway Secret Manager",
@@ -61,6 +62,7 @@ const VaultProviderSchema = z
"hashicorp",
"infisical",
"aws",
"aws-parameter-store",
"doppler",
"azure",
"scaleway",
@@ -79,6 +81,7 @@ const VaultProviderSchema = z
region: z.string(),
accessKeyId: z.string(),
secretAccessKey: z.string(),
parameterPath: z.string(),
serviceToken: z.string(),
awsEndpoint: z.string(),
vaultUri: z.string(),
@@ -136,7 +139,8 @@ const VaultProviderSchema = z
});
}
if (
data.providerType === "aws" &&
(data.providerType === "aws" ||
data.providerType === "aws-parameter-store") &&
data.awsEndpoint &&
!isValidUrl(data.awsEndpoint)
) {
@@ -146,6 +150,17 @@ const VaultProviderSchema = z
path: ["awsEndpoint"],
});
}
if (
data.providerType === "aws-parameter-store" &&
data.parameterPath &&
!data.parameterPath.trim().startsWith("/")
) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "Parameter discovery path must start with /",
path: ["parameterPath"],
});
}
if (
data.providerType === "scaleway" &&
data.scalewayApiUrl &&
@@ -200,6 +215,11 @@ const VaultProviderSchema = z
["accessKeyId", "Access Key ID is required"],
["secretAccessKey", "Secret Access Key is required"],
],
"aws-parameter-store": [
["region", "Region is required"],
["accessKeyId", "Access Key ID is required"],
["secretAccessKey", "Secret Access Key is required"],
],
doppler: [["serviceToken", "Service Token is required"]],
azure: [
["vaultUri", "Vault URI is required"],
@@ -265,6 +285,7 @@ const defaultValues: VaultProviderForm = {
region: "",
accessKeyId: "",
secretAccessKey: "",
parameterPath: "",
serviceToken: "",
awsEndpoint: "",
vaultUri: "",
@@ -313,6 +334,15 @@ const buildConfig = (data: VaultProviderForm) => {
secretAccessKey: data.secretAccessKey,
endpoint: data.awsEndpoint || undefined,
};
case "aws-parameter-store":
return {
providerType: "aws-parameter-store" as const,
region: data.region,
accessKeyId: data.accessKeyId,
secretAccessKey: data.secretAccessKey,
endpoint: data.awsEndpoint || undefined,
parameterPath: data.parameterPath.trim() || undefined,
};
case "doppler":
return {
providerType: "doppler" as const,
@@ -443,12 +473,16 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
environmentSlug: provider.config.environmentSlug,
secretPath: provider.config.secretPath,
}),
...(provider.config.providerType === "aws" && {
...((provider.config.providerType === "aws" ||
provider.config.providerType === "aws-parameter-store") && {
region: provider.config.region,
accessKeyId: provider.config.accessKeyId,
secretAccessKey: provider.config.secretAccessKey,
awsEndpoint: provider.config.endpoint ?? "",
}),
...(provider.config.providerType === "aws-parameter-store" && {
parameterPath: provider.config.parameterPath ?? "",
}),
...(provider.config.providerType === "doppler" && {
serviceToken: provider.config.serviceToken,
dopplerProject: provider.config.project ?? "",
@@ -757,7 +791,8 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
</>
)}
{providerType === "aws" && (
{(providerType === "aws" ||
providerType === "aws-parameter-store") && (
<>
<FormField
control={form.control}
@@ -815,12 +850,44 @@ export const HandleVaultProvider = ({ vaultProviderId }: Props) => {
</FormItem>
)}
/>
<FormDescription>
Reference format:{" "}
<code>{"${{vault.<name>.secret-name}}"}</code> or{" "}
<code>{"${{vault.<name>.secret-name:field}}"}</code> for JSON
secrets
</FormDescription>
{providerType === "aws-parameter-store" && (
<FormField
control={form.control}
name="parameterPath"
render={({ field }) => (
<FormItem>
<FormLabel>Discovery path (optional)</FormLabel>
<FormControl>
<Input placeholder="/production/my-app" {...field} />
</FormControl>
<FormDescription>
Limits parameter browsing and autocomplete to this
hierarchy. IAM permissions remain the security
boundary.
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
)}
{providerType === "aws" ? (
<FormDescription>
Reference format:{" "}
<code>{"${{vault.<name>.secret-name}}"}</code> or{" "}
<code>{"${{vault.<name>.secret-name:field}}"}</code> for
JSON secrets
</FormDescription>
) : (
<FormDescription>
Reference format:{" "}
<code>{"${{vault.<name>./path/to/parameter}}"}</code>.
SecureString parameters are decrypted automatically.
Resolution requires <code>ssm:GetParameters</code>.
Discovery requires <code>ssm:DescribeParameters</code>.
Customer-managed KMS keys also require{" "}
<code>kms:Decrypt</code>.
</FormDescription>
)}
</>
)}
@@ -18,6 +18,7 @@ const providerLabels: Record<string, string> = {
hashicorp: "HashiCorp Vault",
infisical: "Infisical",
aws: "AWS Secrets Manager",
"aws-parameter-store": "AWS Parameter Store",
doppler: "Doppler",
azure: "Azure Key Vault",
scaleway: "Scaleway Secret Manager",
@@ -608,6 +608,7 @@ export const vaultProviderIcons = {
hashicorp: HashicorpVaultIcon,
infisical: InfisicalIcon,
aws: AwsIcon,
"aws-parameter-store": AwsIcon,
doppler: DopplerIcon,
azure: AzureIcon,
scaleway: ScalewayIcon,
@@ -0,0 +1 @@
ALTER TYPE "public"."VaultProviderType" ADD VALUE 'aws-parameter-store' BEFORE 'doppler';
File diff suppressed because it is too large Load Diff
+7
View File
@@ -1352,6 +1352,13 @@
"when": 1788600069255,
"tag": "0192_light_lake",
"breakpoints": true
},
{
"idx": 193,
"version": "7",
"when": 1788802431830,
"tag": "0193_chemical_the_liberteens",
"breakpoints": true
}
]
}
+1
View File
@@ -50,6 +50,7 @@
"@ai-sdk/openai-compatible": "^2.0.30",
"@aws-sdk/client-route-53": "^3.1108.0",
"@aws-sdk/client-secrets-manager": "^3.1108.0",
"@aws-sdk/client-ssm": "3.1108.0",
"@better-auth/api-key": "1.6.23",
"@better-auth/passkey": "1.6.23",
"@better-auth/scim": "1.6.23",
+1
View File
@@ -39,6 +39,7 @@
"@ai-sdk/openai-compatible": "^2.0.30",
"@aws-sdk/client-route-53": "^3.1108.0",
"@aws-sdk/client-secrets-manager": "^3.1108.0",
"@aws-sdk/client-ssm": "3.1108.0",
"@better-auth/api-key": "1.6.23",
"@better-auth/passkey": "1.6.23",
"@better-auth/scim": "1.6.23",
@@ -8,6 +8,7 @@ export const vaultProviderType = pgEnum("VaultProviderType", [
"hashicorp",
"infisical",
"aws",
"aws-parameter-store",
"doppler",
"azure",
"scaleway",
@@ -40,6 +41,21 @@ export const awsVaultConfigSchema = z.object({
endpoint: z.string().url().optional(),
});
export const awsParameterStoreVaultConfigSchema = z.object({
providerType: z.literal("aws-parameter-store"),
region: z.string().min(1),
accessKeyId: z.string().min(1),
secretAccessKey: z.string().min(1),
endpoint: z.string().url().optional(),
parameterPath: z
.string()
.trim()
.refine((path) => path === "" || path.startsWith("/"), {
message: "Parameter discovery path must start with /",
})
.optional(),
});
export const dopplerVaultConfigSchema = z.object({
providerType: z.literal("doppler"),
serviceToken: z.string().min(1),
@@ -76,6 +92,7 @@ export const vaultProviderConfigSchema = z.discriminatedUnion("providerType", [
hashicorpVaultConfigSchema,
infisicalVaultConfigSchema,
awsVaultConfigSchema,
awsParameterStoreVaultConfigSchema,
dopplerVaultConfigSchema,
azureVaultConfigSchema,
scalewayVaultConfigSchema,
@@ -20,6 +20,7 @@ const SENSITIVE_FIELDS: Record<VaultProviderConfig["providerType"], string[]> =
hashicorp: ["token"],
infisical: ["clientSecret"],
aws: ["secretAccessKey"],
"aws-parameter-store": ["secretAccessKey"],
doppler: ["serviceToken"],
azure: ["clientSecret"],
scaleway: ["secretKey"],
@@ -0,0 +1,155 @@
import {
DescribeParametersCommand,
GetParametersCommand,
paginateDescribeParameters,
SSMClient,
} from "@aws-sdk/client-ssm";
import type { awsParameterStoreVaultConfigSchema } from "@dokploy/server/db/schema";
import type { z } from "zod";
import type { VaultClient } from "./types";
type AwsParameterStoreConfig = z.infer<
typeof awsParameterStoreVaultConfigSchema
>;
const MAX_PARAMETERS_PER_REQUEST = 10;
const normalizeParameterPath = (path: string | undefined) => {
const trimmed = path?.trim();
if (!trimmed) {
return undefined;
}
if (trimmed === "/") {
return trimmed;
}
return trimmed.replace(/\/+$/, "");
};
const describeParametersInput = (config: AwsParameterStoreConfig) => {
const parameterPath = normalizeParameterPath(config.parameterPath);
return parameterPath
? {
ParameterFilters: [
{
Key: "Path",
Option: "Recursive",
Values: [parameterPath],
},
],
}
: {};
};
const isAccessDeniedError = (error: unknown) =>
error instanceof Error &&
(error.name === "AccessDeniedException" || error.name === "AccessDenied");
const createClient = (config: AwsParameterStoreConfig) =>
new SSMClient({
region: config.region,
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
...(config.endpoint && { endpoint: config.endpoint }),
});
const findRequestedRef = (
refs: string[],
parameter: { Name?: string; ARN?: string; Selector?: string },
) => {
const bases = [parameter.Name, parameter.ARN].filter(
(value): value is string => Boolean(value),
);
if (!parameter.Selector) {
return refs.find((ref) => bases.includes(ref));
}
const selector = parameter.Name
? parameter.Selector.replace(`${parameter.Name}:`, "").replace(/^:/, "")
: parameter.Selector.replace(/^:/, "");
return refs.find((ref) =>
bases.some((base) => ref === `${base}:${selector}`),
);
};
export const awsParameterStoreClient: VaultClient<AwsParameterStoreConfig> = {
async getSecrets(config, refs) {
const client = createClient(config);
const uniqueRefs = [...new Set(refs)];
const result: Record<string, string> = {};
for (
let index = 0;
index < uniqueRefs.length;
index += MAX_PARAMETERS_PER_REQUEST
) {
const batch = uniqueRefs.slice(index, index + MAX_PARAMETERS_PER_REQUEST);
const response = await client.send(
new GetParametersCommand({
Names: batch,
WithDecryption: true,
}),
);
for (const parameter of response.Parameters ?? []) {
const ref = findRequestedRef(batch, parameter);
if (!ref) {
continue;
}
if (parameter.Value === undefined) {
throw new Error(
`AWS Parameter Store: parameter "${ref}" has no value`,
);
}
result[ref] = parameter.Value;
}
}
for (const ref of uniqueRefs) {
if (result[ref] === undefined) {
throw new Error(`AWS Parameter Store: parameter "${ref}" not found`);
}
}
return result;
},
async testConnection(config) {
const client = createClient(config);
try {
await client.send(
new DescribeParametersCommand({
...describeParametersInput(config),
MaxResults: 1,
}),
);
} catch (error) {
if (isAccessDeniedError(error)) {
throw new Error(
"AWS Parameter Store: credentials were accepted, but connection testing and parameter discovery require ssm:DescribeParameters. Manual references can still work when ssm:GetParameters is allowed.",
);
}
throw error;
}
},
async listSecretNames(config) {
const client = createClient(config);
const names: string[] = [];
for await (const page of paginateDescribeParameters(
{ client, pageSize: 50 },
describeParametersInput(config),
)) {
for (const parameter of page.Parameters ?? []) {
if (parameter.Name) {
names.push(parameter.Name);
}
if (names.length >= 500) {
return names;
}
}
}
return names;
},
};
Binary file not shown.
+21
View File
@@ -121,6 +121,9 @@ importers:
'@aws-sdk/client-secrets-manager':
specifier: ^3.1108.0
version: 3.1108.0
'@aws-sdk/client-ssm':
specifier: 3.1108.0
version: 3.1108.0
'@better-auth/api-key':
specifier: 1.6.23
version: 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(better-auth@1.6.23(22e2e61d74361da37f96c4dc24795f27))(better-call@1.3.7(zod@4.3.6))
@@ -597,6 +600,9 @@ importers:
'@aws-sdk/client-secrets-manager':
specifier: ^3.1108.0
version: 3.1108.0
'@aws-sdk/client-ssm':
specifier: 3.1108.0
version: 3.1108.0
'@better-auth/api-key':
specifier: 1.6.23
version: 1.6.23(@better-auth/core@1.6.23(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.0)(better-call@1.3.7(zod@4.3.6))(jose@6.1.3)(kysely@0.29.3)(nanostores@1.1.1))(@better-auth/utils@0.4.2)(better-auth@1.6.23(2ed6fd84380fde286153435e0641a0ef))(better-call@1.3.7(zod@4.3.6))
@@ -894,6 +900,10 @@ packages:
resolution: {integrity: sha512-pZtoHWD+WorgM9xK1ikNcKLbtEMIS6wFtELuCX5AOo9v3ZajV8wOEmXz/7JnBHbsLmyBnyv3NCf7whyV++joiw==}
engines: {node: '>=20.0.0'}
'@aws-sdk/client-ssm@3.1108.0':
resolution: {integrity: sha512-9bKhMapsv17V2lrC/HtjhfqH72EZOWhdJKod8JkITHALLwdCKgsnnOZ1JPWB1bCMx0hWMW5OtQKdyHjGobE4NQ==}
engines: {node: '>=20.0.0'}
'@aws-sdk/core@3.977.7':
resolution: {integrity: sha512-I88Iov89NVmjSmJLKSv7Cn9M2J+a2942OkA8nZCbz+sl4ZeY4zEOcoLOrbt1GRfQ8zEQKnjAJdXixA3J/p1fDQ==}
engines: {node: '>=20.0.0'}
@@ -9309,6 +9319,17 @@ snapshots:
'@smithy/types': 4.17.0
tslib: 2.8.1
'@aws-sdk/client-ssm@3.1108.0':
dependencies:
'@aws-sdk/core': 3.977.7
'@aws-sdk/credential-provider-node': 3.972.79
'@aws-sdk/types': 3.974.3
'@smithy/core': 3.32.0
'@smithy/fetch-http-handler': 5.7.0
'@smithy/node-http-handler': 4.10.0
'@smithy/types': 4.17.0
tslib: 2.8.1
'@aws-sdk/core@3.977.7':
dependencies:
'@aws-sdk/types': 3.974.3