Merge pull request #4726 from Infisical/misc/add-support-for-translation-preview-ofvault-migration

misc: add support for translation preview for vault migration
This commit is contained in:
Sheen
2025-10-31 21:15:00 +08:00
committed by GitHub
3 changed files with 383 additions and 16 deletions
@@ -0,0 +1,213 @@
import { VaultMount } from "./VaultPolicyImportModal.utils";
export type PolicyBlock = {
id: string;
path: string;
capabilities: string[];
rawText: string;
startLine: number;
endLine: number;
canTranslate: boolean;
reason?: string;
};
export type PolicyLine = {
id: string;
text: string;
lineNumber: number;
type: "comment" | "empty" | "part-of-block" | "other";
belongsToBlock?: string;
};
const hasTranslatableCapabilities = (capabilities: string[], isMetadataPath: boolean): boolean => {
if (isMetadataPath) {
// For metadata/folder paths, only these capabilities create permissions
return capabilities.some((cap) =>
["create", "update", "patch", "delete"].includes(cap.toLowerCase())
);
}
// For data/secret paths, all these capabilities create permissions
return capabilities.some((cap) =>
["create", "list", "read", "update", "patch", "delete"].includes(cap.toLowerCase())
);
};
const canTranslateBlock = (
path: string,
capabilities: string[],
mounts: VaultMount[]
): { canTranslate: boolean; reason?: string } => {
if (path === "*" || path === "+") {
return { canTranslate: true };
}
const isWildcardMount = path.startsWith("*/") || path.startsWith("+/");
if (isWildcardMount) {
// Check if it's a metadata path
const isMetadata = path.includes("/metadata/");
if (!hasTranslatableCapabilities(capabilities, isMetadata)) {
return {
canTranslate: false,
reason: isMetadata
? "Cannot translate list/read capabilities for metadata (Infisical only supports translation for create, update, delete)"
: "No translatable capabilities found"
};
}
return { canTranslate: true };
}
// Check for common non-KV system paths
if (path.startsWith("auth/")) {
return {
canTranslate: false,
reason: "Authentication paths (auth/*) cannot be translated"
};
}
if (path.startsWith("sys/")) {
return {
canTranslate: false,
reason: "System paths (sys/*) cannot be translated"
};
}
if (path.startsWith("identity/")) {
return {
canTranslate: false,
reason: "Identity paths (identity/*) cannot be translated"
};
}
const sortedMounts = [...mounts].sort((a, b) => b.path.length - a.path.length);
const mount = sortedMounts.find((m) => path.startsWith(m.path));
if (!mount) {
return {
canTranslate: false,
reason: "Mount path not found (only KV secret engines are supported)"
};
}
// Check if it's a KV secret engine
if (mount.type !== "kv" && mount.type !== "generic") {
return {
canTranslate: false,
reason: `Only KV secret engines are supported (found: ${mount.type})`
};
}
// Check if it's a metadata path and has valid capabilities
const isKvV2 = mount.version === "2";
const isMetadata = isKvV2 && path.includes("/metadata/");
if (!hasTranslatableCapabilities(capabilities, isMetadata)) {
return {
canTranslate: false,
reason: isMetadata
? "Cannot translate list/read capabilities for metadata (Infisical only supports translation for create, update, delete)"
: "No translatable capabilities found"
};
}
return { canTranslate: true };
};
export const analyzeVaultPolicy = (
hclPolicy: string,
mounts: VaultMount[]
): {
blocks: PolicyBlock[];
lines: PolicyLine[];
translatableCount: number;
nonTranslatableCount: number;
} => {
const blocks: PolicyBlock[] = [];
const lines: PolicyLine[] = [];
let blockIdCounter = 0;
const policyLines = hclPolicy.split("\n");
// Track which lines belong to which blocks
const lineToBlockMap = new Map<number, string>();
// Step 1: Extract all path blocks
try {
const pathRegex = /path\s+"([^"]+)"\s*\{[^}]*capabilities\s*=\s*\[([^\]]+)\][^}]*\}/gi;
let match = pathRegex.exec(hclPolicy);
while (match !== null) {
const [fullMatch, path, capabilitiesStr] = match;
const capabilities = capabilitiesStr
.split(",")
.map((c) => c.trim().replace(/["'\s]/g, ""))
.filter((c) => c.length > 0);
// Find the line numbers for this block
const matchIndex = match.index;
const textBeforeMatch = hclPolicy.substring(0, matchIndex);
const textIncludingMatch = hclPolicy.substring(0, matchIndex + fullMatch.length);
const startLine = textBeforeMatch.split("\n").length;
const endLine = textIncludingMatch.split("\n").length;
// Determine if this block can be translated
const { canTranslate, reason } = canTranslateBlock(path, capabilities, mounts);
const blockId = `block-${blockIdCounter}`;
blockIdCounter += 1;
blocks.push({
id: blockId,
path,
capabilities,
rawText: fullMatch,
startLine,
endLine,
canTranslate,
reason
});
// Mark these lines as belonging to this block
for (let i = startLine; i <= endLine; i += 1) {
lineToBlockMap.set(i, blockId);
}
match = pathRegex.exec(hclPolicy);
}
} catch (err) {
console.error("Error analyzing HCL policy:", err);
}
// Step 2: Process each line
policyLines.forEach((lineText, index) => {
const lineNumber = index + 1;
const trimmedLine = lineText.trim();
const blockId = lineToBlockMap.get(lineNumber);
let type: PolicyLine["type"] = "other";
if (trimmedLine.startsWith("#") || trimmedLine.startsWith("//")) {
type = "comment";
} else if (trimmedLine === "") {
type = "empty";
} else if (blockId) {
type = "part-of-block";
}
lines.push({
id: `line-${lineNumber}`,
text: lineText,
lineNumber,
type,
belongsToBlock: blockId
});
});
const translatableCount = blocks.filter((b) => b.canTranslate).length;
const nonTranslatableCount = blocks.filter((b) => !b.canTranslate).length;
return {
blocks,
lines,
translatableCount,
nonTranslatableCount
};
};
@@ -21,7 +21,9 @@ import {
} from "@app/hooks/api/migration/queries"; } from "@app/hooks/api/migration/queries";
import { TFormSchema } from "./ProjectRoleModifySection.utils"; import { TFormSchema } from "./ProjectRoleModifySection.utils";
import { analyzeVaultPolicy, PolicyBlock, PolicyLine } from "./VaultPolicyAnalyzer.utils";
import { parseVaultPolicyToInfisical } from "./VaultPolicyImportModal.utils"; import { parseVaultPolicyToInfisical } from "./VaultPolicyImportModal.utils";
import { VaultPolicyPreview } from "./VaultPolicyPreview";
type Props = { type Props = {
isOpen: boolean; isOpen: boolean;
@@ -39,6 +41,10 @@ const Content = ({ onClose }: ContentProps) => {
const [hclPolicy, setHclPolicy] = useState<string>(""); const [hclPolicy, setHclPolicy] = useState<string>("");
const [shouldFetchPolicies, setShouldFetchPolicies] = useState(false); const [shouldFetchPolicies, setShouldFetchPolicies] = useState(false);
const [shouldFetchMounts, setShouldFetchMounts] = useState(false); const [shouldFetchMounts, setShouldFetchMounts] = useState(false);
const [analysisResult, setAnalysisResult] = useState<{
blocks: PolicyBlock[];
lines: PolicyLine[];
} | null>(null);
const { data: namespaces, isLoading: isLoadingNamespaces } = useGetVaultNamespaces(); const { data: namespaces, isLoading: isLoadingNamespaces } = useGetVaultNamespaces();
const { data: policies, isLoading: isLoadingPolicies } = useGetVaultPolicies( const { data: policies, isLoading: isLoadingPolicies } = useGetVaultPolicies(
@@ -68,6 +74,49 @@ const Content = ({ onClose }: ContentProps) => {
} }
}, [selectedPolicy, policies]); }, [selectedPolicy, policies]);
// Automatically analyze policy when it changes (with debouncing)
useEffect(() => {
if (!hclPolicy.trim() || !mounts || mounts.length === 0) {
setAnalysisResult(null);
return undefined;
}
const timeoutId = setTimeout(() => {
const result = analyzeVaultPolicy(hclPolicy, mounts);
setAnalysisResult(result);
}, 300); // Debounce for 300ms
return () => {
clearTimeout(timeoutId);
};
}, [hclPolicy, mounts]);
const renderEmptyState = () => {
if (!selectedNamespace) {
return (
<div>
<p>Select a namespace to enable preview</p>
</div>
);
}
if (isLoadingMounts) {
return <div>Loading mounts...</div>;
}
if (!mounts || mounts.length === 0) {
return (
<div>
<p className="font-medium text-yellow-400">No KV mounts found</p>
<p className="mt-1 text-xs">This namespace has no KV secret engines configured.</p>
<p className="mt-1 text-xs">Policy translation requires KV mounts.</p>
</div>
);
}
return <div>Enter a policy to see translation preview</div>;
};
const handleTranslateAndApply = () => { const handleTranslateAndApply = () => {
if (!hclPolicy.trim()) { if (!hclPolicy.trim()) {
createNotification({ type: "error", text: "Please provide a Vault HCL policy" }); createNotification({ type: "error", text: "Please provide a Vault HCL policy" });
@@ -211,27 +260,40 @@ const Content = ({ onClose }: ContentProps) => {
</> </>
</FormControl> </FormControl>
<FormControl label="Vault HCL Policy" className="mb-6"> <div className="grid grid-cols-2 gap-4">
<> <FormControl label="Vault HCL Policy" className="mb-4">
<TextArea <>
value={hclPolicy} <TextArea
onChange={(e) => setHclPolicy(e.target.value)} value={hclPolicy}
placeholder={`path "secret/data/prod/app/*" { onChange={(e) => setHclPolicy(e.target.value)}
placeholder={`path "secret/data/prod/app/*" {
capabilities = ["create", "read", "update", "delete"] capabilities = ["create", "read", "update", "delete"]
} }
path "secret/metadata/prod/*" { path "secret/metadata/prod/*" {
capabilities = ["list"] capabilities = ["list"]
}`} }`}
rows={12} rows={20}
className="font-mono text-sm" className="h-[30rem] px-4 py-0.5 font-mono text-xs leading-6"
/> />
<p className="mt-1 text-xs text-mineshaft-400"> <p className="mt-1 text-xs text-mineshaft-400">
Paste your HCL policy here or select one from the dropdown above. The translator will Paste your HCL policy here or select one from the dropdown above.
extract environments and paths automatically. </p>
</p> </>
</> </FormControl>
</FormControl>
<div className="mb-4">
<FormControl label="Translation Preview" className="mb-4">
{analysisResult ? (
<VaultPolicyPreview blocks={analysisResult.blocks} lines={analysisResult.lines} />
) : (
<div className="flex h-[30rem] items-center justify-center rounded-md border border-mineshaft-600 bg-mineshaft-900 text-center text-sm text-mineshaft-400">
{renderEmptyState()}
</div>
)}
</FormControl>
</div>
</div>
<div className="mt-8 flex space-x-4"> <div className="mt-8 flex space-x-4">
<Button <Button
@@ -256,7 +318,7 @@ export const VaultPolicyImportModal = ({ isOpen, onOpenChange }: Props) => {
<ModalContent <ModalContent
title="Import from HashiCorp Vault" 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." subTitle="Select a policy from your Vault namespace or paste your own HCL policy to translate it into Infisical permissions."
className="max-w-3xl" className="max-w-4xl"
> >
<Content onClose={() => onOpenChange(false)} /> <Content onClose={() => onOpenChange(false)} />
</ModalContent> </ModalContent>
@@ -0,0 +1,92 @@
import { faCheckCircle, faTimesCircle } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { twMerge } from "tailwind-merge";
import { PolicyBlock, PolicyLine } from "./VaultPolicyAnalyzer.utils";
type Props = {
blocks: PolicyBlock[];
lines: PolicyLine[];
};
export const VaultPolicyPreview = ({ blocks, lines }: Props) => {
// Create a map of block IDs to blocks for quick lookup
const blockMap = new Map(blocks.map((block) => [block.id, block]));
return (
<div className="flex h-[30rem] flex-col rounded-md border border-mineshaft-600 bg-mineshaft-900">
<div className="flex-1 overflow-auto font-mono text-xs">
{lines.map((line) => {
const block = line.belongsToBlock ? blockMap.get(line.belongsToBlock) : null;
const isPartOfBlock = line.type === "part-of-block";
const isComment = line.type === "comment";
const isEmpty = line.type === "empty";
let bgColorClass = "";
let borderColorClass = "";
let textColorClass = "text-mineshaft-300";
let showIndicator = false;
let indicator: JSX.Element | null = null;
if (isPartOfBlock && block) {
showIndicator = line.lineNumber === block.startLine;
if (block.canTranslate) {
bgColorClass = "bg-green-500/10";
borderColorClass = "border-l-2 border-green-500/50";
textColorClass = "text-green-100";
if (showIndicator) {
indicator = (
<div className="flex items-center gap-2 text-green-400">
<FontAwesomeIcon icon={faCheckCircle} className="h-3 w-3" />
<span className="text-xs">Can translate</span>
</div>
);
}
} else {
bgColorClass = "bg-red-500/10";
borderColorClass = "border-l-2 border-red-500/50";
textColorClass = "text-red-100";
if (showIndicator) {
indicator = (
<div className="flex items-center gap-2 text-red-400">
<FontAwesomeIcon icon={faTimesCircle} className="h-3 w-3" />
<span className="text-xs">{block.reason || "Cannot translate"}</span>
</div>
);
}
}
} else if (isComment) {
textColorClass = "text-mineshaft-500 italic";
}
return (
<div key={line.id} className="group relative">
{showIndicator && indicator && (
<div
className={twMerge(
"flex items-center px-4 py-1.5",
block?.canTranslate ? "bg-green-500/5" : "bg-red-500/5"
)}
>
{indicator}
</div>
)}
<div
className={twMerge(
"px-4 py-0.5 leading-6",
bgColorClass,
borderColorClass,
isEmpty && "min-h-[1.5rem]"
)}
>
<span className={twMerge("font-mono whitespace-pre", textColorClass)}>
{line.text || " "}
</span>
</div>
</div>
);
})}
</div>
</div>
);
};