Merge branch 'main' into ENG-3955

the commit.
This commit is contained in:
x032205
2025-10-17 23:21:10 -04:00
66 changed files with 4461 additions and 303 deletions
@@ -43,6 +43,8 @@ export const eventToNameMap: { [K in EventType]: string } = {
[EventType.REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET]: "Revoke universal auth client secret",
[EventType.CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS]: "Clear universal auth lockouts",
[EventType.GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS]: "Get universal auth client secrets",
[EventType.GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET_BY_ID]:
"Get universal auth client secret by id",
[EventType.CREATE_ENVIRONMENT]: "Create environment",
[EventType.UPDATE_ENVIRONMENT]: "Update environment",
[EventType.DELETE_ENVIRONMENT]: "Delete environment",
@@ -48,6 +48,7 @@ export enum EventType {
REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET = "revoke-identity-universal-auth-client-secret",
CLEAR_IDENTITY_UNIVERSAL_AUTH_LOCKOUTS = "clear-identity-universal-auth-lockouts",
GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRETS = "get-identity-universal-auth-client-secret",
GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET_BY_ID = "get-identity-universal-auth-client-secret-by-id",
LOGIN_IDENTITY_LDAP_AUTH = "login-identity-ldap-auth",
ADD_IDENTITY_LDAP_AUTH = "add-identity-ldap-auth",
@@ -318,6 +318,14 @@ interface GetIdentityUniversalAuthClientSecretsEvent {
};
}
interface GetIdentityUniversalAuthClientSecretByIdEvent {
type: EventType.GET_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET_BY_ID;
metadata: {
identityId: string;
clientSecretId: string;
};
}
interface RevokeIdentityUniversalAuthClientSecretEvent {
type: EventType.REVOKE_IDENTITY_UNIVERSAL_AUTH_CLIENT_SECRET;
metadata: {
@@ -906,6 +914,7 @@ export type Event =
| GetIdentityUniversalAuthEvent
| CreateIdentityUniversalAuthClientSecretEvent
| GetIdentityUniversalAuthClientSecretsEvent
| GetIdentityUniversalAuthClientSecretByIdEvent
| RevokeIdentityUniversalAuthClientSecretEvent
| ClearIdentityUniversalAuthLockoutsEvent
| CreateEnvironmentEvent
@@ -1,2 +1,3 @@
export * from "./mutations";
export * from "./queries";
export * from "./types";
@@ -1,8 +1,12 @@
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 { TImportVaultSecretsDTO, TVaultExternalMigrationConfig, VaultImportStatus } from "./types";
export const useImportEnvKey = () => {
const queryClient = useQueryClient();
@@ -65,3 +69,97 @@ export const useImportVault = () => {
}
});
};
export const useImportVaultSecrets = () => {
const queryClient = useQueryClient();
return useMutation<{ status: VaultImportStatus }, object, TImportVaultSecretsDTO>({
mutationFn: async (dto) => {
const { data } = await apiRequest.post<{ status: VaultImportStatus }>(
"/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
})
});
}
});
};
export const useCreateVaultExternalMigrationConfig = () => {
const queryClient = useQueryClient();
return useMutation<
TVaultExternalMigrationConfig,
Error,
{ connectionId: string; namespace: string }
>({
mutationFn: async ({ connectionId, namespace }) => {
const { data } = await apiRequest.post<{ config: TVaultExternalMigrationConfig }>(
"/api/v3/external-migration/vault/configs",
{
connectionId,
namespace
}
);
return data.config;
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: externalMigrationQueryKeys.vaultConfigs()
});
}
});
};
export const useUpdateVaultExternalMigrationConfig = () => {
const queryClient = useQueryClient();
return useMutation<
TVaultExternalMigrationConfig,
Error,
{ id: string; connectionId: string; namespace: string }
>({
mutationFn: async ({ id, connectionId, namespace }) => {
const { data } = await apiRequest.put<{ config: TVaultExternalMigrationConfig }>(
`/api/v3/external-migration/vault/configs/${id}`,
{
connectionId,
namespace
}
);
return data.config;
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: externalMigrationQueryKeys.vaultConfigs()
});
}
});
};
export const useDeleteVaultExternalMigrationConfig = () => {
const queryClient = useQueryClient();
return useMutation<TVaultExternalMigrationConfig, Error, { id: string }>({
mutationFn: async ({ id }) => {
const { data } = await apiRequest.delete<{ config: TVaultExternalMigrationConfig }>(
`/api/v3/external-migration/vault/configs/${id}`
);
return data.config;
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: externalMigrationQueryKeys.vaultConfigs()
});
}
});
};
+154 -2
View File
@@ -2,12 +2,35 @@ import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { ExternalMigrationProviders } from "./types";
import {
ExternalMigrationProviders,
TVaultExternalMigrationConfig,
VaultKubernetesAuthRole
} from "./types";
const externalMigrationQueryKeys = {
export const externalMigrationQueryKeys = {
customMigrationAvailable: (provider: ExternalMigrationProviders) => [
"custom-migration-available",
provider
],
vaultConfigs: () => ["vault-external-migration-configs"],
vaultNamespaces: () => ["vault-namespaces"],
vaultPolicies: (namespace?: string) => ["vault-policies", namespace],
vaultMounts: (namespace?: string) => ["vault-mounts", namespace],
vaultAuthMounts: (namespace?: string, authType?: string) => [
"vault-auth-mounts",
namespace,
authType
],
vaultSecretPaths: (namespace?: string, mountPath?: string) => [
"vault-secret-paths",
namespace,
mountPath
],
vaultKubernetesAuthRoles: (namespace?: string, mountPath?: string) => [
"vault-kubernetes-auth-roles",
namespace,
mountPath
]
};
@@ -20,3 +43,132 @@ export const useHasCustomMigrationAvailable = (provider: ExternalMigrationProvid
)
});
};
export const useGetVaultExternalMigrationConfigs = () => {
return useQuery({
queryKey: externalMigrationQueryKeys.vaultConfigs(),
queryFn: async () => {
const { data } = await apiRequest.get<{ configs: TVaultExternalMigrationConfig[] }>(
"/api/v3/external-migration/vault/configs"
);
return data.configs;
}
});
};
export const useGetVaultNamespaces = () => {
return useQuery({
queryKey: externalMigrationQueryKeys.vaultNamespaces(),
queryFn: async () => {
const { data } = await apiRequest.get<{
namespaces: Array<{ id: string; name: string }>;
}>("/api/v3/external-migration/vault/namespaces");
return data.namespaces;
}
});
};
export const useGetVaultPolicies = (enabled = true, namespace?: string) => {
return useQuery({
queryKey: externalMigrationQueryKeys.vaultPolicies(namespace),
queryFn: async () => {
const { data } = await apiRequest.get<{
policies: Array<{ name: string; rules: string }>;
}>("/api/v3/external-migration/vault/policies", {
params: {
namespace
}
});
return data.policies;
},
enabled
});
};
export const useGetVaultMounts = (enabled = true, namespace?: string) => {
return useQuery({
queryKey: externalMigrationQueryKeys.vaultMounts(namespace),
queryFn: async () => {
const { data } = await apiRequest.get<{
mounts: Array<{ path: string; type: string; version: string | null }>;
}>("/api/v3/external-migration/vault/mounts", {
params: {
namespace
}
});
return data.mounts;
},
enabled
});
};
export const useGetVaultSecretPaths = (enabled = true, namespace?: string, mountPath?: string) => {
return useQuery({
queryKey: externalMigrationQueryKeys.vaultSecretPaths(namespace, mountPath),
queryFn: async () => {
if (!namespace || !mountPath) {
throw new Error("Both namespace and mountPath are required");
}
const { data } = await apiRequest.get<{
secretPaths: string[];
}>("/api/v3/external-migration/vault/secret-paths", {
params: {
namespace,
mountPath
}
});
return data.secretPaths;
},
enabled: enabled && !!namespace && !!mountPath
});
};
export const useGetVaultAuthMounts = (enabled = true, namespace?: string, authType?: string) => {
return useQuery({
queryKey: externalMigrationQueryKeys.vaultAuthMounts(namespace, authType),
queryFn: async () => {
const { data } = await apiRequest.get<{
mounts: Array<{ path: string; type: string }>;
}>("/api/v3/external-migration/vault/auth-mounts", {
params: {
namespace,
...(authType && { authType })
}
});
return data.mounts;
},
enabled
});
};
export const useGetVaultKubernetesAuthRoles = (
enabled = true,
namespace?: string,
mountPath?: string
) => {
return useQuery({
queryKey: externalMigrationQueryKeys.vaultKubernetesAuthRoles(namespace, mountPath),
queryFn: async () => {
if (!namespace || !mountPath) {
throw new Error("Both namespace and mountPath are required");
}
const { data } = await apiRequest.get<{
roles: VaultKubernetesAuthRole[];
}>("/api/v3/external-migration/vault/auth-roles/kubernetes", {
params: {
namespace,
mountPath
}
});
return data.roles;
},
enabled: enabled && !!namespace && !!mountPath
});
};
+47
View File
@@ -2,3 +2,50 @@ export enum ExternalMigrationProviders {
Vault = "vault",
EnvKey = "env-key"
}
export enum VaultImportStatus {
Imported = "imported",
ApprovalRequired = "approval-required"
}
export type TVaultExternalMigrationConfig = {
id: string;
orgId: string;
namespace: string;
connectionId: string | null;
createdAt: string;
updatedAt: string;
};
export type TImportVaultSecretsDTO = {
projectId: string;
environment: string;
secretPath: string;
vaultNamespace: string;
vaultSecretPath: string;
};
export type VaultKubernetesAuthRole = {
name: string;
bound_service_account_names: string[];
bound_service_account_namespaces: string[];
token_ttl?: number;
token_max_ttl?: number;
token_policies?: string[];
token_bound_cidrs?: string[];
token_explicit_max_ttl?: number;
token_no_default_policy?: boolean;
token_num_uses?: number;
token_period?: number;
token_type?: string;
audience?: string;
alias_name_source?: string;
mountPath: string;
config: {
kubernetes_host: string;
kubernetes_ca_cert?: string;
issuer?: string;
disable_iss_validation?: boolean;
disable_local_ca_jwt?: boolean;
};
};
@@ -218,12 +218,13 @@ export const useDeleteFolder = () => {
const queryClient = useQueryClient();
return useMutation<object, object, TDeleteFolderDTO>({
mutationFn: async ({ path = "/", folderId, environment, projectId }) => {
mutationFn: async ({ path = "/", folderId, environment, projectId, forceDelete = true }) => {
const { data } = await apiRequest.delete(`/api/v2/folders/${folderId}`, {
data: {
environment,
projectId,
path
path,
forceDelete
}
});
return data;
@@ -59,6 +59,7 @@ export type TDeleteFolderDTO = {
environment: string;
folderId: string;
path?: string;
forceDelete?: boolean;
};
export type TUpdateFolderBatchDTO = {
@@ -1,6 +1,6 @@
import { useEffect, useState } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { faPlus, faXmark } from "@fortawesome/free-solid-svg-icons";
import { faInfoCircle, faPlus, faXmark } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
@@ -37,9 +37,12 @@ import {
IdentityKubernetesAuthTokenReviewMode,
IdentityTrustedIp
} from "@app/hooks/api/identities/types";
import { UsePopUpState } from "@app/hooks/usePopUp";
import { useGetVaultExternalMigrationConfigs } from "@app/hooks/api/migration/queries";
import { VaultKubernetesAuthRole } from "@app/hooks/api/migration/types";
import { usePopUp, UsePopUpState } from "@app/hooks/usePopUp";
import { IdentityFormTab } from "./types";
import { VaultKubernetesAuthImportModal } from "./VaultKubernetesAuthImportModal";
const schema = z
.object({
@@ -121,6 +124,12 @@ export const IdentityKubernetesAuthForm = ({
enabled: isUpdate
});
const { popUp, handlePopUpToggle: handleImportPopUpToggle } = usePopUp([
"importFromVault"
] as const);
const { data: vaultConfigs = [] } = useGetVaultExternalMigrationConfigs();
const hasVaultConnection = vaultConfigs.some((config) => config.connectionId);
const {
control,
handleSubmit,
@@ -192,6 +201,99 @@ export const IdentityKubernetesAuthForm = ({
}
}, [data]);
const handleImportFromVault = (role: VaultKubernetesAuthRole) => {
try {
setValue("kubernetesHost", role.config.kubernetes_host, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true
});
if (role.bound_service_account_names?.length > 0) {
// In Vault, "*" means allow all; in Infisical, empty field means allow any
const allowedNames = role.bound_service_account_names.includes("*")
? ""
: role.bound_service_account_names.join(", ");
setValue("allowedNames", allowedNames, {
shouldDirty: true,
shouldTouch: true
});
}
if (role.bound_service_account_namespaces?.length > 0) {
// In Vault, "*" means allow all; in Infisical, empty field means allow any
const allowedNamespaces = role.bound_service_account_namespaces.includes("*")
? ""
: role.bound_service_account_namespaces.join(", ");
setValue("allowedNamespaces", allowedNamespaces, {
shouldDirty: true,
shouldTouch: true
});
}
if (role.token_ttl !== undefined) {
setValue("accessTokenTTL", String(role.token_ttl), {
shouldDirty: true,
shouldTouch: true
});
}
if (role.token_max_ttl !== undefined) {
setValue("accessTokenMaxTTL", String(role.token_max_ttl), {
shouldDirty: true,
shouldTouch: true
});
}
if (role.token_num_uses !== undefined) {
setValue("accessTokenNumUsesLimit", String(role.token_num_uses), {
shouldDirty: true,
shouldTouch: true
});
}
if (role.audience) {
setValue("allowedAudience", role.audience, {
shouldDirty: true,
shouldTouch: true
});
}
if (role.config.kubernetes_ca_cert) {
setValue("caCert", role.config.kubernetes_ca_cert, {
shouldDirty: true,
shouldTouch: true
});
}
if (
subscription?.ipAllowlisting &&
role.token_bound_cidrs &&
role.token_bound_cidrs.length > 0
) {
setValue(
"accessTokenTrustedIps",
role.token_bound_cidrs.map((cidr) => ({ ipAddress: cidr })),
{
shouldDirty: true,
shouldTouch: true
}
);
}
createNotification({
type: "info",
text: `Successfully prefilled values from Kubernetes auth role: ${role.name}`
});
} catch (err) {
console.error("Import error:", err);
createNotification({
type: "error",
text: "Failed to import Kubernetes auth configuration"
});
}
};
const onFormSubmit = async ({
kubernetesHost,
tokenReviewerJwt,
@@ -301,6 +403,28 @@ export const IdentityKubernetesAuthForm = ({
<Tab value={IdentityFormTab.Advanced}>Advanced</Tab>
</TabList>
<TabPanel value={IdentityFormTab.Configuration}>
{hasVaultConnection && !isUpdate && (
<div className="mb-4 flex items-center justify-between rounded-md border border-primary/30 bg-primary/10 p-3">
<div className="flex items-start gap-2 text-sm">
<FontAwesomeIcon icon={faInfoCircle} className="mt-0.5 text-primary" />
<span className="text-mineshaft-200">Load values from HashiCorp Vault</span>
</div>
<Button
variant="outline_bg"
size="xs"
leftIcon={
<img
src="/images/integrations/Vault.png"
alt="HashiCorp Vault"
className="h-4 w-4"
/>
}
onClick={() => handleImportPopUpToggle("importFromVault", true)}
>
Load from Vault
</Button>
</div>
)}
<div className="flex w-full items-center gap-2">
<div className="w-full flex-1">
<OrgPermissionCan
@@ -407,6 +531,7 @@ export const IdentityKubernetesAuthForm = ({
placeholder="https://my-example-k8s-api-host.com"
type="text"
value={field.value || ""}
autoComplete="off"
/>
</FormControl>
)}
@@ -425,7 +550,7 @@ export const IdentityKubernetesAuthForm = ({
errorText={error?.message}
tooltipText="Optional JWT token for accessing Kubernetes TokenReview API. If provided, this long-lived token will be used to validate service account tokens during authentication. If omitted, the client's own JWT will be used instead, which requires the client to have the system:auth-delegator ClusterRole binding."
>
<Input {...field} placeholder="" type="password" />
<Input {...field} placeholder="" type="password" autoComplete="new-password" />
</FormControl>
)}
/>
@@ -441,7 +566,12 @@ export const IdentityKubernetesAuthForm = ({
errorText={error?.message}
tooltipText="A comma-separated list of trusted namespaces that service accounts must belong to authenticate with Infisical."
>
<Input {...field} placeholder="namespaceA, namespaceB" type="text" />
<Input
{...field}
placeholder="namespaceA, namespaceB"
type="text"
autoComplete="off"
/>
</FormControl>
)}
/>
@@ -456,7 +586,11 @@ export const IdentityKubernetesAuthForm = ({
tooltipText="An optional comma-separated list of trusted service account names that are allowed to authenticate with Infisical. Leave empty to allow any service account."
errorText={error?.message}
>
<Input {...field} placeholder="service-account-1-name, service-account-1-name" />
<Input
{...field}
placeholder="service-account-1-name, service-account-1-name"
autoComplete="off"
/>
</FormControl>
)}
/>
@@ -628,6 +762,11 @@ export const IdentityKubernetesAuthForm = ({
Cancel
</Button>
</div>
<VaultKubernetesAuthImportModal
isOpen={popUp.importFromVault.isOpen}
onOpenChange={(isOpen) => handleImportPopUpToggle("importFromVault", isOpen)}
onImport={handleImportFromVault}
/>
</form>
);
};
@@ -0,0 +1,200 @@
import { useEffect, useState } from "react";
import { createNotification } from "@app/components/notifications";
import {
Button,
FilterableSelect,
FormControl,
Modal,
ModalClose,
ModalContent
} from "@app/components/v2";
import {
useGetVaultAuthMounts,
useGetVaultKubernetesAuthRoles,
useGetVaultNamespaces
} from "@app/hooks/api/migration/queries";
import { VaultKubernetesAuthRole } from "@app/hooks/api/migration/types";
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
onImport: (role: VaultKubernetesAuthRole) => void;
};
type ContentProps = {
onClose: () => void;
onImport: (role: VaultKubernetesAuthRole) => void;
};
const Content = ({ onClose, onImport }: ContentProps) => {
const [selectedNamespace, setSelectedNamespace] = useState<string | null>(null);
const [selectedMountPath, setSelectedMountPath] = useState<string | null>(null);
const [selectedRole, setSelectedRole] = useState<VaultKubernetesAuthRole | null>(null);
const [shouldFetchRoles, setShouldFetchRoles] = useState(false);
const [shouldFetchMounts, setShouldFetchMounts] = useState(false);
const { data: namespaces, isLoading: isLoadingNamespaces } = useGetVaultNamespaces();
const { data: authMounts, isLoading: isLoadingMounts } = useGetVaultAuthMounts(
shouldFetchMounts,
selectedNamespace ?? undefined,
"kubernetes"
);
const { data: roles, isLoading: isLoadingRoles } = useGetVaultKubernetesAuthRoles(
shouldFetchRoles,
selectedNamespace ?? undefined,
selectedMountPath ?? undefined
);
// Enable fetching mounts when namespace is selected
useEffect(() => {
if (selectedNamespace) {
setShouldFetchMounts(true);
}
}, [selectedNamespace]);
// Enable fetching roles when both namespace and mount path are selected
useEffect(() => {
if (selectedNamespace && selectedMountPath) {
setShouldFetchRoles(true);
} else {
setShouldFetchRoles(false);
}
}, [selectedNamespace, selectedMountPath]);
const handleImportAndApply = () => {
if (!selectedRole) {
createNotification({
type: "error",
text: "Please select a Kubernetes role to load"
});
return;
}
onImport(selectedRole);
onClose();
};
return (
<>
<FormControl
label="Namespace"
className="mb-4"
tooltipText="Select the Vault namespace containing the Kubernetes auth configuration."
>
<>
<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);
setSelectedRole(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 auth mounts
</p>
</>
</FormControl>
<FormControl
label="Auth Engine"
className="mb-4"
tooltipText="Select the Kubernetes auth engine to narrow down available roles."
>
<>
<FilterableSelect
value={
selectedMountPath
? authMounts?.find((mount) => mount.path === selectedMountPath)
: null
}
onChange={(value) => {
if (value && !Array.isArray(value)) {
const mount = value as { path: string; type: string };
setSelectedMountPath(mount.path.replace(/\/$/, "")); // Remove trailing slash
setSelectedRole(null);
} else {
setSelectedMountPath(null);
}
}}
options={authMounts || []}
getOptionValue={(option) => option.path}
getOptionLabel={(option) => option.path.replace(/\/$/, "")}
isDisabled={isLoadingMounts || !authMounts?.length}
placeholder="Select auth engine..."
isClearable
className="w-full"
/>
<p className="mt-1 text-xs text-mineshaft-400">
Choose a Kubernetes auth engine to filter available roles
</p>
</>
</FormControl>
<FormControl label="Kubernetes Role" className="mb-6">
<>
<FilterableSelect
value={selectedRole}
onChange={(value) => {
if (value && !Array.isArray(value)) {
setSelectedRole(value as VaultKubernetesAuthRole);
} else {
setSelectedRole(null);
}
}}
options={roles || []}
getOptionValue={(option) => option.name}
getOptionLabel={(option) => option.name}
isDisabled={isLoadingRoles || !roles?.length || !selectedMountPath}
placeholder={
!selectedMountPath
? "Select an auth engine first..."
: "Select a Kubernetes role to load..."
}
isClearable
className="w-full"
/>
<p className="mt-1 text-xs text-mineshaft-400">
Select the Kubernetes role to load configuration from
</p>
</>
</FormControl>
<div className="mt-8 flex space-x-4">
<Button onClick={handleImportAndApply} isDisabled={!selectedRole || isLoadingRoles}>
Load
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</>
);
};
export const VaultKubernetesAuthImportModal = ({ isOpen, onOpenChange, onImport }: Props) => {
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
bodyClassName="overflow-visible"
title="Load Kubernetes Auth from HashiCorp Vault"
subTitle="Load Kubernetes authentication configuration from your Vault instance. The auth method and role settings will be automatically translated and prefilled in the form."
className="max-w-2xl"
>
<Content onClose={() => onOpenChange(false)} onImport={onImport} />
</ModalContent>
</Modal>
);
};
@@ -7,6 +7,7 @@ import { OrgMembershipRole } from "@app/helpers/roles";
import { usePopUp } from "@app/hooks";
import { SelectImportFromPlatformModal } from "./components/SelectImportFromPlatformModal";
import { VaultConnectionSection } from "./components/VaultConnectionSection";
export const ExternalMigrationsTab = () => {
const { hasOrgRole } = useOrgPermission();
@@ -14,45 +15,70 @@ export const ExternalMigrationsTab = () => {
const { popUp, handlePopUpOpen, handlePopUpToggle } = usePopUp(["selectImportPlatform"] as const);
return (
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="flex justify-between">
<div className="flex items-center gap-2">
<p className="text-xl font-medium text-mineshaft-100">Import from external source</p>
<div className="flex flex-col gap-6">
{/* In-Platform Migration Tooling Section */}
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4">
<h2 className="text-xl font-medium text-mineshaft-100">In-Platform Migration Tooling</h2>
<p className="mt-1 mb-6 text-sm text-gray-400">
Configure platform connections to enable migration features throughout Infisical, such
as importing policies and resources directly within the UI.
</p>
</div>
<VaultConnectionSection />
</div>
<div>
<a
target="_blank"
rel="noopener noreferrer"
href="https://infisical.com/docs/documentation/platform/external-migrations/overview"
>
<div className="ml-2 inline-block rounded-md bg-yellow/20 px-1.5 pt-[0.04rem] pb-[0.03rem] text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
Docs
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="text-xxs mb-[0.07rem] ml-1.5"
/>
</div>
</a>
</div>
{/* Bulk Data Import Section */}
<div className="rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4">
<h2 className="text-xl font-medium text-mineshaft-100">Bulk Data Import</h2>
<p className="mt-1 mb-6 text-sm text-gray-400">
Perform one-time bulk imports of data from external platforms.
</p>
</div>
<Button
onClick={() => {
handlePopUpOpen("selectImportPlatform");
}}
isDisabled={!hasOrgRole(OrgMembershipRole.Admin)}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
Import
</Button>
</div>
<p className="mb-4 text-gray-400">Import data from another platform to Infisical.</p>
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2">
<p className="text-base font-medium text-mineshaft-100">
Import from external source
</p>
<a
target="_blank"
rel="noopener noreferrer"
href="https://infisical.com/docs/documentation/platform/external-migrations/overview"
>
<div className="inline-block rounded-md bg-yellow/20 px-1.5 pt-[0.04rem] pb-[0.03rem] text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
Docs
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="text-xxs mb-[0.07rem] ml-1.5"
/>
</div>
</a>
</div>
<p className="mt-1 text-sm text-gray-400">
Import data from another platform to Infisical.
</p>
</div>
<SelectImportFromPlatformModal
isOpen={popUp.selectImportPlatform.isOpen}
onToggle={(state) => handlePopUpToggle("selectImportPlatform", state)}
/>
<Button
onClick={() => {
handlePopUpOpen("selectImportPlatform");
}}
isDisabled={!hasOrgRole(OrgMembershipRole.Admin)}
leftIcon={<FontAwesomeIcon icon={faPlus} />}
>
Import
</Button>
</div>
<SelectImportFromPlatformModal
isOpen={popUp.selectImportPlatform.isOpen}
onToggle={(state) => handlePopUpToggle("selectImportPlatform", state)}
/>
</div>
</div>
);
};
@@ -0,0 +1,197 @@
import { useState } from "react";
import { faEdit, faPlus, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link } from "@tanstack/react-router";
import { createNotification } from "@app/components/notifications";
import {
Button,
DeleteActionModal,
EmptyState,
Table,
TableContainer,
TableSkeleton,
TBody,
Td,
Th,
THead,
Tr
} from "@app/components/v2";
import { useListAppConnections } from "@app/hooks/api/appConnections/queries";
import {
useDeleteVaultExternalMigrationConfig,
useGetVaultExternalMigrationConfigs
} from "@app/hooks/api/migration";
import { TVaultExternalMigrationConfig } from "@app/hooks/api/migration/types";
import { VaultNamespaceConfigModal } from "./VaultNamespaceConfigModal";
export const VaultConnectionSection = () => {
const [selectedConfig, setSelectedConfig] = useState<TVaultExternalMigrationConfig | null>(null);
const [isModalOpen, setIsModalOpen] = useState(false);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const [configToDelete, setConfigToDelete] = useState<TVaultExternalMigrationConfig | null>(null);
const { data: configs = [], isPending: isLoadingConfigs } = useGetVaultExternalMigrationConfigs();
const { data: appConnections = [] } = useListAppConnections();
const { mutateAsync: deleteConfig } = useDeleteVaultExternalMigrationConfig();
const handleEdit = (config: TVaultExternalMigrationConfig) => {
setSelectedConfig(config);
setIsModalOpen(true);
};
const handleAdd = () => {
setSelectedConfig(null);
setIsModalOpen(true);
};
const handleDeleteClick = (config: TVaultExternalMigrationConfig) => {
setConfigToDelete(config);
setIsDeleteModalOpen(true);
};
const handleDeleteConfirm = async () => {
if (!configToDelete) return;
try {
await deleteConfig({ id: configToDelete.id });
createNotification({
type: "success",
text: "Namespace configuration deleted successfully"
});
setIsDeleteModalOpen(false);
setConfigToDelete(null);
} catch (error) {
console.error("Failed to delete namespace config:", error);
createNotification({
type: "error",
text: "Failed to delete namespace configuration"
});
}
};
const getConnectionName = (connectionId: string | null) => {
if (!connectionId) return "None";
const connection = appConnections.find((conn) => conn.id === connectionId);
return connection?.name || "Unknown";
};
return (
<div>
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<img
src="/images/integrations/Vault.png"
alt="HashiCorp Vault logo"
className="h-10 w-10 rounded-md bg-bunker-500 p-2"
/>
<div>
<h3 className="text-lg font-medium text-mineshaft-100">HashiCorp Vault</h3>
<p className="text-sm text-gray-400">
Enable in-platform migration tooling for policy imports, auth methods, and secret
engine migrations
</p>
</div>
</div>
<Button
colorSchema="primary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={handleAdd}
>
Add Namespace
</Button>
</div>
<TableContainer>
<Table>
<THead>
<Tr>
<Th>Namespace</Th>
<Th>Connection</Th>
<Th className="w-5" />
</Tr>
</THead>
<TBody>
{isLoadingConfigs && (
<TableSkeleton columns={3} innerKey="vault-configs-loading" rows={3} />
)}
{!isLoadingConfigs && configs.length === 0 && (
<Tr>
<Td colSpan={3}>
<EmptyState title="No namespace configurations" icon={faPlus} className="py-8">
<p className="mb-4 text-sm text-mineshaft-400">
Add a namespace configuration to enable in-platform migration features.
</p>
</EmptyState>
</Td>
</Tr>
)}
{!isLoadingConfigs &&
configs.map((config) => (
<Tr key={config.id} className="group h-10">
<Td>{config.namespace}</Td>
<Td>{getConnectionName(config.connectionId)}</Td>
<Td>
<div className="flex items-center justify-end gap-2 opacity-0 transition-opacity group-hover:opacity-100">
<Button
variant="plain"
colorSchema="secondary"
size="xs"
onClick={() => handleEdit(config)}
leftIcon={<FontAwesomeIcon icon={faEdit} />}
>
Edit
</Button>
<Button
variant="plain"
colorSchema="danger"
size="xs"
onClick={() => handleDeleteClick(config)}
leftIcon={<FontAwesomeIcon icon={faTrash} />}
>
Delete
</Button>
</div>
</Td>
</Tr>
))}
</TBody>
</Table>
</TableContainer>
<p className="mt-4 text-xs text-mineshaft-400">
Configure namespace-specific connections to enable in-platform migration features. Manage
connections in the{" "}
<Link
to="/organization/app-connections"
className="text-primary underline hover:text-primary-300"
>
App Connections
</Link>{" "}
section.
</p>
<VaultNamespaceConfigModal
isOpen={isModalOpen}
onOpenChange={(open) => {
setIsModalOpen(open);
if (!open) setSelectedConfig(null);
}}
editConfig={selectedConfig || undefined}
/>
<DeleteActionModal
isOpen={isDeleteModalOpen}
title={`Delete namespace configuration for "${configToDelete?.namespace}"?`}
onChange={(open) => {
setIsDeleteModalOpen(open);
if (!open) setConfigToDelete(null);
}}
deleteKey="confirm"
onDeleteApproved={handleDeleteConfirm}
/>
</div>
);
};
@@ -0,0 +1,189 @@
import { useEffect, useMemo } from "react";
import { Controller, useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
import {
Button,
FilterableSelect,
FormControl,
Input,
Modal,
ModalContent
} from "@app/components/v2";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { useListAppConnections } from "@app/hooks/api/appConnections/queries";
import {
useCreateVaultExternalMigrationConfig,
useUpdateVaultExternalMigrationConfig
} from "@app/hooks/api/migration";
import { TVaultExternalMigrationConfig } from "@app/hooks/api/migration/types";
const schema = z.object({
namespace: z
.string()
.min(1, "Namespace is required. If you intend to use the root namespace, use root or /."),
connectionId: z.string().min(1, "Connection is required")
});
type FormData = z.infer<typeof schema>;
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
editConfig?: TVaultExternalMigrationConfig;
};
export const VaultNamespaceConfigModal = ({ isOpen, onOpenChange, editConfig }: Props) => {
const isEdit = Boolean(editConfig);
const { data: appConnections = [], isPending: isLoadingConnections } = useListAppConnections();
const vaultConnections = useMemo(
() => appConnections.filter((conn) => conn.app === AppConnection.HCVault),
[appConnections]
);
const { mutateAsync: createConfig, isPending: isCreating } =
useCreateVaultExternalMigrationConfig();
const { mutateAsync: updateConfig, isPending: isUpdating } =
useUpdateVaultExternalMigrationConfig();
const {
control,
handleSubmit,
reset,
formState: { errors, isSubmitting }
} = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
namespace: "",
connectionId: ""
}
});
// Reset form when editConfig changes or modal opens
useEffect(() => {
if (isOpen) {
reset({
namespace: editConfig?.namespace || "",
connectionId: editConfig?.connectionId || ""
});
}
}, [isOpen, editConfig, reset]);
const onFormSubmit = async (data: FormData) => {
try {
if (isEdit && editConfig) {
await updateConfig({
id: editConfig.id,
namespace: data.namespace,
connectionId: data.connectionId
});
createNotification({
type: "success",
text: "Namespace configuration updated successfully"
});
} else {
await createConfig({
namespace: data.namespace,
connectionId: data.connectionId
});
createNotification({
type: "success",
text: "Namespace configuration created successfully"
});
}
reset();
onOpenChange(false);
} catch (error) {
console.error("Failed to save namespace config:", error);
createNotification({
type: "error",
text: `Failed to ${isEdit ? "update" : "create"} namespace configuration`
});
}
};
const handleClose = () => {
reset();
onOpenChange(false);
};
return (
<Modal isOpen={isOpen} onOpenChange={handleClose}>
<ModalContent
title={isEdit ? "Edit Namespace Configuration" : "Add Namespace Configuration"}
subTitle={`Configure a HashiCorp Vault namespace ${isEdit ? "configuration" : "for migration tooling"}`}
bodyClassName="overflow-visible"
>
<form onSubmit={handleSubmit(onFormSubmit)}>
<Controller
control={control}
name="namespace"
render={({ field }) => (
<FormControl
label="Namespace"
isError={Boolean(errors.namespace)}
errorText={errors.namespace?.message}
className="mb-4"
>
<Input {...field} placeholder="e.g., admin, dev, prod" autoComplete="off" />
</FormControl>
)}
/>
<Controller
control={control}
name="connectionId"
render={({ field }) => {
const selectedConnection = vaultConnections.find((conn) => conn.id === field.value);
return (
<FormControl
label="Vault Connection"
isError={Boolean(errors.connectionId)}
errorText={errors.connectionId?.message}
tooltipText="Select a HashiCorp Vault app connection for this namespace"
>
<FilterableSelect
value={selectedConnection || null}
onChange={(newValue) => {
const singleValue = Array.isArray(newValue) ? newValue[0] : newValue;
if (singleValue && "id" in singleValue) {
field.onChange(singleValue.id);
} else {
field.onChange("");
}
}}
isLoading={isLoadingConnections}
options={vaultConnections}
placeholder="Select connection..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id}
/>
</FormControl>
);
}}
/>
<div className="mt-8 flex items-center gap-2">
<Button
className="mr-4"
size="sm"
type="submit"
isLoading={isSubmitting || isCreating || isUpdating}
isDisabled={isSubmitting || isCreating || isUpdating}
>
{isEdit ? "Update" : "Create"}
</Button>
<Button colorSchema="secondary" variant="plain" onClick={handleClose}>
Cancel
</Button>
</div>
</form>
</ModalContent>
</Modal>
);
};
@@ -6,12 +6,17 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuTrigger,
IconButton
IconButton,
Tooltip
} from "@app/components/v2";
import { useOrgPermission } from "@app/context";
import { OrgMembershipRole } from "@app/helpers/roles";
import { usePopUp } from "@app/hooks";
import { useGetVaultExternalMigrationConfigs } from "@app/hooks/api/migration";
import { ProjectType } from "@app/hooks/api/projects/types";
import { PolicySelectionModal } from "@app/pages/project/RoleDetailsBySlugPage/components/PolicySelectionModal";
import { PolicyTemplateModal } from "@app/pages/project/RoleDetailsBySlugPage/components/PolicyTemplateModal";
import { VaultPolicyImportModal } from "@app/pages/project/RoleDetailsBySlugPage/components/VaultPolicyImportModal";
type Props = {
isDisabled?: boolean;
@@ -22,9 +27,16 @@ export const AddPoliciesButton = ({ isDisabled, projectType }: Props) => {
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([
"addPolicy",
"addPolicyOptions",
"applyTemplate"
"applyTemplate",
"importFromVault"
] as const);
const { hasOrgRole } = useOrgPermission();
const { data: vaultConfigs = [] } = useGetVaultExternalMigrationConfigs();
const hasVaultConnection = vaultConfigs.some((config) => config.connectionId);
const isOrgAdmin = hasOrgRole(OrgMembershipRole.Admin);
const isVaultImportDisabled = isDisabled || !isOrgAdmin;
return (
<div>
<Button
@@ -64,6 +76,35 @@ export const AddPoliciesButton = ({ isDisabled, projectType }: Props) => {
>
Add From Template
</Button>
{hasVaultConnection && (
<Tooltip
content={
!isOrgAdmin
? "Only organization admins can import policies from HashiCorp Vault"
: undefined
}
>
<Button
leftIcon={
<img
src="/images/integrations/Vault.png"
alt="HashiCorp Vault"
className="h-4 w-4"
/>
}
onClick={() => {
handlePopUpOpen("importFromVault");
handlePopUpClose("addPolicyOptions");
}}
isDisabled={isVaultImportDisabled}
variant="outline_bg"
className="h-10 text-left"
isFullWidth
>
Add from HashiCorp Vault
</Button>
</Tooltip>
)}
</div>
</DropdownMenuContent>
</DropdownMenu>
@@ -77,6 +118,10 @@ export const AddPoliciesButton = ({ isDisabled, projectType }: Props) => {
isOpen={popUp.applyTemplate.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("applyTemplate", isOpen)}
/>
<VaultPolicyImportModal
isOpen={popUp.importFromVault.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("importFromVault", isOpen)}
/>
</div>
);
};
@@ -0,0 +1,265 @@
import { useEffect, useState } from "react";
import { useFormContext } from "react-hook-form";
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,
TextArea
} from "@app/components/v2";
import { ProjectPermissionSub } from "@app/context";
import {
useGetVaultMounts,
useGetVaultNamespaces,
useGetVaultPolicies
} from "@app/hooks/api/migration/queries";
import { TFormSchema } from "./ProjectRoleModifySection.utils";
import { parseVaultPolicyToInfisical } from "./VaultPolicyImportModal.utils";
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
};
type ContentProps = {
onClose: () => void;
};
const Content = ({ onClose }: ContentProps) => {
const rootForm = useFormContext<TFormSchema>();
const [selectedNamespace, setSelectedNamespace] = useState<string | null>(null);
const [selectedPolicy, setSelectedPolicy] = useState<string | null>(null);
const [hclPolicy, setHclPolicy] = useState<string>("");
const [shouldFetchPolicies, setShouldFetchPolicies] = useState(false);
const [shouldFetchMounts, setShouldFetchMounts] = useState(false);
const { data: namespaces, isLoading: isLoadingNamespaces } = useGetVaultNamespaces();
const { data: policies, isLoading: isLoadingPolicies } = useGetVaultPolicies(
shouldFetchPolicies,
selectedNamespace ?? undefined
);
const { data: mounts, isLoading: isLoadingMounts } = useGetVaultMounts(
shouldFetchMounts,
selectedNamespace ?? undefined
);
// Enable fetching policies and mounts when namespace is selected
useEffect(() => {
if (selectedNamespace) {
setShouldFetchPolicies(true);
setShouldFetchMounts(true);
}
}, [selectedNamespace]);
// Auto-populate HCL when a policy is selected
useEffect(() => {
if (selectedPolicy && policies) {
const policy = policies.find((p) => p.name === selectedPolicy);
if (policy) {
setHclPolicy(policy.rules);
}
}
}, [selectedPolicy, policies]);
const handleTranslateAndApply = () => {
if (!hclPolicy.trim()) {
createNotification({ type: "error", text: "Please provide a Vault HCL policy" });
return;
}
if (!mounts || mounts.length === 0) {
createNotification({
type: "error",
text: "No Vault mounts found. Please ensure you have KV secret engines configured."
});
return;
}
try {
const parsedPermissions = parseVaultPolicyToInfisical(hclPolicy, mounts);
if (!parsedPermissions || Object.keys(parsedPermissions).length === 0) {
createNotification({
type: "warning",
text: "No translatable permissions found in the policy. Ensure the policy contains KV secret paths (e.g., secret/data/*, secret/metadata/*)."
});
return;
}
// Apply the parsed permissions to the form
(Object.keys(parsedPermissions) as ProjectPermissionSub[]).forEach((subjectKey) => {
const value = parsedPermissions[subjectKey];
if (!value) return;
const existingValue = rootForm.getValues(`permissions.${subjectKey}`) as unknown[];
if (Array.isArray(existingValue) && existingValue.length > 0) {
// Merge with existing permissions
rootForm.setValue(`permissions.${subjectKey}`, [...existingValue, ...value] as never, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true
});
} else {
rootForm.setValue(`permissions.${subjectKey}`, value as never, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true
});
}
});
createNotification({
type: "info",
text: "Vault policy translated and prefilled"
});
onClose();
} catch (err) {
console.error("Translation error:", err);
createNotification({
type: "error",
text: "Failed to translate policy. Please check the HCL format."
});
}
};
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>How Policy Translation Works</strong>
</div>
<div className="space-y-1.5 text-xs leading-relaxed">
<p>
Policies are translated by identifying KV secret engine mounts and parsing path
structures to extract environments and secret paths.
</p>
<p>
<strong>Key assumptions:</strong> The first path segment after the mount is treated
as the environment (e.g., <code className="text-xs">secret/data/prod/app</code> →
env: <code className="text-xs">prod</code>, path:{" "}
<code className="text-xs">/app</code>). Vault capabilities and wildcards are
automatically mapped to equivalent Infisical permissions and glob patterns.
</p>
</div>
</div>
</div>
</div>
<FormControl
label="Namespace"
className="mb-4"
tooltipText="Required to fetch mount information. Policies will be translated using your Vault's KV secret engine mounts to extract environments and secret paths."
>
<>
<FilterableSelect
value={namespaces?.find((ns) => ns.id === selectedNamespace)}
onChange={(value) => {
if (value && !Array.isArray(value)) {
const namespace = value as { id: string; name: string };
setSelectedNamespace(namespace.name);
setSelectedPolicy(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 policies and mount information
</p>
</>
</FormControl>
<FormControl label="Select Vault Policy (Optional)" className="mb-4">
<>
<FilterableSelect
value={selectedPolicy ? policies?.find((p) => p.name === selectedPolicy) : null}
onChange={(value) => {
if (value && !Array.isArray(value)) {
const policy = value as { name: string; rules: string };
setSelectedPolicy(policy.name);
} else {
setSelectedPolicy(null);
}
}}
options={policies || []}
getOptionValue={(option) => option.name}
getOptionLabel={(option) => option.name}
isDisabled={isLoadingPolicies}
placeholder="Choose a policy to import..."
isClearable
className="w-full"
/>
<p className="mt-1 text-xs text-mineshaft-400">
Select a policy to auto-populate the HCL editor below, or skip to paste your own
</p>
</>
</FormControl>
<FormControl label="Vault HCL Policy" className="mb-6">
<>
<TextArea
value={hclPolicy}
onChange={(e) => setHclPolicy(e.target.value)}
placeholder={`path "secret/data/prod/app/*" {
capabilities = ["create", "read", "update", "delete"]
}
path "secret/metadata/prod/*" {
capabilities = ["list"]
}`}
rows={12}
className="font-mono text-sm"
/>
<p className="mt-1 text-xs text-mineshaft-400">
Paste your HCL policy here or select one from the dropdown above. The translator will
extract environments and paths automatically.
</p>
</>
</FormControl>
<div className="mt-8 flex space-x-4">
<Button
onClick={handleTranslateAndApply}
isDisabled={!hclPolicy.trim() || isLoadingMounts || !mounts}
>
Translate & Apply
</Button>
<ModalClose asChild>
<Button colorSchema="secondary" variant="plain">
Cancel
</Button>
</ModalClose>
</div>
</>
);
};
export const VaultPolicyImportModal = ({ isOpen, onOpenChange }: Props) => {
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
title="Import from HashiCorp Vault"
subTitle="Select a policy from your Vault namespace or paste your own HCL policy to translate it into Infisical permissions."
className="max-w-3xl"
>
<Content onClose={() => onOpenChange(false)} />
</ModalContent>
</Modal>
);
};
@@ -0,0 +1,449 @@
import {
PermissionConditionOperators,
ProjectPermissionActions,
ProjectPermissionSecretActions,
ProjectPermissionSub
} from "@app/context/ProjectPermissionContext/types";
import { TFormSchema } from "./ProjectRoleModifySection.utils";
// ============================================================================
// Types
// ============================================================================
export type VaultMount = {
path: string;
type: string;
version: string | null;
};
type ArrayElement<T> = T extends (infer U)[] ? U : never;
export type SecretPermissionRule = ArrayElement<
NonNullable<TFormSchema["permissions"]>[ProjectPermissionSub.Secrets]
>;
export type FolderPermissionRule = ArrayElement<
NonNullable<TFormSchema["permissions"]>[ProjectPermissionSub.SecretFolders]
>;
type ParsedVaultPath = {
environment: string | null;
secretPath: string | null;
mount: VaultMount | null;
isWildcardMount: boolean;
};
// ============================================================================
// Path Parsing
// ============================================================================
/**
* Parses a Vault policy path to extract mount, environment, and secret path.
*
* Handles three types of path patterns:
* 1. Global wildcards: "*" or "+" → matches all mounts, environments, paths
* 2. Wildcard mounts: "* /data/prod/*" → matches all mounts with specific path
* 3. Regular paths: "secret/data/prod/api-keys" → specific mount and path
*
* For KV v2 mounts:
* - data/ paths → secret operations (read, write values)
* - metadata/ paths → folder operations (create, delete folders)
*
* Path structure after mount:
* - KV v2: [data|metadata]/{environment}/{secretPath}
* - KV v1: {environment}/{secretPath}
*/
export const parseVaultPath = (vaultPath: string, mounts: VaultMount[]): ParsedVaultPath => {
// Case 1: Global wildcard (e.g., "*" or "+") - matches everything
if (vaultPath === "*" || vaultPath === "+") {
const syntheticMount: VaultMount = {
path: "*",
type: "kv",
version: "1" // Default to v1 for global wildcards
};
return {
environment: "*",
secretPath: "/*",
mount: syntheticMount,
isWildcardMount: true
};
}
// Case 2: Wildcard mount (e.g., "*/data/*") - matches any mount with pattern
const isWildcardMount = vaultPath.startsWith("*/") || vaultPath.startsWith("+/");
if (isWildcardMount) {
let remainingPath = vaultPath.slice(2); // Remove "*/" or "+/"
if (remainingPath.startsWith("/")) remainingPath = remainingPath.slice(1);
let environment: string | null = null;
let secretPath: string | null = null;
let isDataPath = false;
let isMetadataPath = false;
// Check for KV v2 data/ or metadata/ prefix
if (remainingPath.startsWith("data/")) {
isDataPath = true;
remainingPath = remainingPath.slice(5);
} else if (remainingPath.startsWith("metadata/")) {
isMetadataPath = true;
remainingPath = remainingPath.slice(9);
}
// Parse remaining segments
const segments = remainingPath.split("/").filter(Boolean);
if (segments.length > 0) {
if (segments.length === 1 && (segments[0] === "*" || segments[0] === "+")) {
environment = "*";
secretPath = "/*";
} else {
[environment] = segments;
secretPath = segments.length > 1 ? `/${segments.slice(1).join("/")}` : "/";
}
}
// Create synthetic mount based on detected version
const syntheticMount: VaultMount = {
path: "*",
type: "kv",
version: isDataPath || isMetadataPath ? "2" : "1"
};
return { environment, secretPath, mount: syntheticMount, isWildcardMount: true };
}
// Case 3: Regular path (e.g., "secret/data/prod/api-keys")
// Find matching mount (longest path first for most specific match)
const sortedMounts = [...mounts].sort((a, b) => b.path.length - a.path.length);
const mount = sortedMounts.find((m) => vaultPath.startsWith(m.path));
if (!mount) {
return { environment: null, secretPath: null, mount: null, isWildcardMount: false };
}
// Remove mount prefix
let remainingPath = vaultPath.slice(mount.path.length);
if (remainingPath.startsWith("/")) remainingPath = remainingPath.slice(1);
const isKvV2 = mount.version === "2" || mount.type === "kv";
// For KV v2, remove data/ or metadata/ prefix
if (isKvV2) {
if (remainingPath.startsWith("data/")) {
remainingPath = remainingPath.slice(5);
} else if (remainingPath.startsWith("metadata/")) {
remainingPath = remainingPath.slice(9);
}
}
// Parse environment and secret path
const segments = remainingPath.split("/").filter(Boolean);
let environment: string | null = null;
let secretPath: string | null = null;
if (segments.length > 0) {
if (segments.length === 1 && (segments[0] === "*" || segments[0] === "+")) {
// Single wildcard segment
environment = null;
secretPath = "/*";
} else {
// First segment is the environment
[environment] = segments;
// Remaining segments form the secret path
secretPath = segments.length > 1 ? `/${segments.slice(1).join("/")}` : "/";
}
}
return { environment, secretPath, mount, isWildcardMount: false };
};
// ============================================================================
// Capability Mapping
// ============================================================================
/**
* Maps Vault capabilities to Infisical secret actions.
*
* Mapping:
* - create → Create
* - list → DescribeSecret (view metadata without values)
* - read → DescribeSecret + ReadValue (full access)
* - update/patch → Edit
* - delete → Delete
*/
const mapVaultCapabilitiesToSecretActions = (capabilities: string[]): Record<string, boolean> => {
const actions: Record<string, boolean> = {};
if (capabilities.includes("create")) {
actions[ProjectPermissionSecretActions.Create] = true;
}
if (capabilities.includes("list")) {
actions[ProjectPermissionSecretActions.DescribeSecret] = true;
}
if (capabilities.includes("read")) {
actions[ProjectPermissionSecretActions.DescribeSecret] = true;
actions[ProjectPermissionSecretActions.ReadValue] = true;
}
if (capabilities.includes("update") || capabilities.includes("patch")) {
actions[ProjectPermissionSecretActions.Edit] = true;
}
if (capabilities.includes("delete")) {
actions[ProjectPermissionSecretActions.Delete] = true;
}
return actions;
};
/**
* Maps Vault capabilities to Infisical folder actions.
*
* Mapping:
* - create → Create
* - update/patch → Edit
* - delete → Delete
*
* Note: 'list' is not mapped for folders as it's handled at the secret level
*/
const mapVaultCapabilitiesToFolderActions = (capabilities: string[]): Record<string, boolean> => {
const actions: Record<string, boolean> = {};
if (capabilities.includes("create")) {
actions[ProjectPermissionActions.Create] = true;
}
if (capabilities.includes("update") || capabilities.includes("patch")) {
actions[ProjectPermissionActions.Edit] = true;
}
if (capabilities.includes("delete")) {
actions[ProjectPermissionActions.Delete] = true;
}
return actions;
};
// ============================================================================
// Condition Building
// ============================================================================
type PermissionCondition = {
lhs: string;
operator: string;
rhs: string;
};
/**
* Converts Vault wildcard patterns to Infisical glob patterns.
* - Vault '+' → picomatch '*' (matches single segment)
* - Vault '*' → picomatch '**' (matches any depth)
*/
const convertVaultWildcardToGlob = (vaultPattern: string): string => {
// Use a placeholder to avoid replacing + twice
// Step 1: Replace + with a placeholder
let result = vaultPattern.replace(/\+/g, "__PLUS__");
// Step 2: Replace * with **
result = result.replace(/\*/g, "**");
// Step 3: Replace placeholder with *
result = result.replace(/__PLUS__/g, "*");
return result;
};
/**
* Builds permission conditions for environment and secret path filtering.
* Returns empty array if no restrictions are needed (matches everything).
*/
const buildConditions = (
environment: string | null,
secretPath: string | null
): PermissionCondition[] => {
const conditions: PermissionCondition[] = [];
// Add environment condition if present and not matching everything
if (environment) {
const globEnv = convertVaultWildcardToGlob(environment);
// Skip if matches everything (Vault * becomes **)
if (globEnv !== "**") {
const hasWildcard = globEnv.includes("*");
conditions.push({
lhs: "environment",
operator: hasWildcard
? PermissionConditionOperators.$GLOB
: PermissionConditionOperators.$EQ,
rhs: globEnv
});
}
}
// Add secret path condition if present and not matching everything
if (secretPath && secretPath !== "/*") {
const globPath = convertVaultWildcardToGlob(secretPath);
// After conversion, /* becomes /** which matches everything
if (globPath !== "/**") {
const hasWildcard = globPath.includes("*");
conditions.push({
lhs: "secretPath",
operator: hasWildcard
? PermissionConditionOperators.$GLOB
: PermissionConditionOperators.$EQ,
rhs: globPath
});
}
}
return conditions;
};
// ============================================================================
// Rule Deduplication
// ============================================================================
/**
* Creates a unique key for deduplication of permission rules.
* Combines all actions and conditions into a single string identifier.
*/
const createPermissionRuleKey = (rule: SecretPermissionRule | FolderPermissionRule): string => {
const actions = Object.entries(rule)
.filter(([key]) => key !== "conditions")
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, value]) => `${key}:${value}`)
.join("|");
const conditions = (rule.conditions || [])
.map((c) => `${c.lhs}${c.operator}${c.rhs}`)
.sort()
.join("|");
return `${actions}::${conditions}`;
};
/**
* Adds a permission rule to the list if it's not a duplicate.
*/
const addPermissionRuleIfUnique = <T extends SecretPermissionRule | FolderPermissionRule>(
rule: T,
rulesList: T[],
seenRules: Set<string>
): void => {
const ruleKey = createPermissionRuleKey(rule);
if (!seenRules.has(ruleKey)) {
seenRules.add(ruleKey);
rulesList.push(rule);
}
};
// ============================================================================
// Main Parser
// ============================================================================
/**
* Parses Vault HCL policy and converts it to Infisical permissions.
*
* Process:
* 1. Clean HCL (remove comments, whitespace)
* 2. Extract path blocks with regex
* 3. For each path:
* - Parse to extract mount, environment, and secret path
* - Determine if it's a data path (secrets) or metadata path (folders)
* - Map Vault capabilities to Infisical actions
* - Build conditions for environment and path filtering
* - Create permission rule and add if unique
*
* @param hclPolicy - Raw Vault HCL policy string
* @param mounts - List of Vault mounts to match paths against
* @returns Parsed permissions object ready for Infisical role creation
*/
export const parseVaultPolicyToInfisical = (
hclPolicy: string,
mounts: VaultMount[]
): Partial<TFormSchema["permissions"]> => {
const secretsPermissions: SecretPermissionRule[] = [];
const foldersPermissions: FolderPermissionRule[] = [];
const seenSecretRules = new Set<string>();
const seenFolderRules = new Set<string>();
try {
// Step 1: Clean HCL policy - remove comments and extra whitespace
const cleanedPolicy = hclPolicy
.split("\n")
.map((line) => line.replace(/#.*$/, "").trim())
.filter((line) => line.length > 0)
.join(" ");
// Step 2: Extract path blocks using regex
const pathRegex = /path\s+"([^"]+)"\s*\{[^}]*capabilities\s*=\s*\[([^\]]+)\][^}]*\}/gi;
let match = pathRegex.exec(cleanedPolicy);
// Step 3: Process each path block
while (match !== null) {
const [, path, capabilitiesStr] = match;
// Parse capabilities list
const capabilities = capabilitiesStr
.split(",")
.map((c) => c.trim().replace(/["'\s]/g, ""))
.filter((c) => c.length > 0);
// Parse the Vault path
const { environment, secretPath, mount } = parseVaultPath(path, mounts);
// Only process KV (Key-Value) secret engines
if (mount && (mount.type === "kv" || mount.type === "generic")) {
const isKvV2 = mount.version === "2";
const isMetadata = isKvV2 && path.includes("/metadata/");
if (isMetadata) {
// Metadata paths → Folder permissions only (KV v2 metadata endpoint)
const actions = mapVaultCapabilitiesToFolderActions(capabilities);
if (Object.keys(actions).length > 0) {
const conditions = buildConditions(environment, secretPath);
addPermissionRuleIfUnique(
{ ...actions, conditions },
foldersPermissions,
seenFolderRules
);
}
} else {
// Data paths → Both secret AND folder permissions (KV v1 and v2 data paths)
// Users need both to fully manage secrets and their containing folders
const conditions = buildConditions(environment, secretPath);
// Create secret permissions
const secretActions = mapVaultCapabilitiesToSecretActions(capabilities);
if (Object.keys(secretActions).length > 0) {
addPermissionRuleIfUnique(
{ ...secretActions, conditions },
secretsPermissions,
seenSecretRules
);
}
// Create folder permissions for create/update/delete capabilities
const folderActions = mapVaultCapabilitiesToFolderActions(capabilities);
if (Object.keys(folderActions).length > 0) {
addPermissionRuleIfUnique(
{ ...folderActions, conditions },
foldersPermissions,
seenFolderRules
);
}
}
}
match = pathRegex.exec(cleanedPolicy);
}
} catch (err) {
console.error("Error parsing HCL policy:", err);
}
// Build final permissions object
const permissions: Partial<TFormSchema["permissions"]> = {};
if (secretsPermissions.length > 0) {
permissions[ProjectPermissionSub.Secrets] = secretsPermissions;
}
if (foldersPermissions.length > 0) {
permissions[ProjectPermissionSub.SecretFolders] = foldersPermissions;
}
return permissions;
};
@@ -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}
/>
</>
);
};
@@ -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>
);
};