mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-20 22:17:52 +00:00
Merge pull request #4667 from Infisical/feat/in-platform-vault-migration-tooling
feat: in-platform migration tooling for Vault policies + scaffolding
This commit is contained in:
+85
-1
@@ -77,6 +77,11 @@ import {
|
||||
fetchDashboardProjectSecretsByKeys
|
||||
} from "@app/hooks/api/dashboard/queries";
|
||||
import { UsedBySecretSyncs } from "@app/hooks/api/dashboard/types";
|
||||
import {
|
||||
useGetVaultExternalMigrationConfigs,
|
||||
useImportVaultSecrets
|
||||
} from "@app/hooks/api/migration";
|
||||
import { VaultImportStatus } from "@app/hooks/api/migration/types";
|
||||
import { secretApprovalRequestKeys } from "@app/hooks/api/secretApprovalRequest/queries";
|
||||
import { PendingAction } from "@app/hooks/api/secretFolders/types";
|
||||
import { fetchProjectSecrets, secretKeys } from "@app/hooks/api/secrets/queries";
|
||||
@@ -98,6 +103,7 @@ import { CreateDynamicSecretForm } from "./CreateDynamicSecretForm";
|
||||
import { CreateSecretImportForm } from "./CreateSecretImportForm";
|
||||
import { FolderForm } from "./FolderForm";
|
||||
import { MoveSecretsModal } from "./MoveSecretsModal";
|
||||
import { VaultSecretImportModal } from "./VaultSecretImportModal";
|
||||
|
||||
type TParsedEnv = { value: string; comments: string[]; secretPath?: string; secretKey: string }[];
|
||||
type TParsedFolderEnv = Record<
|
||||
@@ -171,7 +177,8 @@ export const ActionBar = ({
|
||||
"upgradePlan",
|
||||
"replicateFolder",
|
||||
"confirmUpload",
|
||||
"requestAccess"
|
||||
"requestAccess",
|
||||
"importFromVault"
|
||||
] as const);
|
||||
const isProtectedBranch = Boolean(protectedBranchPolicyName);
|
||||
const { subscription } = useSubscription();
|
||||
@@ -185,6 +192,7 @@ export const ActionBar = ({
|
||||
const { mutateAsync: createSecretBatch, isPending: isCreatingSecrets } = useCreateSecretBatch({
|
||||
options: { onSuccess: undefined }
|
||||
});
|
||||
const { mutateAsync: importVaultSecrets } = useImportVaultSecrets();
|
||||
const queryClient = useQueryClient();
|
||||
const { addPendingChange } = useBatchModeActions();
|
||||
|
||||
@@ -193,6 +201,8 @@ export const ActionBar = ({
|
||||
const isMultiSelectActive = Boolean(Object.keys(selectedSecrets).length);
|
||||
|
||||
const { permission } = useProjectPermission();
|
||||
const { data: vaultConfigs = [] } = useGetVaultExternalMigrationConfigs();
|
||||
const hasVaultConnection = vaultConfigs.some((config) => config.connectionId);
|
||||
|
||||
const handleFolderCreate = async (folderName: string, description: string | null) => {
|
||||
try {
|
||||
@@ -663,6 +673,40 @@ export const ActionBar = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleVaultImport = async (vaultPath: string, namespace: string) => {
|
||||
try {
|
||||
const result = await importVaultSecrets({
|
||||
projectId,
|
||||
environment,
|
||||
secretPath,
|
||||
vaultNamespace: namespace,
|
||||
vaultSecretPath: vaultPath
|
||||
});
|
||||
|
||||
if (result.status === VaultImportStatus.ApprovalRequired) {
|
||||
createNotification({
|
||||
type: "info",
|
||||
text: "Secret change request created successfully. Awaiting approval."
|
||||
});
|
||||
} else {
|
||||
createNotification({
|
||||
type: "success",
|
||||
text: "Successfully imported secrets from HashiCorp Vault"
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
console.error("Vault import error:", err);
|
||||
const error = err as AxiosError<{ message?: string }>;
|
||||
const errorMessage =
|
||||
error.response?.data?.message || "Failed to import secrets from Vault. Please try again.";
|
||||
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: errorMessage
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const isTableFiltered =
|
||||
Object.values(filter.tags).some(Boolean) || Object.values(filter.include).some(Boolean);
|
||||
|
||||
@@ -1059,6 +1103,39 @@ export const ActionBar = ({
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
{hasVaultConnection && (
|
||||
<ProjectPermissionCan
|
||||
I={ProjectPermissionActions.Create}
|
||||
a={subject(ProjectPermissionSub.Secrets, {
|
||||
environment,
|
||||
secretPath,
|
||||
secretName: "*",
|
||||
secretTags: ["*"]
|
||||
})}
|
||||
>
|
||||
{(isAllowed) => (
|
||||
<Button
|
||||
leftIcon={
|
||||
<img
|
||||
src="/images/integrations/Vault.png"
|
||||
alt="HashiCorp Vault"
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
}
|
||||
onClick={() => {
|
||||
handlePopUpOpen("importFromVault");
|
||||
handlePopUpClose("misc");
|
||||
}}
|
||||
isDisabled={!isAllowed}
|
||||
variant="outline_bg"
|
||||
className="h-10 text-left"
|
||||
isFullWidth
|
||||
>
|
||||
Add from HashiCorp Vault
|
||||
</Button>
|
||||
)}
|
||||
</ProjectPermissionCan>
|
||||
)}
|
||||
</div>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
@@ -1277,6 +1354,13 @@ export const ActionBar = ({
|
||||
</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<VaultSecretImportModal
|
||||
isOpen={popUp.importFromVault.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("importFromVault", isOpen)}
|
||||
environment={environment}
|
||||
secretPath={secretPath}
|
||||
onImport={handleVaultImport}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+242
@@ -0,0 +1,242 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { faInfoCircle } from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
import {
|
||||
Button,
|
||||
FilterableSelect,
|
||||
FormControl,
|
||||
Modal,
|
||||
ModalClose,
|
||||
ModalContent
|
||||
} from "@app/components/v2";
|
||||
import {
|
||||
useGetVaultMounts,
|
||||
useGetVaultNamespaces,
|
||||
useGetVaultSecretPaths
|
||||
} from "@app/hooks/api/migration/queries";
|
||||
|
||||
type Props = {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (isOpen: boolean) => void;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
onImport: (vaultPath: string, namespace: string) => void;
|
||||
};
|
||||
|
||||
type ContentProps = {
|
||||
onClose: () => void;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
onImport: (vaultPath: string, namespace: string) => void;
|
||||
};
|
||||
|
||||
const Content = ({ onClose, environment, secretPath, onImport }: ContentProps) => {
|
||||
const [selectedNamespace, setSelectedNamespace] = useState<string | null>(null);
|
||||
const [selectedMountPath, setSelectedMountPath] = useState<string | null>(null);
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null);
|
||||
const [shouldFetchPaths, setShouldFetchPaths] = useState(false);
|
||||
const [shouldFetchMounts, setShouldFetchMounts] = useState(false);
|
||||
|
||||
const { data: namespaces, isLoading: isLoadingNamespaces } = useGetVaultNamespaces();
|
||||
const { data: secretPaths, isLoading: isLoadingPaths } = useGetVaultSecretPaths(
|
||||
shouldFetchPaths,
|
||||
selectedNamespace ?? undefined,
|
||||
selectedMountPath ?? undefined
|
||||
);
|
||||
const { data: mounts, isLoading: isLoadingMounts } = useGetVaultMounts(
|
||||
shouldFetchMounts,
|
||||
selectedNamespace ?? undefined
|
||||
);
|
||||
|
||||
// Filter to only show KV mounts
|
||||
const kvMounts = mounts?.filter((mount) => mount.type === "kv" || mount.type.startsWith("kv"));
|
||||
|
||||
// Enable fetching mounts when namespace is selected
|
||||
useEffect(() => {
|
||||
if (selectedNamespace) {
|
||||
setShouldFetchMounts(true);
|
||||
}
|
||||
}, [selectedNamespace]);
|
||||
|
||||
// Enable fetching paths when both namespace and mount path are selected
|
||||
useEffect(() => {
|
||||
if (selectedNamespace && selectedMountPath) {
|
||||
setShouldFetchPaths(true);
|
||||
} else {
|
||||
setShouldFetchPaths(false);
|
||||
}
|
||||
}, [selectedNamespace, selectedMountPath]);
|
||||
|
||||
const handleImport = () => {
|
||||
if (!selectedPath) {
|
||||
createNotification({ type: "error", text: "Please select a Vault secret path to import" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selectedNamespace) {
|
||||
createNotification({ type: "error", text: "Please select a namespace" });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!mounts || mounts.length === 0) {
|
||||
createNotification({
|
||||
type: "error",
|
||||
text: "No Vault mounts found. Please ensure you have KV secret engines configured."
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
onImport(selectedPath, selectedNamespace);
|
||||
onClose();
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 rounded-md bg-primary/10 p-3 text-sm text-mineshaft-200">
|
||||
<div className="flex items-start gap-2">
|
||||
<FontAwesomeIcon icon={faInfoCircle} className="mt-0.5 text-primary" />
|
||||
<div>
|
||||
<div className="mb-2">
|
||||
<strong>Import Secrets from HashiCorp Vault</strong>
|
||||
</div>
|
||||
<div className="space-y-1.5 text-xs leading-relaxed">
|
||||
<p>
|
||||
Select a Vault namespace and secret path to import secrets into the current
|
||||
Infisical environment (<code className="text-xs">{environment}</code>) at path{" "}
|
||||
<code className="text-xs">{secretPath}</code>.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<FormControl
|
||||
label="Namespace"
|
||||
className="mb-4"
|
||||
tooltipText="Select the Vault namespace containing the secrets you want to import."
|
||||
>
|
||||
<>
|
||||
<FilterableSelect
|
||||
value={namespaces?.find((ns) => ns.name === selectedNamespace)}
|
||||
onChange={(value) => {
|
||||
if (value && !Array.isArray(value)) {
|
||||
const namespace = value as { id: string; name: string };
|
||||
setSelectedNamespace(namespace.name);
|
||||
setSelectedMountPath(null);
|
||||
setSelectedPath(null);
|
||||
}
|
||||
}}
|
||||
options={namespaces || []}
|
||||
getOptionValue={(option) => option.name}
|
||||
getOptionLabel={(option) => (option.name === "/" ? "root" : option.name)}
|
||||
isDisabled={isLoadingNamespaces}
|
||||
placeholder="Select namespace..."
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-mineshaft-400">
|
||||
Select the Vault namespace to fetch available mounts
|
||||
</p>
|
||||
</>
|
||||
</FormControl>
|
||||
|
||||
<FormControl
|
||||
label="Secrets Engine"
|
||||
className="mb-4"
|
||||
tooltipText="Select the KV secrets engine to narrow down secret paths."
|
||||
>
|
||||
<>
|
||||
<FilterableSelect
|
||||
value={kvMounts?.find((mount) => mount.path === selectedMountPath)}
|
||||
onChange={(value) => {
|
||||
if (value && !Array.isArray(value)) {
|
||||
const mount = value as { path: string; type: string; version: string | null };
|
||||
setSelectedMountPath(mount.path.replace(/\/$/, "")); // Remove trailing slash
|
||||
setSelectedPath(null);
|
||||
}
|
||||
}}
|
||||
options={kvMounts || []}
|
||||
getOptionValue={(option) => option.path}
|
||||
getOptionLabel={(option) => option.path.replace(/\/$/, "")}
|
||||
isDisabled={isLoadingMounts || !kvMounts?.length}
|
||||
placeholder="Select secrets engine..."
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-mineshaft-400">
|
||||
Choose a KV secrets engine to filter available secret paths
|
||||
</p>
|
||||
</>
|
||||
</FormControl>
|
||||
|
||||
<FormControl label="Vault Secret Path" className="mb-6">
|
||||
<>
|
||||
<FilterableSelect
|
||||
value={selectedPath ? { path: selectedPath } : null}
|
||||
onChange={(value) => {
|
||||
if (value && !Array.isArray(value)) {
|
||||
setSelectedPath((value as { path: string }).path);
|
||||
} else {
|
||||
setSelectedPath(null);
|
||||
}
|
||||
}}
|
||||
options={(secretPaths || []).map((path) => ({ path }))}
|
||||
getOptionValue={(option) => option.path}
|
||||
getOptionLabel={(option) => option.path}
|
||||
isDisabled={isLoadingPaths || !secretPaths?.length || !selectedMountPath}
|
||||
placeholder={
|
||||
!selectedMountPath
|
||||
? "Select a mount path first..."
|
||||
: "Select a Vault path to import..."
|
||||
}
|
||||
isClearable
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="mt-1 text-xs text-mineshaft-400">
|
||||
Choose a secret path from the selected mount to import into Infisical
|
||||
</p>
|
||||
</>
|
||||
</FormControl>
|
||||
|
||||
<div className="mt-8 flex space-x-4">
|
||||
<Button
|
||||
onClick={handleImport}
|
||||
isDisabled={!selectedPath || isLoadingMounts || isLoadingPaths}
|
||||
>
|
||||
Import Secrets
|
||||
</Button>
|
||||
<ModalClose asChild>
|
||||
<Button colorSchema="secondary" variant="plain">
|
||||
Cancel
|
||||
</Button>
|
||||
</ModalClose>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export const VaultSecretImportModal = ({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
environment,
|
||||
secretPath,
|
||||
onImport
|
||||
}: Props) => {
|
||||
return (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
<ModalContent
|
||||
bodyClassName="overflow-visible"
|
||||
title="Import from HashiCorp Vault"
|
||||
subTitle="Select a Vault namespace and secret path to import secrets into the current environment and folder."
|
||||
className="max-w-2xl"
|
||||
>
|
||||
<Content
|
||||
onClose={() => onOpenChange(false)}
|
||||
environment={environment}
|
||||
secretPath={secretPath}
|
||||
onImport={onImport}
|
||||
/>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user