mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-10-19 16:01:44 +00:00
Merge remote-tracking branch 'origin/main' into pki-revamp-v3
This commit is contained in:
@@ -1,8 +1,14 @@
|
||||
import { Helmet } from "react-helmet";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { faCopy, faEdit, faEllipsisV, faTrash } from "@fortawesome/free-solid-svg-icons";
|
||||
import {
|
||||
faChevronLeft,
|
||||
faCopy,
|
||||
faEdit,
|
||||
faEllipsisV,
|
||||
faTrash
|
||||
} from "@fortawesome/free-solid-svg-icons";
|
||||
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
|
||||
import { useNavigate, useParams } from "@tanstack/react-router";
|
||||
import { Link, useNavigate, useParams } from "@tanstack/react-router";
|
||||
import { twMerge } from "tailwind-merge";
|
||||
|
||||
import { createNotification } from "@app/components/notifications";
|
||||
@@ -86,20 +92,29 @@ const Page = () => {
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
||||
<div className="mx-auto flex flex-col justify-between bg-bunker-800 text-white">
|
||||
{data && (
|
||||
<div className="mx-auto mb-6 w-full max-w-7xl">
|
||||
<div className="mx-auto mb-6 w-full max-w-8xl">
|
||||
<Link
|
||||
to={`${getProjectBaseURL(currentProject.type)}/access-management`}
|
||||
params={{
|
||||
projectId
|
||||
}}
|
||||
search={{
|
||||
selectedTab: ProjectAccessControlTabs.Roles
|
||||
}}
|
||||
className="mb-4 flex items-center gap-x-2 text-sm text-mineshaft-400"
|
||||
>
|
||||
<FontAwesomeIcon icon={faChevronLeft} />
|
||||
Roles
|
||||
</Link>
|
||||
<PageHeader
|
||||
scope="project"
|
||||
title={
|
||||
<div className="flex flex-col">
|
||||
<div>
|
||||
<span>{data.name}</span>
|
||||
<p className="text-sm leading-3 font-normal text-mineshaft-400 normal-case">
|
||||
{data.slug} {data.description && `- ${data.description}`}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
scope={currentProject.type}
|
||||
title={data.name}
|
||||
description={
|
||||
<>
|
||||
{data.slug} {data.description && `- ${data.description}`}
|
||||
</>
|
||||
}
|
||||
>
|
||||
{isCustomRole && (
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
+265
@@ -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>
|
||||
);
|
||||
};
|
||||
+449
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user