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

View File

@@ -2199,7 +2199,9 @@ export const registerRoutes = async (
gatewayService,
kmsService,
appConnectionService,
externalMigrationConfigDAL
externalMigrationConfigDAL,
secretService,
auditLogService
});
// setup the communication with license key server

View File

@@ -139,7 +139,7 @@ export const registerExternalMigrationRouter = async (server: FastifyZodProvider
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
onRequest: verifyAuth([AuthMode.JWT]),
handler: async (req) => {
const config = await server.services.migration.getExternalMigrationConfig({
platform: req.query.platform,
@@ -260,4 +260,63 @@ export const registerExternalMigrationRouter = async (server: FastifyZodProvider
return { mounts };
}
});
server.route({
method: "POST",
url: "/vault/import-secrets",
config: {
rateLimit: writeLimit
},
schema: {
body: z.object({
projectId: z.string(),
environment: z.string(),
secretPath: z.string(),
vaultNamespace: z.string(),
vaultSecretPath: z.string()
}),
response: {
200: z.object({
message: z.string()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
await server.services.migration.importVaultSecrets({
actor: req.permission,
auditLogInfo: req.auditLogInfo,
...req.body
});
return { message: "Successfully imported vault secrets" };
}
});
server.route({
method: "GET",
url: "/vault/secret-paths",
config: {
rateLimit: readLimit
},
schema: {
querystring: z.object({
namespace: z.string().optional()
}),
response: {
200: z.object({
secretPaths: z.string().array()
})
}
},
onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]),
handler: async (req) => {
const secretPaths = await server.services.migration.getVaultSecretPaths({
actor: req.permission,
namespace: req.query.namespace
});
return { secretPaths };
}
});
};

View File

@@ -363,3 +363,210 @@ export const listHCVaultMounts = async (
return mounts;
};
export const listHCVaultSecretPaths = async (
connection: THCVaultConnection,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">,
namespace?: string
) => {
const instanceUrl = await getHCVaultInstanceUrl(connection);
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
if (namespace && connection.credentials.namespace) {
throw new BadRequestError({
message: "Namespace cannot be specified when namespace is already set in the connection credentials"
});
}
const targetNamespace = namespace || connection.credentials.namespace;
const getPaths = async (mountPath: string, secretPath: string, kvVersion: "1" | "2"): Promise<string[] | null> => {
try {
let path: string;
if (kvVersion === "2") {
// For KV v2: /v1/{mount}/metadata/{path}?list=true
path = secretPath ? `${mountPath}/metadata/${secretPath}` : `${mountPath}/metadata`;
} else {
// For KV v1: /v1/{mount}/{path}?list=true
path = secretPath ? `${mountPath}/${secretPath}` : mountPath;
}
const { data } = await requestWithHCVaultGateway<{
data: {
keys: string[];
};
}>(connection, gatewayService, {
url: `${instanceUrl}/v1/${path}?list=true`,
method: "GET",
headers: {
"X-Vault-Token": accessToken,
...(targetNamespace ? { "X-Vault-Namespace": targetNamespace } : {})
}
});
return data.data.keys;
} catch (error) {
if (error instanceof AxiosError && error.response?.status === 404) {
return null;
}
throw error;
}
};
// Recursive function to get all secret paths in a mount
const recursivelyGetAllPaths = async (
mountPath: string,
kvVersion: "1" | "2",
currentPath: string = ""
): Promise<string[]> => {
const paths = await getPaths(mountPath, currentPath, kvVersion);
if (paths === null || paths.length === 0) {
return [];
}
const allSecrets: string[] = [];
// Process paths sequentially to maintain tree traversal order
// eslint-disable-next-line no-restricted-syntax
for (const path of paths) {
const cleanPath = path.endsWith("/") ? path.slice(0, -1) : path;
const fullItemPath = currentPath ? `${currentPath}/${cleanPath}` : cleanPath;
if (path.endsWith("/")) {
// it's a folder so we recurse into it
// eslint-disable-next-line no-await-in-loop
const subSecrets = await recursivelyGetAllPaths(mountPath, kvVersion, fullItemPath);
allSecrets.push(...subSecrets);
} else {
// it's a secret so we add it to our results
allSecrets.push(`${mountPath}/${fullItemPath}`);
}
}
return allSecrets;
};
// Get all mounts
const mounts = await listHCVaultMounts(connection, gatewayService, namespace);
// Filter for KV mounts (kv, kv-v1, kv-v2)
const kvMounts = mounts.filter((mount) => mount.type === "kv" || mount.type.startsWith("kv"));
// Collect all secret paths from all KV mounts in parallel
const allSecretPathsArrays = await Promise.all(
kvMounts.map(async (mount) => {
const kvVersion = mount.version === "2" ? "2" : "1";
const cleanMountPath = mount.path.replace(/\/$/, ""); // Remove trailing slash
return recursivelyGetAllPaths(cleanMountPath, kvVersion);
})
);
// Flatten the arrays into a single array
const allSecretPaths = allSecretPathsArrays.flat();
return allSecretPaths;
};
export const getHCVaultSecretsForPath = async (
connection: THCVaultConnection,
gatewayService: Pick<TGatewayServiceFactory, "fnGetGatewayClientTlsByGatewayId">,
namespace: string,
secretPath: string
) => {
const instanceUrl = await getHCVaultInstanceUrl(connection);
const accessToken = await getHCVaultAccessToken(connection, gatewayService);
if (connection.credentials.namespace && connection.credentials.namespace !== namespace) {
throw new BadRequestError({
message: "Specified namespace does not match the namespace in the connection credentials"
});
}
const targetNamespace = namespace || connection.credentials.namespace;
try {
// Extract mount and path from the secretPath
// secretPath format: {mount}/{path}
const pathParts = secretPath.split("/");
const mountPath = pathParts[0];
const actualPath = pathParts.slice(1).join("/");
if (!mountPath || !actualPath) {
throw new BadRequestError({
message: "Invalid secret path format. Expected format: {mount}/{path}"
});
}
// Get mounts to determine KV version
const mounts = await listHCVaultMounts(connection, gatewayService, namespace);
const mount = mounts.find((m) => m.path.replace(/\/$/, "") === mountPath);
if (!mount) {
throw new BadRequestError({
message: `Mount '${mountPath}' not found in HashiCorp Vault`
});
}
const kvVersion = mount.version === "2" ? "2" : "1";
// Fetch secrets based on KV version
if (kvVersion === "2") {
// For KV v2: /v1/{mount}/data/{path}
const { data } = await requestWithHCVaultGateway<{
data: {
data: Record<string, string>; // KV v2 has nested data structure
metadata: {
created_time: string;
deletion_time: string;
destroyed: boolean;
version: number;
};
};
}>(connection, gatewayService, {
url: `${instanceUrl}/v1/${mountPath}/data/${actualPath}`,
method: "GET",
headers: {
"X-Vault-Token": accessToken,
...(targetNamespace ? { "X-Vault-Namespace": targetNamespace } : {})
}
});
return data.data.data;
}
// For KV v1: /v1/{mount}/{path}
const { data } = await requestWithHCVaultGateway<{
data: Record<string, string>; // KV v1 has flat data structure
lease_duration: number;
lease_id: string;
renewable: boolean;
}>(connection, gatewayService, {
url: `${instanceUrl}/v1/${mountPath}/${actualPath}`,
method: "GET",
headers: {
"X-Vault-Token": accessToken,
...(targetNamespace ? { "X-Vault-Namespace": targetNamespace } : {})
}
});
return data.data;
} catch (error: unknown) {
logger.error(error, "Unable to fetch secrets from HC Vault path");
if (error instanceof AxiosError) {
throw new BadRequestError({
message: `Failed to fetch secrets: ${error.message || "Unknown error"}`
});
}
if (error instanceof BadRequestError) {
throw error;
}
throw new BadRequestError({
message: "Unable to fetch secrets from HashiCorp Vault"
});
}
};

View File

@@ -1,4 +1,10 @@
import { OrgMembershipRole } from "@app/db/schemas";
import {
AuditLogInfo,
EventType,
SecretApprovalEvent,
TAuditLogServiceFactory
} from "@app/ee/services/audit-log/audit-log-types";
import { TGatewayServiceFactory } from "@app/ee/services/gateway/gateway-service";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service-types";
import { crypto } from "@app/lib/crypto/cryptography";
@@ -9,12 +15,16 @@ import { AppConnection } from "../app-connection/app-connection-enums";
import { decryptAppConnectionCredentials } from "../app-connection/app-connection-fns";
import { TAppConnectionServiceFactory } from "../app-connection/app-connection-service";
import {
getHCVaultSecretsForPath,
listHCVaultMounts,
listHCVaultNamespaces,
listHCVaultPolicies,
listHCVaultSecretPaths,
THCVaultConnection
} from "../app-connection/hc-vault";
import { TKmsServiceFactory } from "../kms/kms-service";
import { TSecretServiceFactory } from "../secret/secret-service";
import { SecretProtectionType } from "../secret/secret-types";
import { TUserDALFactory } from "../user/user-dal";
import { TExternalMigrationConfigDALFactory } from "./external-migration-config-dal";
import {
@@ -35,6 +45,8 @@ import {
type TExternalMigrationServiceFactoryDep = {
permissionService: TPermissionServiceFactory;
secretService: TSecretServiceFactory;
auditLogService: Pick<TAuditLogServiceFactory, "createAuditLog">;
externalMigrationQueue: TExternalMigrationQueueFactory;
appConnectionService: Pick<TAppConnectionServiceFactory, "connectAppConnectionById">;
externalMigrationConfigDAL: Pick<TExternalMigrationConfigDALFactory, "create" | "upsert" | "findOne" | "transaction">;
@@ -50,6 +62,8 @@ export const externalMigrationServiceFactory = ({
externalMigrationQueue,
userDAL,
gatewayService,
secretService,
auditLogService,
appConnectionService,
externalMigrationConfigDAL,
kmsService
@@ -371,6 +385,143 @@ export const externalMigrationServiceFactory = ({
return mounts;
};
const getVaultSecretPaths = async ({ actor, namespace }: { actor: OrgServiceActor; namespace?: string }) => {
const { hasRole } = await permissionService.getOrgPermission(
actor.type,
actor.id,
actor.orgId,
actor.authMethod,
actor.orgId
);
if (!hasRole(OrgMembershipRole.Admin)) {
throw new ForbiddenRequestError({ message: "Only admins can view vault secret paths" });
}
const vaultConfig = await externalMigrationConfigDAL.findOne({
orgId: actor.orgId,
platform: ExternalMigrationProviders.Vault
});
if (!vaultConfig) {
throw new NotFoundError({ message: "Vault migration config not found" });
}
if (!vaultConfig.connection) {
throw new BadRequestError({ message: "Vault migration connection is not configured" });
}
const credentials = await decryptAppConnectionCredentials({
orgId: vaultConfig.orgId,
encryptedCredentials: vaultConfig.connection.encryptedCredentials,
kmsService,
projectId: null
});
const connection = {
...vaultConfig.connection,
credentials
} as THCVaultConnection;
const secretPaths = await listHCVaultSecretPaths(connection, gatewayService, namespace);
return secretPaths;
};
const importVaultSecrets = async ({
actor,
projectId,
environment,
secretPath,
vaultNamespace,
vaultSecretPath,
auditLogInfo
}: {
actor: OrgServiceActor;
projectId: string;
environment: string;
secretPath: string;
vaultNamespace: string;
vaultSecretPath: string;
auditLogInfo: AuditLogInfo;
}) => {
const { hasRole } = await permissionService.getOrgPermission(
actor.type,
actor.id,
actor.orgId,
actor.authMethod,
actor.orgId
);
if (!hasRole(OrgMembershipRole.Admin)) {
throw new ForbiddenRequestError({ message: "Only admins can import vault secrets" });
}
const vaultConfig = await externalMigrationConfigDAL.findOne({
orgId: actor.orgId,
platform: ExternalMigrationProviders.Vault
});
if (!vaultConfig) {
throw new NotFoundError({ message: "Vault migration config not found" });
}
if (!vaultConfig.connection) {
throw new BadRequestError({ message: "Vault migration connection is not configured" });
}
const credentials = await decryptAppConnectionCredentials({
orgId: vaultConfig.orgId,
encryptedCredentials: vaultConfig.connection.encryptedCredentials,
kmsService,
projectId: null
});
const connection = {
...vaultConfig.connection,
credentials
} as THCVaultConnection;
const vaultSecrets = await getHCVaultSecretsForPath(connection, gatewayService, vaultNamespace, vaultSecretPath);
const secretOperation = await secretService.createManySecretsRaw({
actorId: actor.id,
actor: actor.type,
actorAuthMethod: actor.authMethod,
actorOrgId: actor.orgId,
secretPath,
environment,
projectId,
secrets: Object.entries(vaultSecrets).map(([secretKey, secretValue]) => ({
secretKey,
secretValue
}))
});
if (secretOperation.type === SecretProtectionType.Approval) {
await auditLogService.createAuditLog({
projectId,
...auditLogInfo,
event: {
type: EventType.SECRET_APPROVAL_REQUEST,
metadata: {
committedBy: secretOperation.approval.committerUserId,
secretApprovalRequestId: secretOperation.approval.id,
secretApprovalRequestSlug: secretOperation.approval.slug,
secretPath,
environment,
secrets: Object.entries(vaultSecrets).map(([secretKey]) => ({
secretKey
})),
eventType: SecretApprovalEvent.CreateMany
}
}
});
return { approval: secretOperation.approval };
}
};
return {
importEnvKeyData,
importVaultData,
@@ -379,6 +530,8 @@ export const externalMigrationServiceFactory = ({
getExternalMigrationConfig,
getVaultNamespaces,
getVaultPolicies,
getVaultMounts
getVaultMounts,
getVaultSecretPaths,
importVaultSecrets
};
};

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
})
});
}
});
};

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
});
};

View File

@@ -11,3 +11,11 @@ export type TExternalMigrationConfig = {
createdAt: string;
updatedAt: string;
};
export type TImportVaultSecretsDTO = {
projectId: string;
environment: string;
secretPath: string;
vaultNamespace: string;
vaultSecretPath: string;
};

View File

@@ -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}
/>
</>
);
};

View File

@@ -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>
);
};