feature: add access requests to single env view, with general UI improvements

This commit is contained in:
Scott Wilson
2025-07-16 12:16:13 -07:00
parent d5043fdba4
commit 67ec00d46b
8 changed files with 323 additions and 37 deletions

View File

@@ -40,7 +40,7 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
status: null, status: null,
trial_end: null, trial_end: null,
has_used_trial: true, has_used_trial: true,
secretApproval: false, secretApproval: true,
secretRotation: false, secretRotation: false,
caCrl: false, caCrl: false,
instanceUserManagement: false, instanceUserManagement: false,

View File

@@ -17,8 +17,8 @@ export type SubscriptionPlan = {
rbac: boolean; rbac: boolean;
secretVersioning: boolean; secretVersioning: boolean;
slug: string; slug: string;
secretApproval: string; secretApproval: boolean;
secretRotation: string; secretRotation: boolean;
tier: number; tier: number;
workspaceLimit: number; workspaceLimit: number;
workspacesUsed: number; workspacesUsed: number;

View File

@@ -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]);
};

View File

@@ -79,10 +79,14 @@ type TSecretPermissionForm = z.infer<typeof secretPermissionSchema>;
export const SpecificPrivilegeSecretForm = ({ export const SpecificPrivilegeSecretForm = ({
privilege, privilege,
policies, policies,
onClose onClose,
selectedActions = [],
secretPath: initialSecretPath
}: { }: {
privilege?: TProjectUserPrivilege; privilege?: TProjectUserPrivilege;
policies?: TAccessApprovalPolicy[]; policies?: TAccessApprovalPolicy[];
selectedActions?: ProjectPermissionActions[];
secretPath?: string;
onClose?: () => void; onClose?: () => void;
}) => { }) => {
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
@@ -126,10 +130,11 @@ export const SpecificPrivilegeSecretForm = ({
} }
: { : {
environmentSlug: currentWorkspace.environments?.[0]?.slug, environmentSlug: currentWorkspace.environments?.[0]?.slug,
read: false, secretPath: initialSecretPath,
edit: false, read: selectedActions.includes(ProjectPermissionActions.Read),
create: false, edit: selectedActions.includes(ProjectPermissionActions.Edit),
delete: false, create: selectedActions.includes(ProjectPermissionActions.Create),
delete: selectedActions.includes(ProjectPermissionActions.Delete),
temporaryAccess: { temporaryAccess: {
isTemporary: false isTemporary: false
} }
@@ -281,6 +286,8 @@ export const SpecificPrivilegeSecretForm = ({
isDisabled={isMemberEditDisabled} isDisabled={isMemberEditDisabled}
className="w-full bg-mineshaft-900 hover:bg-mineshaft-800" className="w-full bg-mineshaft-900 hover:bg-mineshaft-800"
onValueChange={(e) => onChange(e)} onValueChange={(e) => onChange(e)}
position="popper"
dropdownContainerClassName="max-w-none"
> >
{currentWorkspace?.environments?.map(({ slug, id, name }) => ( {currentWorkspace?.environments?.map(({ slug, id, name }) => (
<SelectItem value={slug} key={id}> <SelectItem value={slug} key={id}>
@@ -309,6 +316,8 @@ export const SpecificPrivilegeSecretForm = ({
className="w-full hover:bg-mineshaft-800" className="w-full hover:bg-mineshaft-800"
placeholder="Select a secret path" placeholder="Select a secret path"
onValueChange={(e) => field.onChange(e)} onValueChange={(e) => field.onChange(e)}
position="popper"
dropdownContainerClassName="max-w-none"
> >
{selectablePaths.map((path) => ( {selectablePaths.map((path) => (
<SelectItem value={path} key={path}> <SelectItem value={path} key={path}>
@@ -636,6 +645,7 @@ export const SpecificPrivilegeSecretForm = ({
{!!policies && ( {!!policies && (
<Button <Button
type="submit" type="submit"
variant="outline_bg"
isLoading={privilegeForm.formState.isSubmitting || requestAccess.isPending} isLoading={privilegeForm.formState.isSubmitting || requestAccess.isPending}
isDisabled={ isDisabled={
isMemberEditDisabled || isMemberEditDisabled ||
@@ -647,7 +657,7 @@ export const SpecificPrivilegeSecretForm = ({
className="mt-4" className="mt-4"
leftIcon={<FontAwesomeIcon icon={faLockOpen} />} leftIcon={<FontAwesomeIcon icon={faLockOpen} />}
> >
Request access Request Access
</Button> </Button>
)} )}
</form> </form>

View File

@@ -1,15 +1,22 @@
import { Modal, ModalContent } from "@app/components/v2"; 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 { TAccessApprovalPolicy } from "@app/hooks/api/types";
import { SpecificPrivilegeSecretForm } from "@app/pages/project/AccessControlPage/components/MembersTab/components/MemberRoleForm/SpecificPrivilegeSection"; import { SpecificPrivilegeSecretForm } from "@app/pages/project/AccessControlPage/components/MembersTab/components/MemberRoleForm/SpecificPrivilegeSection";
export const RequestAccessModal = ({ export const RequestAccessModal = ({
isOpen, isOpen,
onOpenChange, onOpenChange,
policies policies,
shouldShowBanner,
...props
}: { }: {
isOpen: boolean; isOpen: boolean;
onOpenChange: (isOpen: boolean) => void; onOpenChange: (isOpen: boolean) => void;
policies: TAccessApprovalPolicy[]; policies: TAccessApprovalPolicy[];
selectedActions?: ProjectPermissionActions[];
secretPath?: string;
shouldShowBanner?: boolean;
}) => { }) => {
return ( return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}> <Modal isOpen={isOpen} onOpenChange={onOpenChange}>
@@ -18,7 +25,21 @@ export const RequestAccessModal = ({
title="Request Access" title="Request Access"
subTitle="Request access to any secrets and resources based on the predefined policies." subTitle="Request access to any secrets and resources based on the predefined policies."
> >
<SpecificPrivilegeSecretForm onClose={() => onOpenChange(false)} policies={policies} /> {shouldShowBanner && (
<NoticeBannerV2
className="mb-3"
title="You do not have permission to perform this action"
>
<p className="text-sm text-mineshaft-300">
Request access to gain access to this action.
</p>
</NoticeBannerV2>
)}
<SpecificPrivilegeSecretForm
onClose={() => onOpenChange(false)}
policies={policies}
{...props}
/>
</ModalContent> </ModalContent>
</Modal> </Modal>
); );

View File

@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from "react";
import { Helmet } from "react-helmet"; import { Helmet } from "react-helmet";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { subject } from "@casl/ability"; 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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { useNavigate, useParams, useSearch } from "@tanstack/react-router"; import { useNavigate, useParams, useSearch } from "@tanstack/react-router";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
@@ -10,10 +10,12 @@ import { twMerge } from "tailwind-merge";
import { createNotification } from "@app/components/notifications"; import { createNotification } from "@app/components/notifications";
import { PermissionDeniedBanner } from "@app/components/permissions"; import { PermissionDeniedBanner } from "@app/components/permissions";
import { import {
Button,
Checkbox, Checkbox,
ContentLoader, ContentLoader,
Modal, Modal,
ModalContent, ModalContent,
PageHeader,
Pagination, Pagination,
Tooltip Tooltip
} from "@app/components/v2"; } from "@app/components/v2";
@@ -46,7 +48,9 @@ import { useGetProjectSecretsDetails } from "@app/hooks/api/dashboard";
import { DashboardSecretsOrderBy } from "@app/hooks/api/dashboard/types"; import { DashboardSecretsOrderBy } from "@app/hooks/api/dashboard/types";
import { useGetFolderCommitsCount } from "@app/hooks/api/folderCommits"; import { useGetFolderCommitsCount } from "@app/hooks/api/folderCommits";
import { OrderByDirection } from "@app/hooks/api/generic/types"; import { OrderByDirection } from "@app/hooks/api/generic/types";
import { usePathAccessPolicies } from "@app/hooks/usePathAccessPolicies";
import { hasSecretReadValueOrDescribePermission } from "@app/lib/fn/permission"; 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 { SecretRotationListView } from "@app/pages/secret-manager/SecretDashboardPage/components/SecretRotationListView";
import { SecretTableResourceCount } from "../OverviewPage/components/SecretTableResourceCount"; import { SecretTableResourceCount } from "../OverviewPage/components/SecretTableResourceCount";
@@ -114,7 +118,10 @@ const Page = () => {
const [snapshotId, setSnapshotId] = useState<string | null>(null); const [snapshotId, setSnapshotId] = useState<string | null>(null);
const isRollbackMode = Boolean(snapshotId); const isRollbackMode = Boolean(snapshotId);
const { popUp, handlePopUpClose, handlePopUpToggle } = usePopUp(["snapshots"] as const); const { popUp, handlePopUpClose, handlePopUpToggle, handlePopUpOpen } = usePopUp([
"snapshots",
"requestAccess"
] as const);
// env slug // env slug
const workspaceId = currentWorkspace?.id || ""; 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( const canReadSecretValue = hasSecretReadValueOrDescribePermission(
permission, permission,
ProjectPermissionSecretActions.ReadValue, ProjectPermissionSecretActions.ReadValue,
@@ -257,6 +284,8 @@ const Page = () => {
permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags) ? workspaceId : "" permission.can(ProjectPermissionActions.Read, ProjectPermissionSub.Tags) ? workspaceId : ""
); );
const { pathPolicies, hasPathPolicies } = usePathAccessPolicies({ secretPath, environment });
const { data: boardPolicy } = useGetSecretApprovalPolicyOfABoard({ const { data: boardPolicy } = useGetSecretApprovalPolicyOfABoard({
workspaceId, workspaceId,
environment, environment,
@@ -476,8 +505,55 @@ const Page = () => {
setFilter(defaultFilterState); setFilter(defaultFilterState);
setDebouncedSearchFilter(""); setDebouncedSearchFilter("");
}; };
return ( return (
<div className="container mx-auto flex max-w-7xl flex-col text-mineshaft-50 dark:[color-scheme:dark]"> <div className="container mx-auto flex max-w-7xl flex-col text-mineshaft-50 dark:[color-scheme:dark]">
<PageHeader
title={
currentWorkspace.environments.find((env) => env.slug === environment)?.name ?? environment
}
description={
<p className="text-md text-bunker-300">
Inject your secrets using
<a
className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
href="https://infisical.com/docs/cli/overview"
target="_blank"
rel="noopener noreferrer"
>
Infisical CLI
</a>
,
<a
className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
href="https://infisical.com/docs/documentation/getting-started/api"
target="_blank"
rel="noopener noreferrer"
>
Infisical API
</a>
,
<a
className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
href="https://infisical.com/docs/sdks/overview"
target="_blank"
rel="noopener noreferrer"
>
Infisical SDKs
</a>
, and
<a
className="ml-1 text-mineshaft-300 underline decoration-primary-800 underline-offset-4 duration-200 hover:text-mineshaft-100 hover:decoration-primary-600"
href="https://infisical.com/docs/documentation/getting-started/introduction"
target="_blank"
rel="noopener noreferrer"
>
more
</a>
.
</p>
}
/>
<SecretV2MigrationSection /> <SecretV2MigrationSection />
{!isRollbackMode ? ( {!isRollbackMode ? (
<> <>
@@ -500,8 +576,15 @@ const Page = () => {
importedBy={importedBy} importedBy={importedBy}
usedBySecretSyncs={usedBySecretSyncs} usedBySecretSyncs={usedBySecretSyncs}
isPITEnabled={isPITEnabled} isPITEnabled={isPITEnabled}
hasPathPolicies={hasPathPolicies}
onRequestAccess={(params) => handlePopUpOpen("requestAccess", params)}
/> />
<div className="thin-scrollbar mt-3 overflow-y-auto overflow-x-hidden rounded-md rounded-b-none bg-mineshaft-800 text-left text-sm text-bunker-300"> <div
className={twMerge(
"thin-scrollbar mt-3 overflow-y-auto overflow-x-hidden rounded-md bg-mineshaft-800 text-left text-sm text-bunker-300",
isNotEmpty && "rounded-b-none"
)}
>
<div className="flex flex-col" id="dashboard"> <div className="flex flex-col" id="dashboard">
{isNotEmpty && ( {isNotEmpty && (
<div <div
@@ -548,6 +631,68 @@ const Page = () => {
<div className="flex-grow px-4 py-2">Value</div> <div className="flex-grow px-4 py-2">Value</div>
</div> </div>
)} )}
{hasPathPolicies &&
// eslint-disable-next-line no-nested-ternary
(!canReadSecret ? (
<div
className={twMerge(
"flex border-l-2 border-l-primary bg-mineshaft-700 px-4 py-2",
isNotEmpty ? "border-b border-b-mineshaft-600" : ""
)}
>
<div className="flex items-center text-sm">
<FontAwesomeIcon
icon={faInfoCircle}
className="ml-[0.15rem] mr-[1.65rem] text-primary"
/>
<span>You do not permission to read secrets for this path</span>
</div>
<Button
variant="outline_bg"
size="xs"
className="ml-auto"
onClick={() =>
handlePopUpOpen("requestAccess", {
actions: [ProjectPermissionActions.Read],
shouldShowBanner: false
})
}
>
Request Access
</Button>
</div>
) : !canEditSecrets || !canDeleteSecrets ? (
<div className="flex border-b border-l-2 border-b-mineshaft-600 border-l-primary bg-mineshaft-700 px-4 py-2">
<div className="flex items-center text-sm">
<FontAwesomeIcon
icon={faInfoCircle}
className="ml-[0.15rem] mr-[1.65rem] text-primary"
/>
<span>
You do not permission to {!canEditSecrets ? "edit" : ""}
{!canEditSecrets && !canDeleteSecrets ? " or " : ""}
{!canDeleteSecrets ? "delete" : ""} secrets for this path
</span>
</div>
<Button
variant="outline_bg"
size="xs"
className="ml-auto"
onClick={() =>
handlePopUpOpen("requestAccess", {
actions: [
...(!canEditSecrets ? [ProjectPermissionActions.Edit] : []),
...(!canDeleteSecrets ? [ProjectPermissionActions.Delete] : [])
],
shouldShowBanner: false
})
}
>
Request Access
</Button>
</div>
) : null)}
{canReadSecretImports && Boolean(imports?.length) && ( {canReadSecretImports && Boolean(imports?.length) && (
<SecretImportListView <SecretImportListView
searchTerm={debouncedSearchFilter} searchTerm={debouncedSearchFilter}
@@ -636,6 +781,18 @@ const Page = () => {
/> />
</ModalContent> </ModalContent>
</Modal> </Modal>
{!!pathPolicies && (
<RequestAccessModal
policies={pathPolicies}
isOpen={popUp.requestAccess.isOpen}
onOpenChange={() => {
handlePopUpClose("requestAccess");
}}
selectedActions={popUp.requestAccess.data?.actions}
shouldShowBanner={popUp.requestAccess.data?.shouldShowBanner}
secretPath={pathPolicies?.[0]?.secretPath}
/>
)}
<SecretDropzone <SecretDropzone
environment={environment} environment={environment}
workspaceId={workspaceId} workspaceId={workspaceId}

View File

@@ -53,11 +53,13 @@ import {
ProjectPermissionActions, ProjectPermissionActions,
ProjectPermissionDynamicSecretActions, ProjectPermissionDynamicSecretActions,
ProjectPermissionSub, ProjectPermissionSub,
useProjectPermission,
useSubscription, useSubscription,
useWorkspace useWorkspace
} from "@app/context"; } from "@app/context";
import { import {
ProjectPermissionCommitsActions, ProjectPermissionCommitsActions,
ProjectPermissionSecretActions,
ProjectPermissionSecretRotationActions ProjectPermissionSecretRotationActions
} from "@app/context/ProjectPermissionContext/types"; } from "@app/context/ProjectPermissionContext/types";
import { usePopUp } from "@app/hooks"; import { usePopUp } from "@app/hooks";
@@ -127,6 +129,11 @@ type Props = {
}[]; }[];
}[]; }[];
isPITEnabled: boolean; isPITEnabled: boolean;
onRequestAccess: (params: {
actions: ProjectPermissionActions[];
shouldShowBanner: boolean;
}) => void;
hasPathPolicies: boolean;
}; };
export const ActionBar = ({ export const ActionBar = ({
@@ -147,7 +154,9 @@ export const ActionBar = ({
protectedBranchPolicyName, protectedBranchPolicyName,
importedBy, importedBy,
isPITEnabled = false, isPITEnabled = false,
usedBySecretSyncs usedBySecretSyncs,
onRequestAccess,
hasPathPolicies
}: Props) => { }: Props) => {
const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([ const { handlePopUpOpen, handlePopUpToggle, handlePopUpClose, popUp } = usePopUp([
"addFolder", "addFolder",
@@ -180,6 +189,7 @@ export const ActionBar = ({
const isMultiSelectActive = Boolean(Object.keys(selectedSecrets).length); const isMultiSelectActive = Boolean(Object.keys(selectedSecrets).length);
const { currentWorkspace } = useWorkspace(); const { currentWorkspace } = useWorkspace();
const { permission } = useProjectPermission();
const handleFolderCreate = async (folderName: string, description: string | null) => { const handleFolderCreate = async (folderName: string, description: string | null) => {
try { try {
@@ -807,27 +817,53 @@ export const ActionBar = ({
</ProjectPermissionCan> </ProjectPermissionCan>
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
<ProjectPermissionCan {hasPathPolicies ? (
I={ProjectPermissionActions.Create} <Button
a={subject(ProjectPermissionSub.Secrets, { variant="outline_bg"
environment, leftIcon={<FontAwesomeIcon icon={faPlus} />}
secretPath, onClick={() =>
secretName: "*", permission.can(
secretTags: ["*"] ProjectPermissionSecretActions.Create,
})} subject(ProjectPermissionSub.Secrets, {
> environment,
{(isAllowed) => ( secretPath,
<Button secretName: "*",
variant="outline_bg" secretTags: ["*"]
leftIcon={<FontAwesomeIcon icon={faPlus} />} })
onClick={() => openPopUp(PopUpNames.CreateSecretForm)} )
className="h-10 rounded-r-none" ? openPopUp(PopUpNames.CreateSecretForm)
isDisabled={!isAllowed} : onRequestAccess({
> actions: [ProjectPermissionActions.Create],
Add Secret shouldShowBanner: true
</Button> })
)} }
</ProjectPermissionCan> className="h-10 rounded-r-none"
>
Add Secret
</Button>
) : (
<ProjectPermissionCan
I={ProjectPermissionActions.Create}
a={subject(ProjectPermissionSub.Secrets, {
environment,
secretPath,
secretName: "*",
secretTags: ["*"]
})}
>
{(isAllowed) => (
<Button
variant="outline_bg"
leftIcon={<FontAwesomeIcon icon={faPlus} />}
onClick={() => openPopUp(PopUpNames.CreateSecretForm)}
className="h-10 rounded-r-none"
isDisabled={!isAllowed}
>
Add Secret
</Button>
)}
</ProjectPermissionCan>
)}
<DropdownMenu <DropdownMenu
open={popUp.misc.isOpen} open={popUp.misc.isOpen}
onOpenChange={(isOpen) => handlePopUpToggle("misc", isOpen)} onOpenChange={(isOpen) => handlePopUpToggle("misc", isOpen)}

View File

@@ -262,7 +262,7 @@ export const SecretDropzone = ({
className={twMerge( 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", "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", 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" isLoading && "bg-bunker-800"
)} )}
> >