feat: integrate vault provider management and environment variable resolution

- Added support for managing various vault providers (HashiCorp, AWS, Azure, Doppler, Infisical) in the dashboard.
- Implemented environment variable resolution using vault references, allowing seamless integration of secrets into application environments.
- Enhanced UI components to display and manage vault providers effectively.
- Introduced tests for vault reference resolution and environment variable preparation.
- Updated related components to utilize new vault management features.
This commit is contained in:
Mauricio Siu
2026-08-10 11:14:55 -06:00
parent 771d76a57c
commit b8822a1fde
44 changed files with 3577 additions and 20 deletions
+2 -1
View File
@@ -92,4 +92,5 @@ export type AuditResourceType =
| "mount"
| "application"
| "compose"
| "network";
| "network"
| "vaultProvider";
+1
View File
@@ -42,5 +42,6 @@ export * from "./sso";
export * from "./tag";
export * from "./user";
export * from "./utils";
export * from "./vault-provider";
export * from "./volume-backups";
export * from "./web-server-settings";
@@ -0,0 +1,128 @@
import { jsonb, pgEnum, pgTable, text, uniqueIndex } from "drizzle-orm/pg-core";
import { createInsertSchema } from "drizzle-zod";
import { nanoid } from "nanoid";
import { z } from "zod";
import { organization } from "./account";
export const vaultProviderType = pgEnum("VaultProviderType", [
"hashicorp",
"infisical",
"aws",
"doppler",
"azure",
]);
export const hashicorpVaultConfigSchema = z.object({
providerType: z.literal("hashicorp"),
url: z.string().url(),
token: z.string().min(1),
namespace: z.string().optional(),
mount: z.string().min(1).default("secret"),
});
export const infisicalVaultConfigSchema = z.object({
providerType: z.literal("infisical"),
siteUrl: z.string().url().default("https://app.infisical.com"),
clientId: z.string().min(1),
clientSecret: z.string().min(1),
projectId: z.string().min(1),
environmentSlug: z.string().min(1),
secretPath: z.string().default("/"),
});
export const awsVaultConfigSchema = z.object({
providerType: z.literal("aws"),
region: z.string().min(1),
accessKeyId: z.string().min(1),
secretAccessKey: z.string().min(1),
endpoint: z.string().url().optional(),
});
export const dopplerVaultConfigSchema = z.object({
providerType: z.literal("doppler"),
serviceToken: z.string().min(1),
project: z.string().optional(),
config: z.string().optional(),
});
export const azureVaultConfigSchema = z.object({
providerType: z.literal("azure"),
vaultUri: z.string().url(),
tenantId: z.string().min(1),
clientId: z.string().min(1),
clientSecret: z.string().min(1),
});
export const vaultProviderConfigSchema = z.discriminatedUnion("providerType", [
hashicorpVaultConfigSchema,
infisicalVaultConfigSchema,
awsVaultConfigSchema,
dopplerVaultConfigSchema,
azureVaultConfigSchema,
]);
export type VaultProviderConfig = z.infer<typeof vaultProviderConfigSchema>;
export const vaultProvider = pgTable(
"vault_provider",
{
vaultProviderId: text("vaultProviderId")
.notNull()
.primaryKey()
.$defaultFn(() => nanoid()),
name: text("name").notNull(),
providerType: vaultProviderType("providerType").notNull(),
config: jsonb("config").$type<VaultProviderConfig>().notNull(),
organizationId: text("organizationId")
.notNull()
.references(() => organization.id, { onDelete: "cascade" }),
createdAt: text("createdAt")
.notNull()
.$defaultFn(() => new Date().toISOString()),
},
(table) => [
uniqueIndex("vault_provider_org_name_idx").on(
table.organizationId,
table.name,
),
],
);
const vaultProviderNameSchema = z
.string()
.min(1)
.max(64)
.regex(
/^[a-zA-Z0-9_-]+$/,
"Name can only contain letters, numbers, dashes and underscores",
);
const createSchema = createInsertSchema(vaultProvider);
export const apiCreateVaultProvider = createSchema.pick({}).extend({
name: vaultProviderNameSchema,
config: vaultProviderConfigSchema,
});
export const apiUpdateVaultProvider = createSchema.pick({}).extend({
vaultProviderId: z.string().min(1),
name: vaultProviderNameSchema,
config: vaultProviderConfigSchema,
});
export const apiFindOneVaultProvider = z.object({
vaultProviderId: z.string().min(1),
});
export const apiRemoveVaultProvider = z.object({
vaultProviderId: z.string().min(1),
});
export const apiTestVaultProvider = z.object({
vaultProviderId: z.string().min(1).optional(),
config: vaultProviderConfigSchema.optional(),
});
export const apiListVaultSecretNames = z.object({
vaultProviderId: z.string().min(1),
});
+2
View File
@@ -51,6 +51,7 @@ export * from "./services/server";
export * from "./services/settings";
export * from "./services/ssh-key";
export * from "./services/user";
export * from "./services/vault-provider";
export * from "./services/volume-backups";
export * from "./services/web-server-settings";
export * from "./setup/config-paths";
@@ -102,6 +103,7 @@ export * from "./utils/docker/compose/volume";
export * from "./utils/docker/domain";
export * from "./utils/docker/types";
export * from "./utils/docker/utils";
export * from "./utils/vault";
export * from "./utils/filesystem/directory";
export * from "./utils/filesystem/ssh";
export * from "./utils/git-branch-validation";
@@ -48,6 +48,7 @@ export const statements = {
logs: ["read"],
monitoring: ["read"],
auditLog: ["read"],
vaultProvider: ["read", "create", "update", "delete"],
} as const;
/**
@@ -74,6 +75,7 @@ export const enterpriseOnlyResources = new Set<string>([
"logs",
"monitoring",
"auditLog",
"vaultProvider",
]);
export const ac = createAccessControl(statements);
@@ -113,6 +115,7 @@ export const ownerRole = ac.newRole({
logs: ["read"],
monitoring: ["read"],
auditLog: ["read"],
vaultProvider: ["read", "create", "update", "delete"],
});
/**
@@ -150,6 +153,7 @@ export const adminRole = ac.newRole({
logs: ["read"],
monitoring: ["read"],
auditLog: ["read"],
vaultProvider: ["read", "create", "update", "delete"],
});
/**
@@ -192,4 +196,6 @@ export const memberRole = ac.newRole({
notification: [],
tag: ["read"],
auditLog: [],
// Members need provider/secret names for env editor autocomplete; values are never exposed
vaultProvider: ["read"],
});
+9 -6
View File
@@ -17,6 +17,7 @@ import {
} from "../utils/docker/utils";
import { execAsync, execAsyncRemote } from "../utils/process/execAsync";
import { getRemoteDocker } from "../utils/servers/remote-docker";
import { withResolvedVaultRefs } from "../utils/vault";
import { type Application, findApplicationById } from "./application";
import { findDeploymentById } from "./deployment";
import type { Environment } from "./environment";
@@ -215,7 +216,9 @@ const rollbackApplication = async (
throw new Error("Full context is required for rollback");
}
const rollbackRegistry = fullContext.rollbackRegistry ?? undefined;
const resolvedContext = await withResolvedVaultRefs(fullContext);
const rollbackRegistry = resolvedContext.rollbackRegistry ?? undefined;
// Ensure Docker daemon is authenticated with the rollback registry
// before updating the swarm service. The authconfig in CreateServiceOptions
@@ -236,7 +239,7 @@ const rollbackApplication = async (
cpuReservation,
command,
ports,
} = fullContext;
} = resolvedContext;
const resources = calculateResources({
memoryLimit,
@@ -248,7 +251,7 @@ const rollbackApplication = async (
const volumesMount = generateVolumeMounts(mounts);
const resolvedNetworks = await resolveServiceNetworks(
fullContext as Parameters<typeof resolveServiceNetworks>[0],
resolvedContext as Parameters<typeof resolveServiceNetworks>[0],
);
const {
@@ -261,14 +264,14 @@ const rollbackApplication = async (
UpdateConfig,
Ulimits,
} = generateConfigContainer(
fullContext as Parameters<typeof generateConfigContainer>[0],
resolvedContext as Parameters<typeof generateConfigContainer>[0],
);
const bindsMount = generateBindMounts(mounts);
const envVariables = prepareEnvironmentVariables(
env,
fullContext.environment.project.env,
fullContext.environment.env,
resolvedContext.environment.project.env,
resolvedContext.environment.env,
);
let rollbackImage = image;
@@ -0,0 +1,200 @@
import { db } from "@dokploy/server/db";
import {
type apiCreateVaultProvider,
type VaultProviderConfig,
vaultProvider,
} from "@dokploy/server/db/schema";
import { getVaultClient } from "@dokploy/server/utils/vault";
import { TRPCError } from "@trpc/server";
import { eq } from "drizzle-orm";
import type { z } from "zod";
export type VaultProvider = typeof vaultProvider.$inferSelect;
export const VAULT_SECRET_MASK = "********";
const SENSITIVE_FIELDS: Record<VaultProviderConfig["providerType"], string[]> =
{
hashicorp: ["token"],
infisical: ["clientSecret"],
aws: ["secretAccessKey"],
doppler: ["serviceToken"],
azure: ["clientSecret"],
};
export const maskVaultProviderConfig = (
config: VaultProviderConfig,
): VaultProviderConfig => {
const masked: Record<string, unknown> = { ...config };
for (const field of SENSITIVE_FIELDS[config.providerType]) {
if (masked[field]) {
masked[field] = VAULT_SECRET_MASK;
}
}
return masked as VaultProviderConfig;
};
export const mergeVaultProviderConfig = (
incoming: VaultProviderConfig,
existing: VaultProviderConfig,
): VaultProviderConfig => {
const merged: Record<string, unknown> = { ...incoming };
for (const field of SENSITIVE_FIELDS[incoming.providerType]) {
if (merged[field] === VAULT_SECRET_MASK) {
if (incoming.providerType !== existing.providerType) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
"Credentials must be re-entered when changing the provider type",
});
}
merged[field] = (existing as Record<string, unknown>)[field];
}
}
return merged as VaultProviderConfig;
};
const isUniqueNameViolation = (error: unknown) =>
error instanceof Error &&
error.message.includes("vault_provider_org_name_idx");
export const createVaultProvider = async (
input: z.infer<typeof apiCreateVaultProvider>,
organizationId: string,
) => {
try {
const newProvider = await db
.insert(vaultProvider)
.values({
name: input.name,
providerType: input.config.providerType,
config: input.config,
organizationId,
})
.returning()
.then((value) => value[0]);
if (!newProvider) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error creating the vault provider",
});
}
return newProvider;
} catch (error) {
if (isUniqueNameViolation(error)) {
throw new TRPCError({
code: "CONFLICT",
message: `A vault provider named "${input.name}" already exists in this organization`,
});
}
throw error;
}
};
export const findVaultProviderById = async (vaultProviderId: string) => {
const provider = await db.query.vaultProvider.findFirst({
where: eq(vaultProvider.vaultProviderId, vaultProviderId),
});
if (!provider) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Vault provider not found",
});
}
return provider;
};
export const findVaultProvidersByOrganizationId = async (
organizationId: string,
) => {
return await db.query.vaultProvider.findMany({
where: eq(vaultProvider.organizationId, organizationId),
orderBy: (providers, { asc }) => [asc(providers.name)],
});
};
export const updateVaultProvider = async (
vaultProviderId: string,
name: string,
config: VaultProviderConfig,
) => {
const existing = await findVaultProviderById(vaultProviderId);
const mergedConfig = mergeVaultProviderConfig(config, existing.config);
try {
const updated = await db
.update(vaultProvider)
.set({
name,
providerType: mergedConfig.providerType,
config: mergedConfig,
})
.where(eq(vaultProvider.vaultProviderId, vaultProviderId))
.returning()
.then((res) => res[0]);
if (!updated) {
throw new TRPCError({
code: "BAD_REQUEST",
message: "Error updating the vault provider",
});
}
return updated;
} catch (error) {
if (isUniqueNameViolation(error)) {
throw new TRPCError({
code: "CONFLICT",
message: `A vault provider named "${name}" already exists in this organization`,
});
}
throw error;
}
};
export const removeVaultProvider = async (vaultProviderId: string) => {
const removed = await db
.delete(vaultProvider)
.where(eq(vaultProvider.vaultProviderId, vaultProviderId))
.returning()
.then((res) => res[0]);
if (!removed) {
throw new TRPCError({
code: "NOT_FOUND",
message: "Vault provider not found",
});
}
return removed;
};
export const testVaultProviderConnection = async (
config: VaultProviderConfig,
) => {
const client = getVaultClient(config.providerType);
try {
await client.testConnection(config);
} catch (error) {
throw new TRPCError({
code: "BAD_REQUEST",
message:
error instanceof Error
? error.message
: "Error connecting to the vault provider",
});
}
};
export const listVaultProviderSecretNames = async (
config: VaultProviderConfig,
) => {
const client = getVaultClient(config.providerType);
if (!client.listSecretNames) {
return [];
}
try {
return await client.listSecretNames(config);
} catch {
return [];
}
};
@@ -9,13 +9,15 @@ import {
getEnvironmentVariablesObject,
prepareEnvironmentVariablesForFile,
} from "../docker/utils";
import { withResolvedVaultRefs } from "../vault";
export type ComposeNested = InferResultType<
"compose",
{ environment: { with: { project: true } }; mounts: true; domains: true }
>;
export const getBuildComposeCommand = async (compose: ComposeNested) => {
export const getBuildComposeCommand = async (rawCompose: ComposeNested) => {
const compose = await withResolvedVaultRefs(rawCompose);
const { COMPOSE_PATH } = paths(!!compose.serverId);
const { sourceType, appName, mounts, composeType, domains } = compose;
const command = createCommand(compose);
+5 -2
View File
@@ -12,6 +12,7 @@ import {
prepareEnvironmentVariables,
} from "../docker/utils";
import { getRemoteDocker } from "../servers/remote-docker";
import { withResolvedVaultRefs } from "../vault";
import { getDockerCommand } from "./docker-file";
import { getHerokuCommand } from "./heroku";
import { getNixpacksCommand } from "./nixpacks";
@@ -38,7 +39,8 @@ export type ApplicationNested = InferResultType<
}
>;
export const getBuildCommand = async (application: ApplicationNested) => {
export const getBuildCommand = async (rawApplication: ApplicationNested) => {
const application = await withResolvedVaultRefs(rawApplication);
let command = "";
if (application.sourceType !== "docker") {
@@ -77,8 +79,9 @@ export const getBuildCommand = async (application: ApplicationNested) => {
};
export const mechanizeDockerContainer = async (
application: ApplicationNested,
rawApplication: ApplicationNested,
) => {
const application = await withResolvedVaultRefs(rawApplication);
const {
appName,
env,
@@ -10,6 +10,7 @@ import {
prepareEnvironmentVariables,
} from "../docker/utils";
import { getRemoteDocker } from "../servers/remote-docker";
import { withResolvedVaultRefs } from "../vault";
export type LibsqlNested = InferResultType<
"libsql",
@@ -18,7 +19,8 @@ export type LibsqlNested = InferResultType<
environment: { with: { project: true } };
}
>;
export const buildLibsql = async (libsql: LibsqlNested) => {
export const buildLibsql = async (rawLibsql: LibsqlNested) => {
const libsql = await withResolvedVaultRefs(rawLibsql);
const {
appName,
env,
@@ -10,12 +10,14 @@ import {
prepareEnvironmentVariables,
} from "../docker/utils";
import { getRemoteDocker } from "../servers/remote-docker";
import { withResolvedVaultRefs } from "../vault";
export type MariadbNested = InferResultType<
"mariadb",
{ mounts: true; environment: { with: { project: true } } }
>;
export const buildMariadb = async (mariadb: MariadbNested) => {
export const buildMariadb = async (rawMariadb: MariadbNested) => {
const mariadb = await withResolvedVaultRefs(rawMariadb);
const {
appName,
env,
+3 -1
View File
@@ -10,13 +10,15 @@ import {
prepareEnvironmentVariables,
} from "../docker/utils";
import { getRemoteDocker } from "../servers/remote-docker";
import { withResolvedVaultRefs } from "../vault";
export type MongoNested = InferResultType<
"mongo",
{ mounts: true; environment: { with: { project: true } } }
>;
export const buildMongo = async (mongo: MongoNested) => {
export const buildMongo = async (rawMongo: MongoNested) => {
const mongo = await withResolvedVaultRefs(rawMongo);
const {
appName,
env,
+3 -1
View File
@@ -10,13 +10,15 @@ import {
prepareEnvironmentVariables,
} from "../docker/utils";
import { getRemoteDocker } from "../servers/remote-docker";
import { withResolvedVaultRefs } from "../vault";
export type MysqlNested = InferResultType<
"mysql",
{ mounts: true; environment: { with: { project: true } } }
>;
export const buildMysql = async (mysql: MysqlNested) => {
export const buildMysql = async (rawMysql: MysqlNested) => {
const mysql = await withResolvedVaultRefs(rawMysql);
const {
appName,
env,
@@ -10,12 +10,14 @@ import {
prepareEnvironmentVariables,
} from "../docker/utils";
import { getRemoteDocker } from "../servers/remote-docker";
import { withResolvedVaultRefs } from "../vault";
export type PostgresNested = InferResultType<
"postgres",
{ mounts: true; environment: { with: { project: true } } }
>;
export const buildPostgres = async (postgres: PostgresNested) => {
export const buildPostgres = async (rawPostgres: PostgresNested) => {
const postgres = await withResolvedVaultRefs(rawPostgres);
const {
appName,
env,
+3 -1
View File
@@ -10,12 +10,14 @@ import {
prepareEnvironmentVariables,
} from "../docker/utils";
import { getRemoteDocker } from "../servers/remote-docker";
import { withResolvedVaultRefs } from "../vault";
export type RedisNested = InferResultType<
"redis",
{ mounts: true; environment: { with: { project: true } } }
>;
export const buildRedis = async (redis: RedisNested) => {
export const buildRedis = async (rawRedis: RedisNested) => {
const redis = await withResolvedVaultRefs(rawRedis);
const {
appName,
env,
@@ -401,6 +401,13 @@ export const prepareEnvironmentVariables = (
projectEnv?: string | null,
environmentEnv?: string | null,
) => {
for (const source of [serviceEnv, projectEnv, environmentEnv]) {
if (source?.includes("${{vault.")) {
throw new Error(
"Unresolved vault reference: call withResolvedVaultRefs() on the entity before preparing environment variables",
);
}
}
const projectVars = parse(projectEnv ?? "");
const environmentVars = parse(environmentEnv ?? "");
const serviceVars = parse(serviceEnv ?? "");
+107
View File
@@ -0,0 +1,107 @@
import {
GetSecretValueCommand,
ListSecretsCommand,
SecretsManagerClient,
} from "@aws-sdk/client-secrets-manager";
import type { awsVaultConfigSchema } from "@dokploy/server/db/schema";
import type { z } from "zod";
import type { VaultClient } from "./types";
type AwsConfig = z.infer<typeof awsVaultConfigSchema>;
const parseRef = (ref: string) => {
if (ref.startsWith("arn:")) {
throw new Error(
`Invalid AWS Secrets Manager reference "${ref}": use the secret name, not the ARN`,
);
}
const separatorIndex = ref.lastIndexOf(":");
if (separatorIndex === -1) {
return { secretId: ref, field: null };
}
return {
secretId: ref.slice(0, separatorIndex),
field: ref.slice(separatorIndex + 1),
};
};
const createClient = (config: AwsConfig) =>
new SecretsManagerClient({
region: config.region,
credentials: {
accessKeyId: config.accessKeyId,
secretAccessKey: config.secretAccessKey,
},
...(config.endpoint && { endpoint: config.endpoint }),
});
export const awsClient: VaultClient<AwsConfig> = {
async getSecrets(config, refs) {
const client = createClient(config);
const secretIds = [...new Set(refs.map((ref) => parseRef(ref).secretId))];
const secretStrings = new Map<string, string>();
await Promise.all(
secretIds.map(async (secretId) => {
const response = await client.send(
new GetSecretValueCommand({ SecretId: secretId }),
);
if (response.SecretString === undefined) {
throw new Error(
`AWS Secrets Manager: secret "${secretId}" has no string value (binary secrets are not supported)`,
);
}
secretStrings.set(secretId, response.SecretString);
}),
);
const result: Record<string, string> = {};
for (const ref of refs) {
const { secretId, field } = parseRef(ref);
const secretString = secretStrings.get(secretId) as string;
if (field === null) {
result[ref] = secretString;
continue;
}
let parsed: Record<string, unknown>;
try {
parsed = JSON.parse(secretString);
} catch {
throw new Error(
`AWS Secrets Manager: secret "${secretId}" is not JSON, cannot extract field "${field}"`,
);
}
const value = parsed[field];
if (value === undefined || value === null) {
throw new Error(
`AWS Secrets Manager: field "${field}" not found in secret "${secretId}"`,
);
}
result[ref] = typeof value === "string" ? value : JSON.stringify(value);
}
return result;
},
async testConnection(config) {
const client = createClient(config);
await client.send(new ListSecretsCommand({ MaxResults: 1 }));
},
async listSecretNames(config) {
const client = createClient(config);
const names: string[] = [];
let nextToken: string | undefined;
do {
const response = await client.send(
new ListSecretsCommand({ MaxResults: 100, NextToken: nextToken }),
);
for (const secret of response.SecretList ?? []) {
if (secret.Name) {
names.push(secret.Name);
}
}
nextToken = response.NextToken;
} while (nextToken && names.length < 500);
return names;
},
};
+123
View File
@@ -0,0 +1,123 @@
import type { azureVaultConfigSchema } from "@dokploy/server/db/schema";
import type { z } from "zod";
import { type VaultClient, vaultFetch } from "./types";
type AzureConfig = z.infer<typeof azureVaultConfigSchema>;
const API_VERSION = "7.4";
const baseUrl = (config: AzureConfig) => config.vaultUri.replace(/\/+$/, "");
const getAccessToken = async (config: AzureConfig) => {
const body = new URLSearchParams({
grant_type: "client_credentials",
client_id: config.clientId,
client_secret: config.clientSecret,
scope: "https://vault.azure.net/.default",
});
const response = await vaultFetch(
`https://login.microsoftonline.com/${encodeURIComponent(config.tenantId)}/oauth2/v2.0/token`,
{
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: body.toString(),
},
);
if (!response.ok) {
let detail = "";
try {
const body = (await response.json()) as { error_description?: string };
detail = (body.error_description ?? "").split("\n")[0] ?? "";
} catch {}
throw new Error(
`Azure Key Vault: authentication failed (status ${response.status}${detail ? `: ${detail}` : ""})`,
);
}
const data = (await response.json()) as { access_token?: string };
if (!data.access_token) {
throw new Error("Azure Key Vault: no access token returned");
}
return data.access_token;
};
const readSecret = async (
config: AzureConfig,
token: string,
name: string,
) => {
const response = await vaultFetch(
`${baseUrl(config)}/secrets/${encodeURIComponent(name)}?api-version=${API_VERSION}`,
{ headers: { Authorization: `Bearer ${token}` } },
);
if (response.status === 404) {
throw new Error(`Azure Key Vault: secret "${name}" not found`);
}
if (!response.ok) {
throw new Error(
`Azure Key Vault: failed to read secret "${name}" (status ${response.status})`,
);
}
const data = (await response.json()) as { value?: string };
if (data.value === undefined) {
throw new Error(`Azure Key Vault: secret "${name}" has no value`);
}
return data.value;
};
export const azureClient: VaultClient<AzureConfig> = {
async getSecrets(config, refs) {
const token = await getAccessToken(config);
const result: Record<string, string> = {};
await Promise.all(
refs.map(async (ref) => {
result[ref] = await readSecret(config, token, ref);
}),
);
return result;
},
async testConnection(config) {
const token = await getAccessToken(config);
const response = await vaultFetch(
`${baseUrl(config)}/secrets?api-version=${API_VERSION}&maxresults=1`,
{ headers: { Authorization: `Bearer ${token}` } },
);
if (!response.ok) {
throw new Error(
`Azure Key Vault: cannot list secrets (status ${response.status})`,
);
}
},
async listSecretNames(config) {
const token = await getAccessToken(config);
const names: string[] = [];
let url: string | null =
`${baseUrl(config)}/secrets?api-version=${API_VERSION}&maxresults=25`;
while (url && names.length < 200) {
const response = await vaultFetch(url, {
headers: { Authorization: `Bearer ${token}` },
});
if (!response.ok) {
break;
}
const data = (await response.json()) as {
value?: { id: string }[];
nextLink?: string | null;
};
for (const item of data.value ?? []) {
const name = item.id.split("/").pop();
if (name) {
names.push(name);
}
}
url = data.nextLink ?? null;
}
return names.slice(0, 200);
},
};
@@ -0,0 +1,61 @@
import type { dopplerVaultConfigSchema } from "@dokploy/server/db/schema";
import type { z } from "zod";
import { type VaultClient, vaultFetch } from "./types";
type DopplerConfig = z.infer<typeof dopplerVaultConfigSchema>;
const downloadUrl = (config: DopplerConfig) => {
const params = new URLSearchParams({ format: "json" });
if (config.project) {
params.set("project", config.project);
}
if (config.config) {
params.set("config", config.config);
}
return `https://api.doppler.com/v3/configs/config/secrets/download?${params.toString()}`;
};
const downloadSecrets = async (config: DopplerConfig) => {
const response = await vaultFetch(downloadUrl(config), {
headers: {
Authorization: `Bearer ${config.serviceToken}`,
Accept: "application/json",
},
});
if (!response.ok) {
let detail = "";
try {
const body = (await response.json()) as { messages?: string[] };
detail = (body.messages ?? []).join(", ");
} catch {}
throw new Error(
`Doppler: failed to fetch secrets (status ${response.status}${detail ? `: ${detail}` : ""})`,
);
}
return (await response.json()) as Record<string, string>;
};
export const dopplerClient: VaultClient<DopplerConfig> = {
async getSecrets(config, refs) {
const secrets = await downloadSecrets(config);
const result: Record<string, string> = {};
for (const ref of refs) {
if (secrets[ref] === undefined) {
throw new Error(`Doppler: secret "${ref}" not found in this config`);
}
result[ref] = secrets[ref];
}
return result;
},
async testConnection(config) {
await downloadSecrets(config);
},
async listSecretNames(config) {
const secrets = await downloadSecrets(config);
return Object.keys(secrets);
},
};
@@ -0,0 +1,140 @@
import type { hashicorpVaultConfigSchema } from "@dokploy/server/db/schema";
import type { z } from "zod";
import { type VaultClient, vaultFetch } from "./types";
type HashicorpConfig = z.infer<typeof hashicorpVaultConfigSchema>;
const parseRef = (ref: string) => {
const separatorIndex = ref.lastIndexOf(":");
if (separatorIndex <= 0 || separatorIndex === ref.length - 1) {
throw new Error(
`Invalid HashiCorp Vault reference "${ref}": expected format <path>:<field> (e.g. myapp/prod:DB_PASSWORD)`,
);
}
return {
path: ref.slice(0, separatorIndex),
field: ref.slice(separatorIndex + 1),
};
};
const buildHeaders = (config: HashicorpConfig) => {
const headers: Record<string, string> = {
"X-Vault-Token": config.token,
};
if (config.namespace) {
headers["X-Vault-Namespace"] = config.namespace;
}
return headers;
};
const baseUrl = (config: HashicorpConfig) => config.url.replace(/\/+$/, "");
const encodePath = (path: string) =>
path.split("/").map(encodeURIComponent).join("/");
const readSecret = async (config: HashicorpConfig, path: string) => {
const url = `${baseUrl(config)}/v1/${encodeURIComponent(config.mount)}/data/${encodePath(path)}`;
const response = await vaultFetch(url, { headers: buildHeaders(config) });
if (!response.ok) {
throw new Error(
`HashiCorp Vault: failed to read secret at "${path}" (status ${response.status})`,
);
}
const body = (await response.json()) as {
data?: { data?: Record<string, unknown> };
};
return body.data?.data ?? {};
};
export const hashicorpClient: VaultClient<HashicorpConfig> = {
async getSecrets(config, refs) {
const byPath = new Map<string, string[]>();
for (const ref of refs) {
const { path } = parseRef(ref);
byPath.set(path, [...(byPath.get(path) ?? []), ref]);
}
const result: Record<string, string> = {};
await Promise.all(
[...byPath.entries()].map(async ([path, pathRefs]) => {
const data = await readSecret(config, path);
for (const ref of pathRefs) {
const { field } = parseRef(ref);
const value = data[field];
if (value === undefined || value === null) {
throw new Error(
`HashiCorp Vault: field "${field}" not found in secret "${path}"`,
);
}
result[ref] =
typeof value === "string" ? value : JSON.stringify(value);
}
}),
);
return result;
},
async testConnection(config) {
const response = await vaultFetch(
`${baseUrl(config)}/v1/auth/token/lookup-self`,
{ headers: buildHeaders(config) },
);
if (!response.ok) {
throw new Error(
`HashiCorp Vault: token validation failed (status ${response.status})`,
);
}
},
async listSecretNames(config) {
const MAX_DEPTH = 4;
const MAX_ENTRIES = 200;
const names: string[] = [];
const listKeys = async (path: string) => {
const cleanPath = path.replace(/\/+$/, "");
const suffix = cleanPath ? `/${encodePath(cleanPath)}` : "";
const response = await vaultFetch(
`${baseUrl(config)}/v1/${encodeURIComponent(config.mount)}/metadata${suffix}?list=true`,
{ headers: buildHeaders(config) },
);
if (!response.ok) {
return [];
}
const body = (await response.json()) as { data?: { keys?: string[] } };
return body.data?.keys ?? [];
};
const walk = async (path: string, depth: number) => {
if (depth > MAX_DEPTH || names.length >= MAX_ENTRIES) {
return;
}
for (const key of await listKeys(path)) {
if (names.length >= MAX_ENTRIES) {
return;
}
const fullPath = `${path}${key}`;
if (key.endsWith("/")) {
await walk(fullPath, depth + 1);
continue;
}
const fields = await readSecret(config, fullPath).catch(
() => ({}) as Record<string, unknown>,
);
const fieldNames = Object.keys(fields);
if (fieldNames.length === 0) {
names.push(fullPath);
continue;
}
for (const field of fieldNames) {
names.push(`${fullPath}:${field}`);
}
}
};
await walk("", 0);
return names.slice(0, MAX_ENTRIES);
},
};
Binary file not shown.
@@ -0,0 +1,87 @@
import type { infisicalVaultConfigSchema } from "@dokploy/server/db/schema";
import type { z } from "zod";
import { type VaultClient, vaultFetch } from "./types";
type InfisicalConfig = z.infer<typeof infisicalVaultConfigSchema>;
const baseUrl = (config: InfisicalConfig) => config.siteUrl.replace(/\/+$/, "");
const login = async (config: InfisicalConfig) => {
const response = await vaultFetch(
`${baseUrl(config)}/api/v1/auth/universal-auth/login`,
{
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
clientId: config.clientId,
clientSecret: config.clientSecret,
}),
},
);
if (!response.ok) {
throw new Error(
`Infisical: authentication failed (status ${response.status})`,
);
}
const body = (await response.json()) as { accessToken?: string };
if (!body.accessToken) {
throw new Error("Infisical: no access token returned");
}
return body.accessToken;
};
const fetchSecrets = async (config: InfisicalConfig) => {
const accessToken = await login(config);
const params = new URLSearchParams({
workspaceId: config.projectId,
environment: config.environmentSlug,
secretPath: config.secretPath,
});
const response = await vaultFetch(
`${baseUrl(config)}/api/v3/secrets/raw?${params.toString()}`,
{ headers: { Authorization: `Bearer ${accessToken}` } },
);
if (!response.ok) {
throw new Error(
`Infisical: failed to fetch secrets (status ${response.status})`,
);
}
const body = (await response.json()) as {
secrets?: { secretKey: string; secretValue: string }[];
};
const secrets: Record<string, string> = {};
for (const secret of body.secrets ?? []) {
secrets[secret.secretKey] = secret.secretValue;
}
return secrets;
};
export const infisicalClient: VaultClient<InfisicalConfig> = {
async getSecrets(config, refs) {
const secrets = await fetchSecrets(config);
const result: Record<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];
}
return result;
},
async testConnection(config) {
await fetchSecrets(config);
},
async listSecretNames(config) {
const secrets = await fetchSecrets(config);
return Object.keys(secrets);
},
};
+18
View File
@@ -0,0 +1,18 @@
import type { VaultProviderConfig } from "@dokploy/server/db/schema";
export interface VaultClient<
C extends VaultProviderConfig = VaultProviderConfig,
> {
getSecrets(config: C, refs: string[]): Promise<Record<string, string>>;
testConnection(config: C): Promise<void>;
listSecretNames?(config: C): Promise<string[]>;
}
export const VAULT_REQUEST_TIMEOUT_MS = 15_000;
export const vaultFetch = async (url: string, init: RequestInit = {}) => {
return await fetch(url, {
...init,
signal: AbortSignal.timeout(VAULT_REQUEST_TIMEOUT_MS),
});
};