From 67ec00d46b786e277cccbfe4e589e94670b46be0 Mon Sep 17 00:00:00 2001 From: Scott Wilson Date: Wed, 16 Jul 2025 12:16:13 -0700 Subject: [PATCH] feature: add access requests to single env view, with general UI improvements --- .../src/ee/services/license/license-fns.ts | 2 +- frontend/src/hooks/api/subscriptions/types.ts | 4 +- frontend/src/hooks/usePathAccessPolicies.tsx | 62 +++++++ .../SpecificPrivilegeSection.tsx | 22 ++- .../components/RequestAccessModal.tsx | 25 ++- .../SecretDashboardPage.tsx | 163 +++++++++++++++++- .../components/ActionBar/ActionBar.tsx | 80 ++++++--- .../SecretDropzone/SecretDropzone.tsx | 2 +- 8 files changed, 323 insertions(+), 37 deletions(-) create mode 100644 frontend/src/hooks/usePathAccessPolicies.tsx diff --git a/backend/src/ee/services/license/license-fns.ts b/backend/src/ee/services/license/license-fns.ts index 5b755567b..353070910 100644 --- a/backend/src/ee/services/license/license-fns.ts +++ b/backend/src/ee/services/license/license-fns.ts @@ -40,7 +40,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({ status: null, trial_end: null, has_used_trial: true, - secretApproval: false, + secretApproval: true, secretRotation: false, caCrl: false, instanceUserManagement: false, diff --git a/frontend/src/hooks/api/subscriptions/types.ts b/frontend/src/hooks/api/subscriptions/types.ts index dad9e11fa..87a02231f 100644 --- a/frontend/src/hooks/api/subscriptions/types.ts +++ b/frontend/src/hooks/api/subscriptions/types.ts @@ -17,8 +17,8 @@ export type SubscriptionPlan = { rbac: boolean; secretVersioning: boolean; slug: string; - secretApproval: string; - secretRotation: string; + secretApproval: boolean; + secretRotation: boolean; tier: number; workspaceLimit: number; workspacesUsed: number; diff --git a/frontend/src/hooks/usePathAccessPolicies.tsx b/frontend/src/hooks/usePathAccessPolicies.tsx new file mode 100644 index 000000000..118e1f75a --- /dev/null +++ b/frontend/src/hooks/usePathAccessPolicies.tsx @@ -0,0 +1,62 @@ +import { useMemo } from "react"; + +import { useSubscription, useWorkspace } from "@app/context"; +import { useGetAccessApprovalPolicies } from "@app/hooks/api"; + +const matchesPath = (folderPath: string, pattern: string) => { + const normalizedPath = folderPath === "/" ? "/" : folderPath.replace(/\/$/, ""); + const normalizedPattern = pattern === "/" ? "/" : pattern.replace(/\/$/, ""); + + console.log(normalizedPath, normalizedPattern); + + if (normalizedPath === normalizedPattern) { + return true; + } + + if (normalizedPattern.endsWith("/**")) { + const basePattern = normalizedPattern.slice(0, -3); // Remove "/**" + + // Handle root wildcard "/**" + if (basePattern === "") { + return true; + } + + // Check if path starts with the base pattern + if (normalizedPath === basePattern) { + return true; + } + + // Check if path is a subdirectory of the base pattern + return normalizedPath.startsWith(`${basePattern}/`); + } + + return false; +}; + +type Params = { + secretPath: string; + environment: string; +}; + +export const usePathAccessPolicies = ({ secretPath, environment }: Params) => { + const { currentWorkspace } = useWorkspace(); + const { subscription } = useSubscription(); + const { data: policies } = useGetAccessApprovalPolicies({ + projectSlug: currentWorkspace.slug, + options: { + enabled: subscription.secretApproval + } + }); + + return useMemo(() => { + const pathPolicies = policies?.filter( + (policy) => + policy.environment.slug === environment && matchesPath(secretPath, policy.secretPath) + ); + + return { + hasPathPolicies: subscription.secretApproval && Boolean(pathPolicies?.length), + pathPolicies + }; + }, [secretPath, environment, policies, subscription.secretApproval]); +}; diff --git a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MemberRoleForm/SpecificPrivilegeSection.tsx b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MemberRoleForm/SpecificPrivilegeSection.tsx index 86b7d7633..51a83c6e0 100644 --- a/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MemberRoleForm/SpecificPrivilegeSection.tsx +++ b/frontend/src/pages/project/AccessControlPage/components/MembersTab/components/MemberRoleForm/SpecificPrivilegeSection.tsx @@ -79,10 +79,14 @@ type TSecretPermissionForm = z.infer; export const SpecificPrivilegeSecretForm = ({ privilege, policies, - onClose + onClose, + selectedActions = [], + secretPath: initialSecretPath }: { privilege?: TProjectUserPrivilege; policies?: TAccessApprovalPolicy[]; + selectedActions?: ProjectPermissionActions[]; + secretPath?: string; onClose?: () => void; }) => { const { currentWorkspace } = useWorkspace(); @@ -126,10 +130,11 @@ export const SpecificPrivilegeSecretForm = ({ } : { environmentSlug: currentWorkspace.environments?.[0]?.slug, - read: false, - edit: false, - create: false, - delete: false, + secretPath: initialSecretPath, + read: selectedActions.includes(ProjectPermissionActions.Read), + edit: selectedActions.includes(ProjectPermissionActions.Edit), + create: selectedActions.includes(ProjectPermissionActions.Create), + delete: selectedActions.includes(ProjectPermissionActions.Delete), temporaryAccess: { isTemporary: false } @@ -281,6 +286,8 @@ export const SpecificPrivilegeSecretForm = ({ isDisabled={isMemberEditDisabled} className="w-full bg-mineshaft-900 hover:bg-mineshaft-800" onValueChange={(e) => onChange(e)} + position="popper" + dropdownContainerClassName="max-w-none" > {currentWorkspace?.environments?.map(({ slug, id, name }) => ( @@ -309,6 +316,8 @@ export const SpecificPrivilegeSecretForm = ({ className="w-full hover:bg-mineshaft-800" placeholder="Select a secret path" onValueChange={(e) => field.onChange(e)} + position="popper" + dropdownContainerClassName="max-w-none" > {selectablePaths.map((path) => ( @@ -636,6 +645,7 @@ export const SpecificPrivilegeSecretForm = ({ {!!policies && ( )} diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/RequestAccessModal.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/RequestAccessModal.tsx index b337f4e69..71b14ec5e 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/RequestAccessModal.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/RequestAccessModal.tsx @@ -1,15 +1,22 @@ import { Modal, ModalContent } from "@app/components/v2"; +import { NoticeBannerV2 } from "@app/components/v2/NoticeBannerV2/NoticeBannerV2"; +import { ProjectPermissionActions } from "@app/context"; import { TAccessApprovalPolicy } from "@app/hooks/api/types"; import { SpecificPrivilegeSecretForm } from "@app/pages/project/AccessControlPage/components/MembersTab/components/MemberRoleForm/SpecificPrivilegeSection"; export const RequestAccessModal = ({ isOpen, onOpenChange, - policies + policies, + shouldShowBanner, + ...props }: { isOpen: boolean; onOpenChange: (isOpen: boolean) => void; policies: TAccessApprovalPolicy[]; + selectedActions?: ProjectPermissionActions[]; + secretPath?: string; + shouldShowBanner?: boolean; }) => { return ( @@ -18,7 +25,21 @@ export const RequestAccessModal = ({ title="Request Access" subTitle="Request access to any secrets and resources based on the predefined policies." > - onOpenChange(false)} policies={policies} /> + {shouldShowBanner && ( + +

+ Request access to gain access to this action. +

+
+ )} + onOpenChange(false)} + policies={policies} + {...props} + />
); diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx index 0f1c194d2..079909912 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/SecretDashboardPage.tsx @@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { Helmet } from "react-helmet"; import { useTranslation } from "react-i18next"; import { subject } from "@casl/ability"; -import { faArrowDown, faArrowUp } from "@fortawesome/free-solid-svg-icons"; +import { faArrowDown, faArrowUp, faInfoCircle } from "@fortawesome/free-solid-svg-icons"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; import { twMerge } from "tailwind-merge"; @@ -10,10 +10,12 @@ import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; import { PermissionDeniedBanner } from "@app/components/permissions"; import { + Button, Checkbox, ContentLoader, Modal, ModalContent, + PageHeader, Pagination, Tooltip } from "@app/components/v2"; @@ -46,7 +48,9 @@ import { useGetProjectSecretsDetails } from "@app/hooks/api/dashboard"; import { DashboardSecretsOrderBy } from "@app/hooks/api/dashboard/types"; import { useGetFolderCommitsCount } from "@app/hooks/api/folderCommits"; import { OrderByDirection } from "@app/hooks/api/generic/types"; +import { usePathAccessPolicies } from "@app/hooks/usePathAccessPolicies"; import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission"; +import { RequestAccessModal } from "@app/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/RequestAccessModal"; import { SecretRotationListView } from "@app/pages/secret-manager/SecretDashboardPage/components/SecretRotationListView"; import { SecretTableResourceCount } from "../OverviewPage/components/SecretTableResourceCount"; @@ -114,7 +118,10 @@ const Page = () => { const [snapshotId, setSnapshotId] = useState(null); const isRollbackMode = Boolean(snapshotId); - const { popUp, handlePopUpClose, handlePopUpToggle } = usePopUp(["snapshots"] as const); + const { popUp, handlePopUpClose, handlePopUpToggle, handlePopUpOpen } = usePopUp([ + "snapshots", + "requestAccess" + ] as const); // env slug const workspaceId = currentWorkspace?.id || ""; @@ -132,6 +139,26 @@ const Page = () => { } ); + const canEditSecrets = permission.can( + ProjectPermissionSecretActions.Edit, + subject(ProjectPermissionSub.Secrets, { + environment, + secretPath, + secretName: "*", + secretTags: ["*"] + }) + ); + + const canDeleteSecrets = permission.can( + ProjectPermissionSecretActions.Delete, + subject(ProjectPermissionSub.Secrets, { + environment, + secretPath, + secretName: "*", + secretTags: ["*"] + }) + ); + const canReadSecretValue = hasSecretReadValueOrDescribePermission( permission, ProjectPermissionSecretActions.ReadValue, @@ -257,6 +284,8 @@ const Page = () => { permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags) ? workspaceId : "" ); + const { pathPolicies, hasPathPolicies } = usePathAccessPolicies({ secretPath, environment }); + const { data: boardPolicy } = useGetSecretApprovalPolicyOfABoard({ workspaceId, environment, @@ -476,8 +505,55 @@ const Page = () => { setFilter(defaultFilterState); setDebouncedSearchFilter(""); }; + return (
+ env.slug === environment)?.name ?? environment + } + description={ +

+ Inject your secrets using + + Infisical CLI + + , + + Infisical API + + , + + Infisical SDKs + + , and + + more + + . +

+ } + /> {!isRollbackMode ? ( <> @@ -500,8 +576,15 @@ const Page = () => { importedBy={importedBy} usedBySecretSyncs={usedBySecretSyncs} isPITEnabled={isPITEnabled} + hasPathPolicies={hasPathPolicies} + onRequestAccess={(params) => handlePopUpOpen("requestAccess", params)} /> -
+
{isNotEmpty && (
{
Value
)} + {hasPathPolicies && + // eslint-disable-next-line no-nested-ternary + (!canReadSecret ? ( +
+
+ + You do not permission to read secrets for this path +
+ +
+ ) : !canEditSecrets || !canDeleteSecrets ? ( +
+
+ + + You do not permission to {!canEditSecrets ? "edit" : ""} + {!canEditSecrets && !canDeleteSecrets ? " or " : ""} + {!canDeleteSecrets ? "delete" : ""} secrets for this path + +
+ +
+ ) : null)} + {canReadSecretImports && Boolean(imports?.length) && ( { /> + {!!pathPolicies && ( + { + handlePopUpClose("requestAccess"); + }} + selectedActions={popUp.requestAccess.data?.actions} + shouldShowBanner={popUp.requestAccess.data?.shouldShowBanner} + secretPath={pathPolicies?.[0]?.secretPath} + /> + )} void; + hasPathPolicies: boolean; }; export const ActionBar = ({ @@ -147,7 +154,9 @@ export const ActionBar = ({ protectedBranchPolicyName, importedBy, isPITEnabled = false, - usedBySecretSyncs + usedBySecretSyncs, + onRequestAccess, + hasPathPolicies }: Props) => { const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([ "addFolder", @@ -180,6 +189,7 @@ export const ActionBar = ({ const isMultiSelectActive = Boolean(Object.keys(selectedSecrets).length); const { currentWorkspace } = useWorkspace(); + const { permission } = useProjectPermission(); const handleFolderCreate = async (folderName: string, description: string | null) => { try { @@ -807,27 +817,53 @@ export const ActionBar = ({
- - {(isAllowed) => ( - - )} - + {hasPathPolicies ? ( + + ) : ( + + {(isAllowed) => ( + + )} + + )} handlePopUpToggle("misc", isOpen)} diff --git a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretDropzone/SecretDropzone.tsx b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretDropzone/SecretDropzone.tsx index b0f61dc89..92df89cae 100644 --- a/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretDropzone/SecretDropzone.tsx +++ b/frontend/src/pages/secret-manager/SecretDashboardPage/components/SecretDropzone/SecretDropzone.tsx @@ -262,7 +262,7 @@ export const SecretDropzone = ({ className={twMerge( "relative mx-0.5 mb-4 mt-4 flex cursor-pointer items-center justify-center rounded-md bg-mineshaft-900 px-2 py-4 text-sm text-mineshaft-200 opacity-60 outline-dashed outline-2 outline-chicago-600 duration-200 hover:opacity-100", isDragActive && "opacity-100", - !isSmaller && "mx-auto w-full max-w-3xl flex-col space-y-4 py-20", + !isSmaller && "mx-auto mt-40 w-full max-w-3xl flex-col space-y-4 py-20", isLoading && "bg-bunker-800" )} >