feat: add in-platform support for secret imports

This commit is contained in:
Sheen Capadngan
2025-10-14 20:25:08 +08:00
parent b214e2dac6
commit dffc204ec2
9 changed files with 763 additions and 12 deletions
+31 -1
View File
@@ -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
})
});
}
});
};
+20 -1
View File
@@ -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;
};
@@ -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}
/>
</>
);
};
@@ -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>
);
};