Merge pull request #4440 from Infisical/daniel/vault-migration

feat(vault-migration): gateway support & kv v1 support
This commit is contained in:
Daniel Hougaard
2025-08-30 01:02:46 +02:00
committed by GitHub
8 changed files with 294 additions and 98 deletions
+2 -1
View File
@@ -1746,7 +1746,8 @@ export const registerRoutes = async (
const migrationService = externalMigrationServiceFactory({ const migrationService = externalMigrationServiceFactory({
externalMigrationQueue, externalMigrationQueue,
userDAL, userDAL,
permissionService permissionService,
gatewayService
}); });
const externalGroupOrgRoleMappingService = externalGroupOrgRoleMappingServiceFactory({ const externalGroupOrgRoleMappingService = externalGroupOrgRoleMappingServiceFactory({
@@ -66,7 +66,8 @@ export const registerExternalMigrationRouter = async (server: FastifyZodProvider
vaultAccessToken: z.string(), vaultAccessToken: z.string(),
vaultNamespace: z.string().trim().optional(), vaultNamespace: z.string().trim().optional(),
vaultUrl: z.string(), vaultUrl: z.string(),
mappingType: z.nativeEnum(VaultMappingType) mappingType: z.nativeEnum(VaultMappingType),
gatewayId: z.string().optional()
}) })
}, },
onRequest: verifyAuth([AuthMode.JWT]), onRequest: verifyAuth([AuthMode.JWT]),
@@ -1,12 +1,21 @@
import https from "node:https";
import axios, { AxiosInstance } from "axios"; import axios, { AxiosInstance } from "axios";
import { v4 as uuidv4 } from "uuid"; import { v4 as uuidv4 } from "uuid";
import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
import { BadRequestError } from "@app/lib/errors"; import { BadRequestError } from "@app/lib/errors";
import { GatewayProxyProtocol, withGatewayProxy } from "@app/lib/gateway";
import { logger } from "@app/lib/logger"; import { logger } from "@app/lib/logger";
import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator"; import { blockLocalAndPrivateIpAddresses } from "@app/lib/validator";
import { InfisicalImportData, VaultMappingType } from "../external-migration-types"; import { InfisicalImportData, VaultMappingType } from "../external-migration-types";
enum KvVersion {
V1 = "1",
V2 = "2"
}
type VaultData = { type VaultData = {
namespace: string; namespace: string;
mount: string; mount: string;
@@ -14,7 +23,42 @@ type VaultData = {
secretData: Record<string, string>; secretData: Record<string, string>;
}; };
const vaultFactory = () => { const vaultFactory = (gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">) => {
const $gatewayProxyWrapper = async <T>(
inputs: {
gatewayId: string;
targetHost?: string;
targetPort?: number;
},
gatewayCallback: (host: string, port: number, httpsAgent?: https.Agent) => Promise<T>
): Promise<T> => {
const relayDetails = await gatewayService.fnGetGatewayClientTlsByGatewayId(inputs.gatewayId);
const [relayHost, relayPort] = relayDetails.relayAddress.split(":");
const callbackResult = await withGatewayProxy(
async (port, httpsAgent) => {
const res = await gatewayCallback("http://localhost", port, httpsAgent);
return res;
},
{
protocol: GatewayProxyProtocol.Http,
targetHost: inputs.targetHost,
targetPort: inputs.targetPort,
relayHost,
relayPort: Number(relayPort),
identityId: relayDetails.identityId,
orgId: relayDetails.orgId,
tlsOptions: {
ca: relayDetails.certChain,
cert: relayDetails.certificate,
key: relayDetails.privateKey.toString()
}
}
);
return callbackResult;
};
const getMounts = async (request: AxiosInstance) => { const getMounts = async (request: AxiosInstance) => {
const response = await request const response = await request
.get<{ .get<{
@@ -31,9 +75,11 @@ const vaultFactory = () => {
const getPaths = async ( const getPaths = async (
request: AxiosInstance, request: AxiosInstance,
{ mountPath, secretPath = "" }: { mountPath: string; secretPath?: string } { mountPath, secretPath = "" }: { mountPath: string; secretPath?: string },
kvVersion: KvVersion
) => { ) => {
try { try {
if (kvVersion === KvVersion.V2) {
// For KV v2: /v1/{mount}/metadata/{path}?list=true // For KV v2: /v1/{mount}/metadata/{path}?list=true
const path = secretPath ? `${mountPath}/metadata/${secretPath}` : `${mountPath}/metadata`; const path = secretPath ? `${mountPath}/metadata/${secretPath}` : `${mountPath}/metadata`;
const response = await request.get<{ const response = await request.get<{
@@ -42,6 +88,17 @@ const vaultFactory = () => {
}; };
}>(`/v1/${path}?list=true`); }>(`/v1/${path}?list=true`);
return response.data.data.keys;
}
// kv version v1: /v1/{mount}?list=true
const path = secretPath ? `${mountPath}/${secretPath}` : mountPath;
const response = await request.get<{
data: {
keys: string[];
};
}>(`/v1/${path}?list=true`);
return response.data.data.keys; return response.data.data.keys;
} catch (err) { } catch (err) {
if (axios.isAxiosError(err)) { if (axios.isAxiosError(err)) {
@@ -56,8 +113,10 @@ const vaultFactory = () => {
const getSecrets = async ( const getSecrets = async (
request: AxiosInstance, request: AxiosInstance,
{ mountPath, secretPath }: { mountPath: string; secretPath: string } { mountPath, secretPath }: { mountPath: string; secretPath: string },
kvVersion: KvVersion
) => { ) => {
if (kvVersion === KvVersion.V2) {
// For KV v2: /v1/{mount}/data/{path} // For KV v2: /v1/{mount}/data/{path}
const response = await request const response = await request
.get<{ .get<{
@@ -79,6 +138,25 @@ const vaultFactory = () => {
}); });
return response.data.data.data; return response.data.data.data;
}
// kv version v1
const response = await request
.get<{
data: Record<string, string>; // KV v1 has flat data structure
lease_duration: number;
lease_id: string;
renewable: boolean;
}>(`/v1/${mountPath}/${secretPath}`)
.catch((err) => {
if (axios.isAxiosError(err)) {
logger.error(err.response?.data, "External migration: Failed to get Vault secret");
}
throw err;
});
return response.data.data;
}; };
// helper function to check if a mount is KV v2 (will be useful if we add support for Vault KV v1) // helper function to check if a mount is KV v2 (will be useful if we add support for Vault KV v1)
@@ -89,9 +167,10 @@ const vaultFactory = () => {
const recursivelyGetAllPaths = async ( const recursivelyGetAllPaths = async (
request: AxiosInstance, request: AxiosInstance,
mountPath: string, mountPath: string,
kvVersion: KvVersion,
currentPath: string = "" currentPath: string = ""
): Promise<string[]> => { ): Promise<string[]> => {
const paths = await getPaths(request, { mountPath, secretPath: currentPath }); const paths = await getPaths(request, { mountPath, secretPath: currentPath }, kvVersion);
if (paths === null || paths.length === 0) { if (paths === null || paths.length === 0) {
return []; return [];
@@ -105,7 +184,7 @@ const vaultFactory = () => {
if (path.endsWith("/")) { if (path.endsWith("/")) {
// it's a folder so we recurse into it // it's a folder so we recurse into it
const subSecrets = await recursivelyGetAllPaths(request, mountPath, fullItemPath); const subSecrets = await recursivelyGetAllPaths(request, mountPath, kvVersion, fullItemPath);
allSecrets.push(...subSecrets); allSecrets.push(...subSecrets);
} else { } else {
// it's a secret so we add it to our results // it's a secret so we add it to our results
@@ -119,22 +198,26 @@ const vaultFactory = () => {
async function collectVaultData({ async function collectVaultData({
baseUrl, baseUrl,
namespace, namespace,
accessToken accessToken,
gatewayId
}: { }: {
baseUrl: string; baseUrl: string;
namespace?: string; namespace?: string;
accessToken: string; accessToken: string;
gatewayId?: string;
}): Promise<VaultData[]> { }): Promise<VaultData[]> {
const getData = async (host: string, port?: number, httpsAgent?: https.Agent) => {
const allData: VaultData[] = [];
const request = axios.create({ const request = axios.create({
baseURL: baseUrl, baseURL: port ? `${host}:${port}` : host,
headers: { headers: {
"X-Vault-Token": accessToken, "X-Vault-Token": accessToken,
...(namespace ? { "X-Vault-Namespace": namespace } : {}) ...(namespace ? { "X-Vault-Namespace": namespace } : {})
} },
httpsAgent
}); });
const allData: VaultData[] = [];
// Get all mounts in this namespace // Get all mounts in this namespace
const mounts = await getMounts(request); const mounts = await getMounts(request);
@@ -151,17 +234,23 @@ const vaultFactory = () => {
continue; continue;
} }
const kvVersion = mountInfo.options?.version === "2" ? KvVersion.V2 : KvVersion.V1;
// get all paths in this mount // get all paths in this mount
const paths = await recursivelyGetAllPaths(request, `${mountPath.replace(/\/$/, "")}`); const paths = await recursivelyGetAllPaths(request, `${mountPath.replace(/\/$/, "")}`, kvVersion);
const cleanMountPath = mountPath.replace(/\/$/, ""); const cleanMountPath = mountPath.replace(/\/$/, "");
for await (const secretPath of paths) { for await (const secretPath of paths) {
// get the actual secret data // get the actual secret data
const secretData = await getSecrets(request, { const secretData = await getSecrets(
request,
{
mountPath: cleanMountPath, mountPath: cleanMountPath,
secretPath: secretPath.replace(`${cleanMountPath}/`, "") secretPath: secretPath.replace(`${cleanMountPath}/`, "")
}); },
kvVersion
);
allData.push({ allData.push({
namespace: namespace || "", namespace: namespace || "",
@@ -173,6 +262,29 @@ const vaultFactory = () => {
} }
return allData; return allData;
};
let data;
if (gatewayId) {
const url = new URL(baseUrl);
const { port, protocol, hostname } = url;
const cleanedProtocol = protocol.slice(0, -1);
data = await $gatewayProxyWrapper(
{
gatewayId,
targetHost: `${cleanedProtocol}://${hostname}`,
targetPort: port ? Number(port) : 8200 // 8200, default port for Vault self-hosted/dedicated
},
getData
);
} else {
data = await getData(baseUrl);
}
return data;
} }
return { return {
@@ -296,17 +408,22 @@ export const transformToInfisicalFormatNamespaceToProjects = (
}; };
}; };
export const importVaultDataFn = async ({ export const importVaultDataFn = async (
{
vaultAccessToken, vaultAccessToken,
vaultNamespace, vaultNamespace,
vaultUrl, vaultUrl,
mappingType mappingType,
}: { gatewayId
}: {
vaultAccessToken: string; vaultAccessToken: string;
vaultNamespace?: string; vaultNamespace?: string;
vaultUrl: string; vaultUrl: string;
mappingType: VaultMappingType; mappingType: VaultMappingType;
}) => { gatewayId?: string;
},
{ gatewayService }: { gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId"> }
) => {
await blockLocalAndPrivateIpAddresses(vaultUrl); await blockLocalAndPrivateIpAddresses(vaultUrl);
if (mappingType === VaultMappingType.Namespace && !vaultNamespace) { if (mappingType === VaultMappingType.Namespace && !vaultNamespace) {
@@ -315,12 +432,13 @@ export const importVaultDataFn = async ({
}); });
} }
const vaultApi = vaultFactory(); const vaultApi = vaultFactory(gatewayService);
const vaultData = await vaultApi.collectVaultData({ const vaultData = await vaultApi.collectVaultData({
accessToken: vaultAccessToken, accessToken: vaultAccessToken,
baseUrl: vaultUrl, baseUrl: vaultUrl,
namespace: vaultNamespace namespace: vaultNamespace,
gatewayId
}); });
const infisicalData = transformToInfisicalFormatNamespaceToProjects(vaultData, mappingType); const infisicalData = transformToInfisicalFormatNamespaceToProjects(vaultData, mappingType);
@@ -1,4 +1,5 @@
import { OrgMembershipRole } from "@app/db/schemas"; import { OrgMembershipRole } from "@app/db/schemas";
import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { crypto } from "@app/lib/crypto/cryptography"; import { crypto } from "@app/lib/crypto/cryptography";
import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors"; import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors";
@@ -12,6 +13,7 @@ type TExternalMigrationServiceFactoryDep = {
permissionService: TPermissionServiceFactory; permissionService: TPermissionServiceFactory;
externalMigrationQueue: TExternalMigrationQueueFactory; externalMigrationQueue: TExternalMigrationQueueFactory;
userDAL: Pick<TUserDALFactory, "findById">; userDAL: Pick<TUserDALFactory, "findById">;
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">;
}; };
export type TExternalMigrationServiceFactory = ReturnType<typeof externalMigrationServiceFactory>; export type TExternalMigrationServiceFactory = ReturnType<typeof externalMigrationServiceFactory>;
@@ -19,7 +21,8 @@ export type TExternalMigrationServiceFactory = ReturnType<typeof externalMigrati
export const externalMigrationServiceFactory = ({ export const externalMigrationServiceFactory = ({
permissionService, permissionService,
externalMigrationQueue, externalMigrationQueue,
userDAL userDAL,
gatewayService
}: TExternalMigrationServiceFactoryDep) => { }: TExternalMigrationServiceFactoryDep) => {
const importEnvKeyData = async ({ const importEnvKeyData = async ({
decryptionKey, decryptionKey,
@@ -72,6 +75,7 @@ export const externalMigrationServiceFactory = ({
vaultNamespace, vaultNamespace,
mappingType, mappingType,
vaultUrl, vaultUrl,
gatewayId,
actor, actor,
actorId, actorId,
actorOrgId, actorOrgId,
@@ -91,12 +95,18 @@ export const externalMigrationServiceFactory = ({
const user = await userDAL.findById(actorId); const user = await userDAL.findById(actorId);
const vaultData = await importVaultDataFn({ const vaultData = await importVaultDataFn(
{
vaultAccessToken, vaultAccessToken,
vaultNamespace, vaultNamespace,
vaultUrl, vaultUrl,
mappingType mappingType,
}); gatewayId
},
{
gatewayService
}
);
const stringifiedJson = JSON.stringify({ const stringifiedJson = JSON.stringify({
data: vaultData, data: vaultData,
@@ -31,6 +31,7 @@ export type TImportVaultDataDTO = {
vaultNamespace?: string; vaultNamespace?: string;
mappingType: VaultMappingType; mappingType: VaultMappingType;
vaultUrl: string; vaultUrl: string;
gatewayId?: string;
} & Omit<TOrgPermission, "orgId">; } & Omit<TOrgPermission, "orgId">;
export type TImportInfisicalDataCreate = { export type TImportInfisicalDataCreate = {
@@ -8,12 +8,12 @@ description: "Learn how to migrate secrets from Vault to Infisical."
Migrating from Vault Self-Hosted or Dedicated Vault is a straight forward process with our inbuilt migration option. In order to migrate from Vault, you'll need to provide Infisical an access token to your Vault instance. Migrating from Vault Self-Hosted or Dedicated Vault is a straight forward process with our inbuilt migration option. In order to migrate from Vault, you'll need to provide Infisical an access token to your Vault instance.
Currently the Vault migration only supports migrating secrets from the KV v2 secrets engine. If you're using a different secrets engine, please open an issue on our [GitHub repository](https://github.com/infisical/infisical/issues). Currently the Vault migration only supports migrating secrets from the KV V2 and V1 secrets engine. If you're using a different secrets engine, please open an issue on our [GitHub repository](https://github.com/infisical/infisical/issues).
### Prerequisites ### Prerequisites
- A Vault instance with the KV v2 secrets engine enabled. - A Vault instance with the KV secret engine enabled.
- An access token to your Vault instance. - An access token to your Vault instance.
@@ -46,18 +46,21 @@ export const useImportVault = () => {
vaultAccessToken, vaultAccessToken,
vaultNamespace, vaultNamespace,
vaultUrl, vaultUrl,
mappingType mappingType,
gatewayId
}: { }: {
vaultAccessToken: string; vaultAccessToken: string;
vaultNamespace?: string; vaultNamespace?: string;
vaultUrl: string; vaultUrl: string;
mappingType: string; mappingType: string;
gatewayId?: string;
}) => { }) => {
await apiRequest.post("/api/v3/external-migration/vault/", { await apiRequest.post("/api/v3/external-migration/vault/", {
vaultAccessToken, vaultAccessToken,
vaultNamespace, vaultNamespace,
vaultUrl, vaultUrl,
mappingType mappingType,
gatewayId
}); });
} }
}); });
@@ -6,9 +6,16 @@ import { twMerge } from "tailwind-merge";
import { z } from "zod"; import { z } from "zod";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { Button, FormControl, Input, Tooltip } from "@app/components/v2"; import { Button, FormControl, Input, Select, SelectItem, Tooltip } from "@app/components/v2";
import { NoticeBannerV2 } from "@app/components/v2/NoticeBannerV2/NoticeBannerV2"; import { NoticeBannerV2 } from "@app/components/v2/NoticeBannerV2/NoticeBannerV2";
import { useImportVault } from "@app/hooks/api/migration/mutations"; import { useImportVault } from "@app/hooks/api/migration/mutations";
import { OrgPermissionCan } from "@app/components/permissions";
import {
OrgGatewayPermissionActions,
OrgPermissionSubjects
} from "@app/context/OrgPermissionContext/types";
import { useQuery } from "@tanstack/react-query";
import { gatewaysQueryKeys } from "@app/hooks/api";
type Props = { type Props = {
id?: string; id?: string;
@@ -62,36 +69,32 @@ const MAPPING_TYPE_MENU_ITEMS = [
export const VaultPlatformModal = ({ onClose }: Props) => { export const VaultPlatformModal = ({ onClose }: Props) => {
const formSchema = z.object({ const formSchema = z.object({
vaultUrl: z.string().min(1), vaultUrl: z.string().min(1),
gatewayId: z.string().optional(),
vaultNamespace: z.string().trim().optional(), vaultNamespace: z.string().trim().optional(),
vaultAccessToken: z.string().min(1), vaultAccessToken: z.string().min(1),
mappingType: z.nativeEnum(VaultMappingType).default(VaultMappingType.KeyVault) mappingType: z.nativeEnum(VaultMappingType).default(VaultMappingType.KeyVault)
}); });
type TFormData = z.infer<typeof formSchema>; type TFormData = z.infer<typeof formSchema>;
const { data: gateways, isPending: isGatewayLoading } = useQuery(gatewaysQueryKeys.list());
const { mutateAsync: importVault } = useImportVault(); const { mutateAsync: importVault } = useImportVault();
const { const {
control, control,
handleSubmit, handleSubmit,
reset, reset,
formState: { isLoading, isDirty, isSubmitting, isValid, errors } formState: { isLoading, isDirty, isSubmitting, isValid }
} = useForm<TFormData>({ } = useForm<TFormData>({
resolver: zodResolver(formSchema) resolver: zodResolver(formSchema)
}); });
console.log({
isSubmitting,
isLoading,
isValid,
errors
});
const onSubmit = async (data: TFormData) => { const onSubmit = async (data: TFormData) => {
await importVault({ await importVault({
vaultAccessToken: data.vaultAccessToken, vaultAccessToken: data.vaultAccessToken,
vaultNamespace: data.vaultNamespace, vaultNamespace: data.vaultNamespace,
vaultUrl: data.vaultUrl, vaultUrl: data.vaultUrl,
mappingType: data.mappingType mappingType: data.mappingType,
...(data.gatewayId && { gatewayId: data.gatewayId })
}); });
createNotification({ createNotification({
title: "Import started", title: "Import started",
@@ -110,11 +113,70 @@ export const VaultPlatformModal = ({ onClose }: Props) => {
The Vault migration currently supports importing static secrets from Vault The Vault migration currently supports importing static secrets from Vault
Dedicated/Self-Hosted. Dedicated/Self-Hosted.
<div className="mt-2 text-xs opacity-80"> <div className="mt-2 text-xs opacity-80">
Currently only KV Secret Engine V2 is supported for Vault migrations. Currently only KV Secret Engine is supported for Vault migrations.
</div> </div>
</p> </p>
</NoticeBannerV2> </NoticeBannerV2>
<form onSubmit={handleSubmit(onSubmit)} autoComplete="off"> <form onSubmit={handleSubmit(onSubmit)} autoComplete="off">
<div className="w-full flex-1">
<OrgPermissionCan
I={OrgGatewayPermissionActions.AttachGateways}
a={OrgPermissionSubjects.Gateway}
>
{(isAllowed) => (
<Controller
control={control}
name="gatewayId"
defaultValue=""
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
isError={Boolean(error?.message)}
errorText={error?.message}
label="Gateway"
isOptional
>
<Tooltip
isDisabled={isAllowed}
content="Restricted access. You don't have permission to attach gateways to resources."
>
<div>
<Select
isDisabled={!isAllowed}
value={value as string}
onValueChange={(v) => {
if (v !== "") {
onChange(v);
}
}}
className="w-full border border-mineshaft-500"
dropdownContainerClassName="max-w-none"
isLoading={isGatewayLoading}
placeholder="Default: Internet Gateway"
position="popper"
>
<SelectItem
value=""
onClick={() => {
onChange(undefined);
}}
>
Internet Gateway
</SelectItem>
{gateways?.map((el) => (
<SelectItem value={el.id} key={el.id}>
{el.name}
</SelectItem>
))}
</Select>
</div>
</Tooltip>
</FormControl>
)}
/>
)}
</OrgPermissionCan>
</div>
<Controller <Controller
control={control} control={control}
name="vaultUrl" name="vaultUrl"