Merge remote-tracking branch 'origin/main' into pki-revamp-v3

This commit is contained in:
Carlos Monastyrski
2025-10-20 14:27:19 -03:00
201 changed files with 7648 additions and 3401 deletions
@@ -37,26 +37,32 @@ const Page = () => {
const isSecretManager = currentProject.type === ProjectType.SecretManager;
return (
<div className="container mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-7xl">
<div className="mx-auto flex flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto mb-6 w-full max-w-8xl">
<PageHeader
scope="project"
scope={currentProject.type}
title="Access Control"
description="Manage fine-grained access for users, groups, roles, and identities within your project resources."
/>
<Tabs value={selectedTab} onValueChange={updateSelectedTab}>
<Tabs orientation="vertical" value={selectedTab} onValueChange={updateSelectedTab}>
<TabList>
<Tab value={ProjectAccessControlTabs.Member}>Users</Tab>
<Tab value={ProjectAccessControlTabs.Groups}>Groups</Tab>
<Tab value={ProjectAccessControlTabs.Identities}>
<div className="flex items-center">
<p>Machine Identities</p>
</div>
<Tab variant="project" value={ProjectAccessControlTabs.Member}>
Users
</Tab>
<Tab variant="project" value={ProjectAccessControlTabs.Groups}>
Groups
</Tab>
<Tab variant="project" value={ProjectAccessControlTabs.Identities}>
Identities
</Tab>
{isSecretManager && (
<Tab value={ProjectAccessControlTabs.ServiceTokens}>Service Tokens</Tab>
<Tab variant="project" value={ProjectAccessControlTabs.ServiceTokens}>
Service Tokens
</Tab>
)}
<Tab value={ProjectAccessControlTabs.Roles}>Project Roles</Tab>
<Tab variant="project" value={ProjectAccessControlTabs.Roles}>
Roles
</Tab>
</TabList>
<TabPanel value={ProjectAccessControlTabs.Member}>
<MembersTab />
@@ -1,5 +1,3 @@
import { motion } from "framer-motion";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { withProjectPermission } from "@app/hoc";
@@ -7,17 +5,7 @@ import { GroupsSection } from "./components";
export const GroupsTab = withProjectPermission(
() => {
return (
<motion.div
key="panel-groups"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<GroupsSection />
</motion.div>
);
return <GroupsSection />;
},
{
action: ProjectPermissionActions.Read,
@@ -14,7 +14,6 @@ import {
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate } from "@tanstack/react-router";
import { format } from "date-fns";
import { motion } from "framer-motion";
import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications";
@@ -158,307 +157,295 @@ export const IdentityTab = withProjectPermission(
};
return (
<motion.div
key="identity-role-panel"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-1">
<p className="text-xl font-medium text-mineshaft-100">Identities</p>
<a
href="https://infisical.com/docs/documentation/platform/identities/overview"
target="_blank"
rel="noopener noreferrer"
>
<div className="mt-[0.16rem] ml-1 inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
<span>Docs</span>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.07rem] ml-1.5 text-[10px]"
/>
</div>
</a>
</div>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.Identity}
<div className="mb-6 rounded-lg border border-mineshaft-600 bg-mineshaft-900 p-4">
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-1">
<p className="text-xl font-medium text-mineshaft-100">Identities</p>
<a
href="https://infisical.com/docs/documentation/platform/identities/overview"
target="_blank"
rel="noopener noreferrer"
>
{(isAllowed) => (
<Button
colorSchema="secondary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("identity")}
isDisabled={!isAllowed}
>
Add Identity
</Button>
)}
</ProjectPermissionCan>
<div className="mt-[0.16rem] ml-1 inline-block rounded-md bg-yellow/20 px-1.5 text-sm text-yellow opacity-80 hover:opacity-100">
<FontAwesomeIcon icon={faBookOpen} className="mr-1.5" />
<span>Docs</span>
<FontAwesomeIcon
icon={faArrowUpRightFromSquare}
className="mb-[0.07rem] ml-1.5 text-[10px]"
/>
</div>
</a>
</div>
<Input
containerClassName="mb-4"
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search identities by name..."
/>
<TableContainer>
<Table>
<THead>
<Tr className="h-14">
<Th className="w-1/3">
<div className="flex items-center">
Name
<IconButton
variant="plain"
className={`ml-2 ${
orderBy === ProjectIdentityOrderBy.Name ? "" : "opacity-30"
}`}
ariaLabel="sort"
onClick={() => handleSort(ProjectIdentityOrderBy.Name)}
>
<FontAwesomeIcon
icon={
orderDirection === OrderByDirection.DESC &&
orderBy === ProjectIdentityOrderBy.Name
? faArrowUp
: faArrowDown
}
/>
</IconButton>
</div>
</Th>
<Th className="w-1/3">Role</Th>
<Th>Added on</Th>
<Th className="w-5">{isFetching ? <Spinner size="xs" /> : null}</Th>
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={4} innerKey="project-identities" />}
{!isPending &&
data &&
data.identityMemberships.length > 0 &&
data.identityMemberships.map((identityMember) => {
const {
identity: { id, name },
roles,
createdAt
} = identityMember;
return (
<Tr
className="group h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
key={`st-v3-${id}`}
role="button"
tabIndex={0}
onKeyDown={(evt) => {
if (evt.key === "Enter") {
navigate({
to: `${getProjectBaseURL(currentProject.type)}/identities/$identityId` as const,
params: {
projectId: currentProject.id,
identityId: id
}
});
}
}}
onClick={() =>
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={ProjectPermissionSub.Identity}
>
{(isAllowed) => (
<Button
colorSchema="secondary"
type="submit"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => handlePopUpOpen("identity")}
isDisabled={!isAllowed}
>
Add Identity
</Button>
)}
</ProjectPermissionCan>
</div>
<Input
containerClassName="mb-4"
value={search}
onChange={(e) => setSearch(e.target.value)}
leftIcon={<FontAwesomeIcon icon={faMagnifyingGlass} />}
placeholder="Search identities by name..."
/>
<TableContainer>
<Table>
<THead>
<Tr className="h-14">
<Th className="w-1/3">
<div className="flex items-center">
Name
<IconButton
variant="plain"
className={`ml-2 ${
orderBy === ProjectIdentityOrderBy.Name ? "" : "opacity-30"
}`}
ariaLabel="sort"
onClick={() => handleSort(ProjectIdentityOrderBy.Name)}
>
<FontAwesomeIcon
icon={
orderDirection === OrderByDirection.DESC &&
orderBy === ProjectIdentityOrderBy.Name
? faArrowUp
: faArrowDown
}
/>
</IconButton>
</div>
</Th>
<Th className="w-1/3">Role</Th>
<Th>Added on</Th>
<Th className="w-5">{isFetching ? <Spinner size="xs" /> : null}</Th>
</Tr>
</THead>
<TBody>
{isPending && <TableSkeleton columns={4} innerKey="project-identities" />}
{!isPending &&
data &&
data.identityMemberships.length > 0 &&
data.identityMemberships.map((identityMember) => {
const {
identity: { id, name },
roles,
createdAt
} = identityMember;
return (
<Tr
className="group h-10 cursor-pointer transition-colors duration-100 hover:bg-mineshaft-700"
key={`st-v3-${id}`}
role="button"
tabIndex={0}
onKeyDown={(evt) => {
if (evt.key === "Enter") {
navigate({
to: `${getProjectBaseURL(currentProject.type)}/identities/$identityId` as const,
params: {
projectId: currentProject.id,
identityId: id
}
})
});
}
>
<Td>{name}</Td>
}}
onClick={() =>
navigate({
to: `${getProjectBaseURL(currentProject.type)}/identities/$identityId` as const,
params: {
projectId: currentProject.id,
identityId: id
}
})
}
>
<Td>{name}</Td>
<Td>
<div className="flex items-center space-x-2">
{roles
.slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
.map(
({
role,
customRoleName,
id: roleId,
isTemporary,
temporaryAccessEndTime
}) => {
const isExpired =
new Date() > new Date(temporaryAccessEndTime || ("" as string));
return (
<Tag key={roleId}>
<div className="flex items-center space-x-2">
<div className="capitalize">
{formatProjectRoleName(role, customRoleName)}
</div>
{isTemporary && (
<div>
<Tooltip
content={
isExpired
? "Timed role expired"
: "Timed role access"
}
>
<FontAwesomeIcon
icon={faClock}
className={twMerge(isExpired && "text-red-600")}
/>
</Tooltip>
</div>
)}
<Td>
<div className="flex items-center space-x-2">
{roles
.slice(0, MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
.map(
({
role,
customRoleName,
id: roleId,
isTemporary,
temporaryAccessEndTime
}) => {
const isExpired =
new Date() > new Date(temporaryAccessEndTime || ("" as string));
return (
<Tag key={roleId}>
<div className="flex items-center space-x-2">
<div className="capitalize">
{formatProjectRoleName(role, customRoleName)}
</div>
</Tag>
);
}
)}
{roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && (
<HoverCard>
<HoverCardTrigger>
<Tag>+{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE}</Tag>
</HoverCardTrigger>
<HoverCardContent className="border border-gray-700 bg-mineshaft-800 p-4">
{roles
.slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
.map(
({
role,
customRoleName,
id: roleId,
isTemporary,
temporaryAccessEndTime
}) => {
const isExpired =
new Date() >
new Date(temporaryAccessEndTime || ("" as string));
return (
<Tag key={roleId} className="capitalize">
<div className="flex items-center space-x-2">
<div>
{formatProjectRoleName(role, customRoleName)}
</div>
{isTemporary && (
<div>
<Tooltip
content={
isExpired
? "Access expired"
: "Temporary access"
}
>
<FontAwesomeIcon
icon={faClock}
className={twMerge(
new Date() >
new Date(
temporaryAccessEndTime as string
) && "text-red-600"
)}
/>
</Tooltip>
</div>
)}
</div>
</Tag>
);
}
)}
</HoverCardContent>
</HoverCard>
{isTemporary && (
<div>
<Tooltip
content={
isExpired ? "Timed role expired" : "Timed role access"
}
>
<FontAwesomeIcon
icon={faClock}
className={twMerge(isExpired && "text-red-600")}
/>
</Tooltip>
</div>
)}
</div>
</Tag>
);
}
)}
</div>
</Td>
<Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td>
<Td className="flex justify-end space-x-2">
<Tooltip className="max-w-sm text-center" content="Options">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={2} align="end">
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.Identity, {
identityId: id
})}
>
{(isAllowed) => (
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faCircleXmark} />}
isDisabled={!isAllowed}
onClick={(evt) => {
evt.stopPropagation();
evt.preventDefault();
handlePopUpOpen("deleteIdentity", {
identityId: id,
name
});
}}
>
Remove Identity From Project
</DropdownMenuItem>
{roles.length > MAX_ROLES_TO_BE_SHOWN_IN_TABLE && (
<HoverCard>
<HoverCardTrigger>
<Tag>+{roles.length - MAX_ROLES_TO_BE_SHOWN_IN_TABLE}</Tag>
</HoverCardTrigger>
<HoverCardContent className="border border-gray-700 bg-mineshaft-800 p-4">
{roles
.slice(MAX_ROLES_TO_BE_SHOWN_IN_TABLE)
.map(
({
role,
customRoleName,
id: roleId,
isTemporary,
temporaryAccessEndTime
}) => {
const isExpired =
new Date() >
new Date(temporaryAccessEndTime || ("" as string));
return (
<Tag key={roleId} className="capitalize">
<div className="flex items-center space-x-2">
<div>{formatProjectRoleName(role, customRoleName)}</div>
{isTemporary && (
<div>
<Tooltip
content={
isExpired
? "Access expired"
: "Temporary access"
}
>
<FontAwesomeIcon
icon={faClock}
className={twMerge(
new Date() >
new Date(
temporaryAccessEndTime as string
) && "text-red-600"
)}
/>
</Tooltip>
</div>
)}
</div>
</Tag>
);
}
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Tooltip>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isPending && data && totalCount > 0 && (
<Pagination
count={totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={handlePerPageChange}
/>
)}
{!isPending && data && data?.identityMemberships.length === 0 && (
<EmptyState
title={
debouncedSearch.trim().length > 0
? "No identities match search filter"
: "No identities have been added to this project"
}
icon={faServer}
/>
)}
</TableContainer>
<IdentityModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.deleteIdentity.isOpen}
title={`Are you sure you want to remove ${
(popUp?.deleteIdentity?.data as { name: string })?.name || ""
} from the project?`}
onChange={(isOpen) => handlePopUpToggle("deleteIdentity", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onRemoveIdentitySubmit(
(popUp?.deleteIdentity?.data as { identityId: string })?.identityId
)
}
/>
</div>
</motion.div>
</HoverCardContent>
</HoverCard>
)}
</div>
</Td>
<Td>{format(new Date(createdAt), "yyyy-MM-dd")}</Td>
<Td className="flex justify-end space-x-2">
<Tooltip className="max-w-sm text-center" content="Options">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton
ariaLabel="Options"
colorSchema="secondary"
className="w-6"
variant="plain"
>
<FontAwesomeIcon icon={faEllipsisV} />
</IconButton>
</DropdownMenuTrigger>
<DropdownMenuContent sideOffset={2} align="end">
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={subject(ProjectPermissionSub.Identity, {
identityId: id
})}
>
{(isAllowed) => (
<DropdownMenuItem
icon={<FontAwesomeIcon icon={faCircleXmark} />}
isDisabled={!isAllowed}
onClick={(evt) => {
evt.stopPropagation();
evt.preventDefault();
handlePopUpOpen("deleteIdentity", {
identityId: id,
name
});
}}
>
Remove Identity From Project
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Tooltip>
</Td>
</Tr>
);
})}
</TBody>
</Table>
{!isPending && data && totalCount > 0 && (
<Pagination
count={totalCount}
page={page}
perPage={perPage}
onChangePage={(newPage) => setPage(newPage)}
onChangePerPage={handlePerPageChange}
/>
)}
{!isPending && data && data?.identityMemberships.length === 0 && (
<EmptyState
title={
debouncedSearch.trim().length > 0
? "No identities match search filter"
: "No identities have been added to this project"
}
icon={faServer}
/>
)}
</TableContainer>
<IdentityModal popUp={popUp} handlePopUpToggle={handlePopUpToggle} />
<DeleteActionModal
isOpen={popUp.deleteIdentity.isOpen}
title={`Are you sure you want to remove ${
(popUp?.deleteIdentity?.data as { name: string })?.name || ""
} from the project?`}
onChange={(isOpen) => handlePopUpToggle("deleteIdentity", isOpen)}
deleteKey="confirm"
onDeleteApproved={() =>
onRemoveIdentitySubmit(
(popUp?.deleteIdentity?.data as { identityId: string })?.identityId
)
}
/>
</div>
);
},
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Identity }
@@ -1,5 +1,3 @@
import { motion } from "framer-motion";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { withProjectPermission } from "@app/hoc";
@@ -7,17 +5,7 @@ import { MembersSection } from "./components";
export const MembersTab = withProjectPermission(
() => {
return (
<motion.div
key="panel-project-members"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<MembersSection />
</motion.div>
);
return <MembersSection />;
},
{
action: ProjectPermissionActions.Read,
@@ -1,5 +1,3 @@
import { motion } from "framer-motion";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/context";
import { withProjectPermission } from "@app/hoc";
@@ -7,17 +5,7 @@ import { ProjectRoleList } from "./components/ProjectRoleList";
export const ProjectRoleListTab = withProjectPermission(
() => {
return (
<motion.div
key="role-list"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<ProjectRoleList />
</motion.div>
);
return <ProjectRoleList />;
},
{ action: ProjectPermissionActions.Read, subject: ProjectPermissionSub.Role }
);
@@ -1,20 +1,12 @@
// import { faWarning } from "@fortawesome/free-solid-svg-icons";
// import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { motion } from "framer-motion";
import { ServiceTokenSection } from "./components";
export const ServiceTokenTab = () => {
return (
<motion.div
key="panel-service-token"
transition={{ duration: 0.15 }}
initial={{ opacity: 0, translateX: 30 }}
animate={{ opacity: 1, translateX: 0 }}
exit={{ opacity: 0, translateX: 30 }}
>
<div className="space-y-3">
{/* <div className="flex w-full flex-row items-center rounded-md border border-primary-600/70 bg-primary/[.07] p-4 text-base text-white">
<div className="space-y-3">
{/* <div className="flex w-full flex-row items-center rounded-md border border-primary-600/70 bg-primary/[.07] p-4 text-base text-white">
<FontAwesomeIcon icon={faWarning} className="pr-6 text-4xl text-white/80" />
<div className="flex w-full flex-col text-sm">
<span className="mb-4 text-lg font-medium">Deprecation Notice</span>
@@ -42,8 +34,7 @@ export const ServiceTokenTab = () => {
</p>
</div>
</div> */}
<ServiceTokenSection />
</div>
</motion.div>
<ServiceTokenSection />
</div>
);
};
@@ -21,9 +21,9 @@ export const AppConnectionsPage = withProjectPermission(
<meta property="og:image" content="/images/message.png" />
</Helmet>
<div className="flex w-full justify-center bg-bunker-800 text-white">
<div className="w-full max-w-7xl">
<div className="w-full max-w-8xl">
<PageHeader
scope="project"
scope={currentProject.type}
className="w-full"
title="App Connections"
description="Manage project App Connections"
@@ -8,15 +8,15 @@ export const AuditLogsPage = () => {
const { currentProject } = useProject();
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">
<Helmet>
<title>Project Audit Logs</title>
<link rel="icon" href="/infisical.ico" />
</Helmet>
<div className="flex h-full w-full justify-center bg-bunker-800 text-white">
<div className="w-full max-w-7xl">
<div className="w-full max-w-8xl">
<PageHeader
scope="project"
scope={currentProject.type}
title="Audit logs"
description="Audit logs for security and compliance teams to monitor information access."
/>
@@ -1,11 +1,16 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { useParams } from "@tanstack/react-router";
import { faChevronLeft } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, useParams } from "@tanstack/react-router";
import { formatRelative } from "date-fns";
import { ProjectPermissionCan } from "@app/components/permissions";
import { EmptyState, PageHeader, Spinner } from "@app/components/v2";
import { ProjectPermissionActions, ProjectPermissionSub, useProject } from "@app/context";
import { getProjectBaseURL } from "@app/helpers/project";
import { useGetWorkspaceGroupMembershipDetails } from "@app/hooks/api/projects/queries";
import { ProjectAccessControlTabs } from "@app/types/project";
import { GroupDetailsSection } from "./components/GroupDetailsSection";
import { GroupMembersSection } from "./components/GroupMembersSection";
@@ -31,10 +36,27 @@ 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">
{groupMembership ? (
<div className="mx-auto mb-6 w-full max-w-7xl">
<PageHeader scope="project" title={groupMembership.group.name} />
<div className="mx-auto mb-6 w-full max-w-8xl">
<Link
to={`${getProjectBaseURL(currentProject.type)}/access-management`}
params={{
projectId: currentProject.id
}}
search={{
selectedTab: ProjectAccessControlTabs.Groups
}}
className="mb-4 flex items-center gap-x-2 text-sm text-mineshaft-400"
>
<FontAwesomeIcon icon={faChevronLeft} />
Groups
</Link>
<PageHeader
scope={currentProject.type}
title={groupMembership.group.name}
description={`Group joined on ${formatRelative(new Date(groupMembership.createdAt || ""), new Date())}`}
/>
<div className="flex">
<div className="mr-4 w-96">
<GroupDetailsSection groupMembership={groupMembership} />
@@ -1,7 +1,9 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { subject } from "@casl/ability";
import { useNavigate, useParams } from "@tanstack/react-router";
import { faChevronLeft } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, useNavigate, useParams } from "@tanstack/react-router";
import { formatRelative } from "date-fns";
import { createNotification } from "@app/components/notifications";
@@ -28,6 +30,7 @@ import {
useGetWorkspaceIdentityMembershipDetails
} from "@app/hooks/api";
import { ActorType } from "@app/hooks/api/auditLogs/enums";
import { ProjectAccessControlTabs } from "@app/types/project";
import { IdentityProjectAdditionalPrivilegeSection } from "./components/IdentityProjectAdditionalPrivilegeSection";
import { IdentityRoleDetailsSection } from "./components/IdentityRoleDetailsSection";
@@ -113,11 +116,24 @@ const Page = () => {
}
return (
<div className="container mx-auto flex max-w-7xl flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto flex max-w-8xl flex-col justify-between bg-bunker-800 text-white">
{identityMembershipDetails ? (
<>
<Link
to={`${getProjectBaseURL(currentProject.type)}/access-management`}
params={{
projectId
}}
search={{
selectedTab: ProjectAccessControlTabs.Identities
}}
className="mb-4 flex items-center gap-x-2 text-sm text-mineshaft-400"
>
<FontAwesomeIcon icon={faChevronLeft} />
Identities
</Link>
<PageHeader
scope="project"
scope={currentProject.type}
title={identityMembershipDetails?.identity?.name}
description={`Identity joined on ${identityMembershipDetails?.createdAt && formatRelative(new Date(identityMembershipDetails?.createdAt || ""), new Date())}`}
>
@@ -1,6 +1,8 @@
import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next";
import { useNavigate, useParams } from "@tanstack/react-router";
import { faChevronLeft } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Link, useNavigate, useParams } from "@tanstack/react-router";
import { formatRelative } from "date-fns";
import { UpgradePlanModal } from "@app/components/license/UpgradePlanModal";
@@ -29,6 +31,7 @@ import {
useGetWorkspaceUserDetails
} from "@app/hooks/api";
import { ActorType } from "@app/hooks/api/auditLogs/enums";
import { ProjectAccessControlTabs } from "@app/types/project";
import { MemberProjectAdditionalPrivilegeSection } from "./components/MemberProjectAdditionalPrivilegeSection";
import { MemberRoleDetailsSection } from "./components/MemberRoleDetailsSection";
@@ -115,11 +118,24 @@ export const Page = () => {
}
return (
<div className="container mx-auto flex max-w-7xl flex-col justify-between bg-bunker-800 text-white">
<div className="mx-auto flex max-w-8xl flex-col justify-between bg-bunker-800 text-white">
{membershipDetails ? (
<>
<Link
to={`${getProjectBaseURL(currentProject.type)}/access-management`}
params={{
projectId: currentProject.id
}}
search={{
selectedTab: ProjectAccessControlTabs.Member
}}
className="mb-4 flex items-center gap-x-2 text-sm text-mineshaft-400"
>
<FontAwesomeIcon icon={faChevronLeft} />
Users
</Link>
<PageHeader
scope="project"
scope={currentProject.type}
title={
membershipDetails.user.firstName || membershipDetails.user.lastName
? `${membershipDetails.user.firstName} ${membershipDetails.user.lastName}`
@@ -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>
);
};
@@ -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;
};
@@ -35,7 +35,7 @@ export const DeleteProjectProtection = () => {
<p className="mb-3 text-xl font-medium">Delete Protection</p>
<ProjectPermissionCan I={ProjectPermissionActions.Edit} a={ProjectPermissionSub.Settings}>
{(isAllowed) => (
<div className="w-max">
<div>
<Checkbox
id="hasDeleteProtection"
isDisabled={!isAllowed}
@@ -43,6 +43,7 @@ export const DeleteProjectProtection = () => {
onCheckedChange={(state) => {
handleToggleDeleteProjectProtection(state as boolean);
}}
allowMultilineLabel
>
Protects the project from being deleted accidentally. While this option is enabled,
you can&apos;t delete the project.