mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-12 14:29:34 +00:00
feat: add in-platform support for secret imports
This commit is contained in:
@@ -1,10 +1,16 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { apiRequest } from "@app/config/request";
|
||||
import { dashboardKeys } from "@app/hooks/api/dashboard/queries";
|
||||
import { secretKeys } from "@app/hooks/api/secrets/queries";
|
||||
|
||||
import { projectKeys } from "../projects";
|
||||
import { externalMigrationQueryKeys } from "./queries";
|
||||
import { ExternalMigrationProviders, TExternalMigrationConfig } from "./types";
|
||||
import {
|
||||
ExternalMigrationProviders,
|
||||
TExternalMigrationConfig,
|
||||
TImportVaultSecretsDTO
|
||||
} from "./types";
|
||||
|
||||
export const useImportEnvKey = () => {
|
||||
const queryClient = useQueryClient();
|
||||
@@ -90,3 +96,27 @@ export const useUpdateExternalMigrationConfig = (platform: ExternalMigrationProv
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
export const useImportVaultSecrets = () => {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation<{ message: string }, object, TImportVaultSecretsDTO>({
|
||||
mutationFn: async (dto) => {
|
||||
const { data } = await apiRequest.post<{ message: string }>(
|
||||
"/api/v3/external-migration/vault/import-secrets",
|
||||
dto
|
||||
);
|
||||
return data;
|
||||
},
|
||||
onSuccess: (_, { projectId, environment, secretPath }) => {
|
||||
queryClient.invalidateQueries({ queryKey: dashboardKeys.all() });
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: secretKeys.getProjectSecret({
|
||||
projectId,
|
||||
environment,
|
||||
secretPath
|
||||
})
|
||||
});
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -12,7 +12,8 @@ export const externalMigrationQueryKeys = {
|
||||
config: (platform: string) => ["external-migration-config", { platform }],
|
||||
vaultNamespaces: () => ["vault-namespaces"],
|
||||
vaultPolicies: () => ["vault-policies"],
|
||||
vaultMounts: () => ["vault-mounts"]
|
||||
vaultMounts: () => ["vault-mounts"],
|
||||
vaultSecretPaths: () => ["vault-secret-paths"]
|
||||
};
|
||||
|
||||
export const useHasCustomMigrationAvailable = (provider: ExternalMigrationProviders) => {
|
||||
@@ -88,3 +89,21 @@ export const useGetVaultMounts = (enabled = true, namespace?: string) => {
|
||||
enabled
|
||||
});
|
||||
};
|
||||
|
||||
export const useGetVaultSecretPaths = (enabled = true, namespace?: string) => {
|
||||
return useQuery({
|
||||
queryKey: externalMigrationQueryKeys.vaultSecretPaths(),
|
||||
queryFn: async () => {
|
||||
const { data } = await apiRequest.get<{
|
||||
secretPaths: string[];
|
||||
}>("/api/v3/external-migration/vault/secret-paths", {
|
||||
params: {
|
||||
namespace
|
||||
}
|
||||
});
|
||||
|
||||
return data.secretPaths;
|
||||
},
|
||||
enabled
|
||||
});
|
||||
};
|
||||
|
||||
@@ -11,3 +11,11 @@ export type TExternalMigrationConfig = {
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type TImportVaultSecretsDTO = {
|
||||
projectId: string;
|
||||
environment: string;
|
||||
secretPath: string;
|
||||
vaultNamespace: string;
|
||||
vaultSecretPath: string;
|
||||
};
|
||||
|
||||
+81
-7
@@ -77,6 +77,8 @@ import {
|
||||
fetchDashboardProjectSecretsByKeys
|
||||
} from "@app/hooks/api/dashboard/queries";
|
||||
import { UsedBySecretSyncs } from "@app/hooks/api/dashboard/types";
|
||||
import { useGetExternalMigrationConfig, useImportVaultSecrets } from "@app/hooks/api/migration";
|
||||
import { ExternalMigrationProviders } 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 +100,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 +174,8 @@ export const ActionBar = ({
|
||||
"upgradePlan",
|
||||
"replicateFolder",
|
||||
"confirmUpload",
|
||||
"requestAccess"
|
||||
"requestAccess",
|
||||
"importFromVault"
|
||||
] as const);
|
||||
const isProtectedBranch = Boolean(protectedBranchPolicyName);
|
||||
const { subscription } = useSubscription();
|
||||
@@ -185,6 +189,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 +198,8 @@ export const ActionBar = ({
|
||||
const isMultiSelectActive = Boolean(Object.keys(selectedSecrets).length);
|
||||
|
||||
const { permission } = useProjectPermission();
|
||||
const { data: vaultConfig } = useGetExternalMigrationConfig(ExternalMigrationProviders.Vault);
|
||||
const hasVaultConnection = Boolean(vaultConfig?.connectionId);
|
||||
|
||||
const handleFolderCreate = async (folderName: string, description: string | null) => {
|
||||
try {
|
||||
@@ -663,6 +670,33 @@ export const ActionBar = ({
|
||||
}
|
||||
};
|
||||
|
||||
const handleVaultImport = async (vaultPath: string, namespace: string) => {
|
||||
try {
|
||||
await importVaultSecrets({
|
||||
projectId,
|
||||
environment,
|
||||
secretPath,
|
||||
vaultNamespace: namespace,
|
||||
vaultSecretPath: vaultPath
|
||||
});
|
||||
|
||||
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);
|
||||
|
||||
@@ -784,8 +818,8 @@ export const ActionBar = ({
|
||||
{Boolean(filteredTags) && <Badge>{filteredTags} Applied</Badge>}
|
||||
</div>
|
||||
</DropdownSubMenuTrigger>
|
||||
<DropdownSubMenuContent className="max-h-80 thin-scrollbar overflow-y-auto rounded-l-none">
|
||||
<DropdownMenuLabel className="sticky top-0 bg-mineshaft-900">
|
||||
<DropdownSubMenuContent className="thin-scrollbar max-h-80 overflow-y-auto rounded-l-none">
|
||||
<DropdownMenuLabel className="bg-mineshaft-900 sticky top-0">
|
||||
<div className="flex w-full items-center justify-between">
|
||||
<span>Filter by Secret Tags</span>
|
||||
<Tooltip content="Matches secrets with one or more of the applied tags">
|
||||
@@ -926,7 +960,7 @@ export const ActionBar = ({
|
||||
<IconButton
|
||||
ariaLabel="add-folder-or-import"
|
||||
variant="outline_bg"
|
||||
className="rounded-l-none bg-mineshaft-600 p-3"
|
||||
className="bg-mineshaft-600 rounded-l-none p-3"
|
||||
>
|
||||
<FontAwesomeIcon icon={faAngleDown} />
|
||||
</IconButton>
|
||||
@@ -1059,6 +1093,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>
|
||||
@@ -1070,11 +1137,11 @@ export const ActionBar = ({
|
||||
isMultiSelectActive && "h-16"
|
||||
)}
|
||||
>
|
||||
<div className="mt-3.5 flex items-center rounded-md border border-mineshaft-600 bg-mineshaft-800 px-4 py-2 text-bunker-300">
|
||||
<div className="border-mineshaft-600 bg-mineshaft-800 text-bunker-300 mt-3.5 flex items-center rounded-md border px-4 py-2">
|
||||
<div className="mr-2 text-sm">{Object.keys(selectedSecrets).length} Selected</div>
|
||||
<button
|
||||
type="button"
|
||||
className="mr-auto text-xs text-mineshaft-400 underline-offset-2 hover:text-mineshaft-200 hover:underline"
|
||||
className="text-mineshaft-400 hover:text-mineshaft-200 mr-auto text-xs underline-offset-2 hover:underline"
|
||||
onClick={resetSelectedSecret}
|
||||
>
|
||||
Unselect All
|
||||
@@ -1261,7 +1328,7 @@ export const ActionBar = ({
|
||||
onOpenChange={(open) => handlePopUpToggle("requestAccess", open)}
|
||||
>
|
||||
<ModalContent title="Access Restricted">
|
||||
<p className="mb-2 text-bunker-300">You do not have permission to perform this action.</p>
|
||||
<p className="text-bunker-300 mb-2">You do not have permission to perform this action.</p>
|
||||
<p className="text-bunker-300">Request access to perform this action in this folder.</p>
|
||||
<div className="mt-8 flex items-center gap-4">
|
||||
<ModalClose asChild>
|
||||
@@ -1277,6 +1344,13 @@ export const ActionBar = ({
|
||||
</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
<VaultSecretImportModal
|
||||
isOpen={popUp.importFromVault.isOpen}
|
||||
onOpenChange={(isOpen) => handlePopUpToggle("importFromVault", isOpen)}
|
||||
environment={environment}
|
||||
secretPath={secretPath}
|
||||
onImport={handleVaultImport}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
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>("default");
|
||||
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,
|
||||
refetch: refetchPaths
|
||||
} = useGetVaultSecretPaths(shouldFetchPaths, selectedNamespace);
|
||||
const {
|
||||
data: mounts,
|
||||
isLoading: isLoadingMounts,
|
||||
refetch: refetchMounts
|
||||
} = useGetVaultMounts(shouldFetchMounts, selectedNamespace);
|
||||
|
||||
// Enable fetching paths and mounts when namespace is selected
|
||||
useEffect(() => {
|
||||
if (selectedNamespace) {
|
||||
setShouldFetchPaths(true);
|
||||
setShouldFetchMounts(true);
|
||||
}
|
||||
}, [selectedNamespace]);
|
||||
|
||||
const handleImport = () => {
|
||||
if (!selectedPath) {
|
||||
createNotification({ type: "error", text: "Please select a Vault secret path to import" });
|
||||
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="bg-primary/10 text-mineshaft-200 mb-4 rounded-md p-3 text-sm">
|
||||
<div className="flex items-start gap-2">
|
||||
<FontAwesomeIcon icon={faInfoCircle} className="text-primary mt-0.5" />
|
||||
<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
|
||||
environment (<code className="text-xs">{environment}</code>) at path{" "}
|
||||
<code className="text-xs">{secretPath}</code>.
|
||||
</p>
|
||||
<p>
|
||||
<strong>Note:</strong> Existing secrets with the same key will be overwritten.
|
||||
Secrets will be imported from the selected Vault path.
|
||||
</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);
|
||||
setSelectedPath(null);
|
||||
// Refetch paths and mounts when namespace changes
|
||||
refetchPaths();
|
||||
refetchMounts();
|
||||
}
|
||||
}}
|
||||
options={namespaces || []}
|
||||
getOptionValue={(option) => option.name}
|
||||
getOptionLabel={(option) => option.name}
|
||||
isDisabled={isLoadingNamespaces}
|
||||
placeholder="Select namespace..."
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-mineshaft-400 mt-1 text-xs">
|
||||
Select the Vault namespace to fetch 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}
|
||||
placeholder="Select a Vault path to import..."
|
||||
isClearable
|
||||
className="w-full"
|
||||
/>
|
||||
<p className="text-mineshaft-400 mt-1 text-xs">
|
||||
Choose a secret path from your Vault namespace 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
|
||||
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