feat: in-platform migration tooling for Vault policies + scaffolding

This commit is contained in:
Sheen Capadngan
2025-10-14 03:43:24 +08:00
parent fc6e364c60
commit c7d9df419e
21 changed files with 1525 additions and 62 deletions
@@ -1,2 +1,3 @@
export * from "./mutations";
export * from "./queries";
export * from "./types";
@@ -3,6 +3,8 @@ import { useMutation, useQueryClient } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { projectKeys } from "../projects";
import { externalMigrationQueryKeys } from "./queries";
import { ExternalMigrationProviders, TExternalMigrationConfig } from "./types";
export const useImportEnvKey = () => {
const queryClient = useQueryClient();
@@ -65,3 +67,26 @@ export const useImportVault = () => {
}
});
};
export const useUpdateExternalMigrationConfig = (platform: ExternalMigrationProviders) => {
const queryClient = useQueryClient();
return useMutation<TExternalMigrationConfig, Error, { connectionId: string | null }>({
mutationFn: async ({ connectionId }: { connectionId: string | null }) => {
const { data } = await apiRequest.put<{ config: TExternalMigrationConfig }>(
"/api/v3/external-migration/config",
{
connectionId,
platform
}
);
return data.config;
},
onSuccess: () => {
queryClient.invalidateQueries({
queryKey: externalMigrationQueryKeys.config(platform)
});
}
});
};
+71 -3
View File
@@ -2,13 +2,17 @@ import { useQuery } from "@tanstack/react-query";
import { apiRequest } from "@app/config/request";
import { ExternalMigrationProviders } from "./types";
import { ExternalMigrationProviders, TExternalMigrationConfig } from "./types";
const externalMigrationQueryKeys = {
export const externalMigrationQueryKeys = {
customMigrationAvailable: (provider: ExternalMigrationProviders) => [
"custom-migration-available",
provider
]
],
config: (platform: string) => ["external-migration-config", { platform }],
vaultNamespaces: () => ["vault-namespaces"],
vaultPolicies: () => ["vault-policies"],
vaultMounts: () => ["vault-mounts"]
};
export const useHasCustomMigrationAvailable = (provider: ExternalMigrationProviders) => {
@@ -20,3 +24,67 @@ export const useHasCustomMigrationAvailable = (provider: ExternalMigrationProvid
)
});
};
export const useGetExternalMigrationConfig = (platform: string) => {
return useQuery({
queryKey: externalMigrationQueryKeys.config(platform),
queryFn: async () => {
const { data } = await apiRequest.get<{ config: TExternalMigrationConfig | null }>(
"/api/v3/external-migration/config",
{
params: { platform }
}
);
return data.config;
},
enabled: Boolean(platform)
});
};
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(),
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(),
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
});
};
@@ -2,3 +2,12 @@ export enum ExternalMigrationProviders {
Vault = "vault",
EnvKey = "env-key"
}
export type TExternalMigrationConfig = {
id: string;
orgId: string;
platform: string;
connectionId: string | null;
createdAt: string;
updatedAt: string;
};
@@ -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,72 @@ 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="border-mineshaft-600 bg-mineshaft-900 rounded-lg border p-4">
<div className="mb-4">
<h2 className="text-mineshaft-100 text-xl font-semibold">
In-Platform Migration Tooling
</h2>
<p className="mb-6 mt-1 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="border-mineshaft-600 bg-mineshaft-900 rounded-lg border p-4">
<div className="mb-4">
<h2 className="text-mineshaft-100 text-xl font-semibold">Bulk Data Import</h2>
<p className="mb-6 mt-1 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-mineshaft-100 text-base font-medium">
Import from external source
</p>
<a
target="_blank"
rel="noopener noreferrer"
href="https://infisical.com/docs/documentation/platform/external-migrations/overview"
>
<div className="bg-yellow/20 text-yellow inline-block rounded-md px-1.5 pb-[0.03rem] pt-[0.04rem] text-sm 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,100 @@
import { useMemo } from "react";
import { createNotification } from "@app/components/notifications";
import { FilterableSelect, FormControl } from "@app/components/v2";
import { AppConnection } from "@app/hooks/api/appConnections/enums";
import { useListAppConnections } from "@app/hooks/api/appConnections/queries";
import {
useGetExternalMigrationConfig,
useUpdateExternalMigrationConfig
} from "@app/hooks/api/migration";
import { ExternalMigrationProviders } from "@app/hooks/api/migration/types";
export const VaultConnectionSection = () => {
const { data: appConnections = [], isPending: isLoadingConnections } = useListAppConnections();
const vaultConnections = useMemo(
() => appConnections.filter((conn) => conn.app === AppConnection.HCVault),
[appConnections]
);
const { data: currentConfig, isPending: isLoadingConfig } = useGetExternalMigrationConfig(
ExternalMigrationProviders.Vault
);
const { mutateAsync: updateConfig, isPending: isUpdating } = useUpdateExternalMigrationConfig(
ExternalMigrationProviders.Vault
);
const handleConnectionChange = async (
selectedConnection: { id: string; name: string } | null
) => {
try {
await updateConfig({
connectionId: selectedConnection?.id || null
});
createNotification({
type: "success",
text: "Vault connection updated successfully"
});
} catch (error) {
console.error("Failed to update vault connection:", error);
createNotification({
type: "error",
text: "Failed to update vault connection"
});
}
};
const selectedConnection = useMemo(() => {
if (!currentConfig?.connectionId) return null;
return vaultConnections.find((conn) => conn.id === currentConfig.connectionId) || null;
}, [currentConfig?.connectionId, vaultConnections]);
const isLoading = isLoadingConnections || isLoadingConfig;
return (
<div>
<div className="mb-4 flex items-center gap-3">
<img
src="/images/integrations/Vault.png"
alt="HashiCorp Vault logo"
className="bg-bunker-500 h-10 w-10 rounded-md p-2"
/>
<div>
<h3 className="text-mineshaft-100 text-lg font-medium">HashiCorp Vault</h3>
<p className="text-sm text-gray-400">
Enable in-platform migration tooling for policy imports and secret engine migrations
</p>
</div>
</div>
<div className="max-w-md">
<FormControl
label="HashiCorp Vault Connection"
tooltipText="Select an existing App Connection to enable in-platform migration features. Manage connections in the App Connections section."
>
<FilterableSelect
value={selectedConnection}
onChange={(newValue) => {
handleConnectionChange(newValue as { id: string; name: string } | null);
}}
isLoading={isLoading}
isDisabled={isUpdating}
options={vaultConnections}
placeholder="Select connection..."
getOptionLabel={(option) => option.name}
getOptionValue={(option) => option.id}
isClearable
/>
</FormControl>
</div>
<p className="text-mineshaft-400 mt-2 text-xs">
Select an existing App Connection to enable in-platform migration features. Manage
connections in the App Connections section.
</p>
</div>
);
};
@@ -6,12 +6,18 @@ 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 { useGetExternalMigrationConfig } from "@app/hooks/api/migration";
import { ExternalMigrationProviders } from "@app/hooks/api/migration/types";
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 +28,17 @@ export const AddPoliciesButton = ({ isDisabled, projectType }: Props) => {
const { popUp, handlePopUpToggle, handlePopUpOpen, handlePopUpClose } = usePopUp([
"addPolicy",
"addPolicyOptions",
"applyTemplate"
"applyTemplate",
"importFromVault"
] as const);
const { hasOrgRole } = useOrgPermission();
const { data: vaultConfig } = useGetExternalMigrationConfig(ExternalMigrationProviders.Vault);
const hasVaultConnection = Boolean(vaultConfig?.connectionId);
const isOrgAdmin = hasOrgRole(OrgMembershipRole.Admin);
const isVaultImportDisabled = isDisabled || !isOrgAdmin;
return (
<div>
<Button
@@ -44,7 +58,7 @@ export const AddPoliciesButton = ({ isDisabled, projectType }: Props) => {
<IconButton
ariaLabel="Open policy template options"
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>
@@ -64,6 +78,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 +120,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,517 @@
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 { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import {
PermissionConditionOperators,
ProjectPermissionSecretActions
} from "@app/context/ProjectPermissionContext/types";
import {
useGetVaultMounts,
useGetVaultNamespaces,
useGetVaultPolicies
} from "@app/hooks/api/migration/queries";
import { TFormSchema } from "./ProjectRoleModifySection.utils";
type Props = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
};
type ContentProps = {
onClose: () => void;
};
type VaultMount = { path: string; type: string; version: string | null };
// Extract array element type helper
type ArrayElement<T> = T extends (infer U)[] ? U : never;
// Extract permission rule types from the form schema
type SecretPermissionRule = ArrayElement<
NonNullable<TFormSchema["permissions"]>[ProjectPermissionSub.Secrets]
>;
type FolderPermissionRule = ArrayElement<
NonNullable<TFormSchema["permissions"]>[ProjectPermissionSub.SecretFolders]
>;
// Helper to parse Vault path and extract environment and secret path
const parseVaultPath = (
vaultPath: string,
mounts: VaultMount[]
): { environment: string | null; secretPath: string | null; mount: VaultMount | null } => {
// Find the matching mount for this path
// Sort by path length (longest first) to match most specific mount
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 };
}
// Remove mount prefix and any trailing slash
let remainingPath = vaultPath.slice(mount.path.length);
if (remainingPath.startsWith("/")) remainingPath = remainingPath.slice(1);
// For KV v2, paths have format: data/{environment}/{path} or metadata/{environment}/{path}
// For KV v1, paths have format: {environment}/{path}
const isKvV2 = mount.version === "2" || mount.type === "kv";
let environment: string | null = null;
let secretPath: string | null = null;
if (isKvV2) {
// Remove data/ or metadata/ prefix for KV v2
if (remainingPath.startsWith("data/")) {
remainingPath = remainingPath.slice(5);
} else if (remainingPath.startsWith("metadata/")) {
remainingPath = remainingPath.slice(9);
}
}
// Split remaining path into segments
const segments = remainingPath.split("/").filter(Boolean);
if (segments.length > 0) {
// Special case: if the only segment is a wildcard, treat it as a path wildcard
if (segments.length === 1 && (segments[0] === "*" || segments[0] === "+")) {
environment = null; // No specific environment
secretPath = "/*"; // Match all paths
} else {
// First segment is treated as the environment
// (wildcards in environment will be handled with $GLOB operator later)
[environment] = segments;
// Remaining segments form the secret path
if (segments.length > 1) {
secretPath = `/${segments.slice(1).join("/")}`;
} else {
secretPath = "/";
}
}
}
return { environment, secretPath, mount };
};
// HCL parser for Vault policies - converts Vault HCL to Infisical permissions
const parseVaultPolicyToInfisical = (
hclPolicy: string,
mounts: VaultMount[]
): Partial<TFormSchema["permissions"]> => {
const permissions: Partial<TFormSchema["permissions"]> = {};
const secretsPermissions: SecretPermissionRule[] = [];
const foldersPermissions: FolderPermissionRule[] = [];
try {
// Remove comments from HCL before parsing
const cleanedPolicy = hclPolicy
.split("\n")
.map((line) => line.replace(/#.*$/, "").trim()) // Remove # comments
.filter((line) => line.length > 0) // Remove empty lines
.join(" "); // Join into single line for easier parsing
// Match path blocks with flexible whitespace handling
const pathRegex = /path\s+"([^"]+)"\s*\{[^}]*capabilities\s*=\s*\[([^\]]+)\][^}]*\}/gi;
let match = pathRegex.exec(cleanedPolicy);
while (match !== null) {
const [, path, capabilitiesStr] = match;
// Split by comma and clean up each capability (handles newlines, extra spaces, quotes)
const capabilities = capabilitiesStr
.split(",")
.map((c) => c.trim().replace(/["'\s]/g, "")) // Remove quotes, spaces, newlines
.filter((c) => c.length > 0); // Filter out empty strings
// Parse the Vault path using mount information
const { environment, secretPath, mount } = parseVaultPath(path, mounts);
// Only process KV (Key-Value) mounts
if (mount && (mount.type === "kv" || mount.type === "generic")) {
const isKvV2 = mount.version === "2";
const isDataPath = isKvV2 ? path.includes("/data/") : true; // KV v1 = all paths are data paths
const isMetadataPath = isKvV2 ? path.includes("/metadata/") : false; // KV v1 has no metadata endpoint
if (isDataPath && !isMetadataPath) {
// Data paths map to secret permissions
const actions: { [key: string]: boolean } = {};
if (capabilities.includes("create"))
actions[ProjectPermissionSecretActions.Create] = true;
if (capabilities.includes("read")) {
actions[ProjectPermissionSecretActions.DescribeSecret] = true;
actions[ProjectPermissionSecretActions.ReadValue] = true;
}
if (capabilities.includes("update")) actions[ProjectPermissionSecretActions.Edit] = true;
if (capabilities.includes("delete"))
actions[ProjectPermissionSecretActions.Delete] = true;
if (Object.keys(actions).length > 0) {
const conditions: Array<{ lhs: string; operator: string; rhs: string }> = [];
// Add environment condition with glob support if it contains wildcards
if (environment) {
// Convert Vault '+' to glob '*' for environment matching
const globEnv = environment.replace(/\+/g, "*");
// Skip condition if it's just '*' (matches everything = no restriction)
if (globEnv !== "*") {
const hasWildcard = globEnv.includes("*");
conditions.push({
lhs: "environment",
operator: hasWildcard
? PermissionConditionOperators.$GLOB
: PermissionConditionOperators.$EQ,
rhs: globEnv
});
}
}
// Add secret path condition with glob support
if (secretPath) {
// Convert Vault wildcards to picomatch glob patterns
// Vault '*' = match within segment, picomatch '**' = match across segments
// Vault '+' = single segment, convert to '*' (note: slightly more permissive)
const globPath = secretPath.replace(/\+/g, "*");
// Check if we need glob operator
const hasWildcard = globPath.includes("*");
conditions.push({
lhs: "secretPath",
operator: hasWildcard
? PermissionConditionOperators.$GLOB
: PermissionConditionOperators.$EQ,
rhs: globPath
});
}
secretsPermissions.push({
...actions,
conditions
});
}
} else if (isMetadataPath) {
// Metadata paths map to folder permissions
const actions: { [key: string]: boolean } = {};
if (capabilities.includes("create")) actions[ProjectPermissionActions.Create] = true;
if (capabilities.includes("update")) actions[ProjectPermissionActions.Edit] = true;
if (capabilities.includes("delete")) actions[ProjectPermissionActions.Delete] = true;
if (Object.keys(actions).length > 0) {
const conditions: Array<{ lhs: string; operator: string; rhs: string }> = [];
// Add environment condition with glob support if it contains wildcards
if (environment) {
// Convert Vault '+' to glob '*' for environment matching
const globEnv = environment.replace(/\+/g, "*");
// Skip condition if it's just '*' (matches everything = no restriction)
if (globEnv !== "*") {
const hasWildcard = globEnv.includes("*");
conditions.push({
lhs: "environment",
operator: hasWildcard
? PermissionConditionOperators.$GLOB
: PermissionConditionOperators.$EQ,
rhs: globEnv
});
}
}
// Add secret path condition for folders with glob support
if (secretPath) {
// Convert Vault '+' wildcard to glob '*'
const globPath = secretPath.replace(/\+/g, "*");
const hasWildcard = globPath.includes("*");
conditions.push({
lhs: "secretPath",
operator: hasWildcard
? PermissionConditionOperators.$GLOB
: PermissionConditionOperators.$EQ,
rhs: globPath
});
}
foldersPermissions.push({
...actions,
conditions
});
}
}
}
match = pathRegex.exec(cleanedPolicy);
}
if (secretsPermissions.length > 0) {
permissions[ProjectPermissionSub.Secrets] = secretsPermissions;
}
if (foldersPermissions.length > 0) {
permissions[ProjectPermissionSub.SecretFolders] = foldersPermissions;
}
} catch (err) {
console.error("Error parsing HCL policy:", err);
}
return permissions;
};
const Content = ({ onClose }: ContentProps) => {
const rootForm = useFormContext<TFormSchema>();
const [selectedNamespace, setSelectedNamespace] = useState<string>("default");
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,
refetch: refetchPolicies
} = useGetVaultPolicies(shouldFetchPolicies, selectedNamespace);
const {
data: mounts,
isLoading: isLoadingMounts,
refetch: refetchMounts
} = useGetVaultMounts(shouldFetchMounts, selectedNamespace);
// 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.entries(parsedPermissions).forEach(([subject, value]) => {
if (!value) return;
const subjectKey = subject as ProjectPermissionSub;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const existingValue = rootForm.getValues(`permissions.${subjectKey}`) as any;
if (Array.isArray(existingValue) && existingValue.length > 0) {
// Merge with existing permissions
rootForm.setValue(
`permissions.${subjectKey}`,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore-error
[...existingValue, ...value],
{
shouldDirty: true,
shouldTouch: true,
shouldValidate: true
}
);
} else {
rootForm.setValue(
`permissions.${subjectKey}`,
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore-error
value,
{
shouldDirty: true,
shouldTouch: true,
shouldValidate: true
}
);
}
});
createNotification({
type: "success",
text: "Policy translated and applied successfully"
});
onClose();
} catch (err) {
console.error("Translation error:", err);
createNotification({
type: "error",
text: "Failed to translate policy. Please check the HCL format."
});
}
};
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>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 intelligently translated using your Vault's KV secret engine mounts to extract environments and secret paths."
>
<>
<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);
// Refetch policies and mounts when namespace changes
refetchPolicies();
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 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="text-mineshaft-400 mt-1 text-xs">
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="text-mineshaft-400 mt-1 text-xs">
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>
);
};