diff --git a/backend/src/ee/routes/v1/access-approval-request-router.ts b/backend/src/ee/routes/v1/access-approval-request-router.ts index b0914d5c4..d90f28184 100644 --- a/backend/src/ee/routes/v1/access-approval-request-router.ts +++ b/backend/src/ee/routes/v1/access-approval-request-router.ts @@ -154,7 +154,8 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv requestId: z.string().trim() }), body: z.object({ - status: z.enum([ApprovalStatus.APPROVED, ApprovalStatus.REJECTED]) + status: z.enum([ApprovalStatus.APPROVED, ApprovalStatus.REJECTED]), + bypassReason: z.string().min(10).max(1000).optional() }), response: { 200: z.object({ @@ -170,7 +171,8 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv actorOrgId: req.permission.orgId, actorAuthMethod: req.permission.authMethod, requestId: req.params.requestId, - status: req.body.status + status: req.body.status, + bypassReason: req.body.bypassReason }); return { review }; diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts index 2b2758b2e..017356a5d 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts @@ -6,6 +6,7 @@ import { getConfig } from "@app/lib/config/env"; import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { ms } from "@app/lib/ms"; import { alphaNumericNanoId } from "@app/lib/nanoid"; +import { EnforcementLevel } from "@app/lib/types"; import { triggerWorkflowIntegrationNotification } from "@app/lib/workflow-integrations/trigger-notification"; import { TriggerFeature } from "@app/lib/workflow-integrations/types"; import { TKmsServiceFactory } from "@app/services/kms/kms-service"; @@ -22,6 +23,7 @@ import { TAccessApprovalPolicyApproverDALFactory } from "../access-approval-poli import { TAccessApprovalPolicyDALFactory } from "../access-approval-policy/access-approval-policy-dal"; import { TGroupDALFactory } from "../group/group-dal"; import { TPermissionServiceFactory } from "../permission/permission-service"; +import { ProjectPermissionApprovalActions, ProjectPermissionSub } from "../permission/project-permission"; import { TProjectUserAdditionalPrivilegeDALFactory } from "../project-user-additional-privilege/project-user-additional-privilege-dal"; import { ProjectUserAdditionalPrivilegeTemporaryMode } from "../project-user-additional-privilege/project-user-additional-privilege-types"; import { TAccessApprovalRequestDALFactory } from "./access-approval-request-dal"; @@ -323,26 +325,22 @@ export const accessApprovalRequestServiceFactory = ({ status, actorId, actorAuthMethod, - actorOrgId + actorOrgId, + bypassReason }: TReviewAccessRequestDTO) => { const accessApprovalRequest = await accessApprovalRequestDAL.findById(requestId); if (!accessApprovalRequest) { throw new NotFoundError({ message: `Secret approval request with ID '${requestId}' not found` }); } - const { policy } = accessApprovalRequest; + const { policy, environment } = accessApprovalRequest; if (policy.deletedAt) { throw new BadRequestError({ message: "The policy associated with this access request has been deleted." }); } - if (!policy.allowedSelfApprovals && actorId === accessApprovalRequest.requestedByUserId) { - throw new BadRequestError({ - message: "Failed to review access approval request. Users are not authorized to review their own request." - }); - } - const { membership, hasRole } = await permissionService.getProjectPermission({ + const { membership, hasRole, permission } = await permissionService.getProjectPermission({ actor, actorId, projectId: accessApprovalRequest.projectId, @@ -355,6 +353,20 @@ export const accessApprovalRequestServiceFactory = ({ throw new ForbiddenRequestError({ message: "You are not a member of this project" }); } + const isSelfApproval = actorId === accessApprovalRequest.requestedByUserId; + const isSoftEnforcement = policy.enforcementLevel === EnforcementLevel.Soft; + const canBypassApproval = permission.can( + ProjectPermissionApprovalActions.AllowAccessBypass, + ProjectPermissionSub.SecretApproval + ); + const cannotBypassUnderSoftEnforcement = !(isSoftEnforcement && canBypassApproval); + + if (!policy.allowedSelfApprovals && isSelfApproval && cannotBypassUnderSoftEnforcement) { + throw new BadRequestError({ + message: "Failed to review access approval request. Users are not authorized to review their own request." + }); + } + if ( !hasRole(ProjectMembershipRole.Admin) && accessApprovalRequest.requestedByUserId !== actorId && // The request wasn't made by the current user @@ -363,21 +375,49 @@ export const accessApprovalRequestServiceFactory = ({ throw new ForbiddenRequestError({ message: "You are not authorized to approve this request" }); } + const project = await projectDAL.findById(accessApprovalRequest.projectId); + if (!project) { + throw new NotFoundError({ message: "The project associated with this access request was not found." }); + } + const existingReviews = await accessApprovalRequestReviewerDAL.find({ requestId: accessApprovalRequest.id }); if (existingReviews.some((review) => review.status === ApprovalStatus.REJECTED)) { throw new BadRequestError({ message: "The request has already been rejected by another reviewer" }); } const reviewStatus = await accessApprovalRequestReviewerDAL.transaction(async (tx) => { - const review = await accessApprovalRequestReviewerDAL.findOne( + const isBreakGlassApprovalAttempt = + policy.enforcementLevel === EnforcementLevel.Soft && + actorId === accessApprovalRequest.requestedByUserId && + status === ApprovalStatus.APPROVED; + + let reviewForThisActorProcessing: { + id: string; + requestId: string; + reviewerUserId: string; + status: string; + createdAt: Date; + updatedAt: Date; + }; + + const existingReviewByActorInTx = await accessApprovalRequestReviewerDAL.findOne( { requestId: accessApprovalRequest.id, reviewerUserId: actorId }, tx ); - if (!review) { - const newReview = await accessApprovalRequestReviewerDAL.create( + + // Check if review exists for actor + if (existingReviewByActorInTx) { + // Check if breakglass re-approval + if (isBreakGlassApprovalAttempt && existingReviewByActorInTx.status === ApprovalStatus.APPROVED) { + reviewForThisActorProcessing = existingReviewByActorInTx; + } else { + throw new BadRequestError({ message: "You have already reviewed this request" }); + } + } else { + reviewForThisActorProcessing = await accessApprovalRequestReviewerDAL.create( { status, requestId: accessApprovalRequest.id, @@ -385,19 +425,26 @@ export const accessApprovalRequestServiceFactory = ({ }, tx ); + } - const allReviews = [...existingReviews, newReview]; + const otherReviews = existingReviews.filter((er) => er.reviewerUserId !== actorId); + const allUniqueReviews = [...otherReviews, reviewForThisActorProcessing]; - const approvedReviews = allReviews.filter((r) => r.status === ApprovalStatus.APPROVED); + const approvedReviews = allUniqueReviews.filter((r) => r.status === ApprovalStatus.APPROVED); + const meetsStandardApprovalThreshold = approvedReviews.length >= policy.approvals; - // approvals is the required number of approvals. If the number of approved reviews is equal to the number of required approvals, then the request is approved. - if (approvedReviews.length === policy.approvals) { + if ( + reviewForThisActorProcessing.status === ApprovalStatus.APPROVED && + (meetsStandardApprovalThreshold || isBreakGlassApprovalAttempt) + ) { + const currentRequestState = await accessApprovalRequestDAL.findById(accessApprovalRequest.id, tx); + let privilegeIdToSet = currentRequestState?.privilegeId || null; + + if (!privilegeIdToSet) { if (accessApprovalRequest.isTemporary && !accessApprovalRequest.temporaryRange) { throw new BadRequestError({ message: "Temporary range is required for temporary access" }); } - let privilegeId: string | null = null; - if (!accessApprovalRequest.isTemporary && !accessApprovalRequest.temporaryRange) { // Permanent access const privilege = await additionalPrivilegeDAL.create( @@ -409,7 +456,7 @@ export const accessApprovalRequestServiceFactory = ({ }, tx ); - privilegeId = privilege.id; + privilegeIdToSet = privilege.id; } else { // Temporary access const relativeTempAllocatedTimeInMs = ms(accessApprovalRequest.temporaryRange!); @@ -421,23 +468,57 @@ export const accessApprovalRequestServiceFactory = ({ projectId: accessApprovalRequest.projectId, slug: `requested-privilege-${slugify(alphaNumericNanoId(12))}`, permissions: JSON.stringify(accessApprovalRequest.permissions), - isTemporary: true, + isTemporary: true, // Explicitly set to true for the privilege temporaryMode: ProjectUserAdditionalPrivilegeTemporaryMode.Relative, temporaryRange: accessApprovalRequest.temporaryRange!, temporaryAccessStartTime: startTime, - temporaryAccessEndTime: new Date(new Date(startTime).getTime() + relativeTempAllocatedTimeInMs) + temporaryAccessEndTime: new Date(startTime.getTime() + relativeTempAllocatedTimeInMs) }, tx ); - privilegeId = privilege.id; + privilegeIdToSet = privilege.id; } - - await accessApprovalRequestDAL.updateById(accessApprovalRequest.id, { privilegeId }, tx); + await accessApprovalRequestDAL.updateById(accessApprovalRequest.id, { privilegeId: privilegeIdToSet }, tx); } - - return newReview; } - throw new BadRequestError({ message: "You have already reviewed this request" }); + + // Send notification if this was a breakglass approval + if (isBreakGlassApprovalAttempt) { + const cfg = getConfig(); + const actingUser = await userDAL.findById(actorId, tx); + + if (actingUser) { + const policyApproverUserIds = policy.approvers + .map((ap) => ap.userId) + .filter((id): id is string => typeof id === "string"); + + if (policyApproverUserIds.length > 0) { + const approverUsersForEmail = await userDAL.find({ $in: { id: policyApproverUserIds } }, { tx }); + const recipientEmails = approverUsersForEmail + .map((appUser) => appUser.email) + .filter((email): email is string => !!email); + + if (recipientEmails.length > 0) { + await smtpService.sendMail({ + recipients: recipientEmails, + subjectLine: "Infisical Secret Access Policy Bypassed", + substitutions: { + projectName: project.name, + requesterFullName: `${actingUser.firstName} ${actingUser.lastName}`, + requesterEmail: actingUser.email, + bypassReason: bypassReason || "No reason provided", + secretPath: policy.secretPath || "/", + environment, + approvalUrl: `${cfg.SITE_URL}/secret-manager/${project.id}/approval`, + requestType: "access" + }, + template: SmtpTemplates.AccessSecretRequestBypassed + }); + } + } + } + } + return reviewForThisActorProcessing; }); return reviewStatus; diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-types.ts b/backend/src/ee/services/access-approval-request/access-approval-request-types.ts index 51a5e0ca2..162f8b3c6 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-types.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-types.ts @@ -17,6 +17,8 @@ export type TGetAccessRequestCountDTO = { export type TReviewAccessRequestDTO = { requestId: string; status: ApprovalStatus; + envName?: string; + bypassReason?: string; } & Omit; export type TCreateAccessApprovalRequestDTO = { diff --git a/backend/src/ee/services/permission/default-roles.ts b/backend/src/ee/services/permission/default-roles.ts index 9461227da..2a422d55f 100644 --- a/backend/src/ee/services/permission/default-roles.ts +++ b/backend/src/ee/services/permission/default-roles.ts @@ -61,7 +61,8 @@ const buildAdminPermissionRules = () => { ProjectPermissionApprovalActions.Edit, ProjectPermissionApprovalActions.Create, ProjectPermissionApprovalActions.Delete, - ProjectPermissionApprovalActions.AllowChangeBypass + ProjectPermissionApprovalActions.AllowChangeBypass, + ProjectPermissionApprovalActions.AllowAccessBypass ], ProjectPermissionSub.SecretApproval ); diff --git a/backend/src/ee/services/permission/project-permission.ts b/backend/src/ee/services/permission/project-permission.ts index 27cd33855..5d06e085d 100644 --- a/backend/src/ee/services/permission/project-permission.ts +++ b/backend/src/ee/services/permission/project-permission.ts @@ -39,7 +39,8 @@ export enum ProjectPermissionApprovalActions { Create = "create", Edit = "edit", Delete = "delete", - AllowChangeBypass = "allow-change-bypass" + AllowChangeBypass = "allow-change-bypass", + AllowAccessBypass = "allow-access-bypass" } export enum ProjectPermissionCmekActions { diff --git a/backend/src/services/smtp/emails/SecretApprovalRequestBypassedTemplate.tsx b/backend/src/services/smtp/emails/SecretApprovalRequestBypassedTemplate.tsx index bad823bd3..a07110aa3 100644 --- a/backend/src/services/smtp/emails/SecretApprovalRequestBypassedTemplate.tsx +++ b/backend/src/services/smtp/emails/SecretApprovalRequestBypassedTemplate.tsx @@ -12,6 +12,7 @@ interface SecretApprovalRequestBypassedTemplateProps environment: string; bypassReason: string; approvalUrl: string; + requestType: "change" | "access"; } export const SecretApprovalRequestBypassedTemplate = ({ @@ -22,7 +23,8 @@ export const SecretApprovalRequestBypassedTemplate = ({ secretPath, environment, bypassReason, - approvalUrl + approvalUrl, + requestType = "change" }: SecretApprovalRequestBypassedTemplateProps) => { return ( {requesterEmail} - ) has merged a secret to {secretPath} in the {environment} environment - without obtaining the required approval. + ) has {requestType === "change" ? "merged" : "accessed"} a secret {requestType === "change" ? "to" : "in"}{" "} + {secretPath} in the {environment} environment without obtaining the required + approval. The following reason was provided for bypassing the policy: " diff --git a/docs/documentation/platform/access-controls/access-requests.mdx b/docs/documentation/platform/access-controls/access-requests.mdx index 76cc4b74e..58b21d4eb 100644 --- a/docs/documentation/platform/access-controls/access-requests.mdx +++ b/docs/documentation/platform/access-controls/access-requests.mdx @@ -3,10 +3,10 @@ title: "Access Requests" description: "Learn how to request access to sensitive resources in Infisical." --- -In certain situations, developers need to expand their access to a certain new project or a sensitive environment. For those use cases, it is helpful to utilize Infisical's **Access Requests** functionality. +In certain situations, developers need to expand their access to a certain new project or a sensitive environment. For those use cases, it is helpful to utilize Infisical's **Access Requests** functionality. -This functionality works in the following way: -1. A project administrator sets up an access policy that assigns access managers (also known as eligible approvers) to a certain sensitive folder or environment. +This functionality works in the following way: +1. A project administrator sets up an access policy that assigns access managers (also known as eligible approvers) to a certain sensitive folder or environment. ![Create Access Request Policy Modal](/images/platform/access-controls/create-access-request-policy.png) ![Access Request Policies](/images/platform/access-controls/access-request-policies.png) @@ -19,9 +19,8 @@ This functionality works in the following way: ![Access Request Bypass](/images/platform/access-controls/access-request-bypass.png) - If the access request matches with a policy that has a **Soft** enforcement level, the requester may bypass the policy and get access to the resource without full approval. + If the access request matches with a policy that allows break-glass approval bypasses, the requester may bypass the policy and get access to the resource without full approval. -5. As soon as the request is approved, developer is able to access the sought resources. +5. As soon as the request is approved, developer is able to access the sought resources. ![Access Request Dashboard](/images/platform/access-controls/access-requests-completed.png) - diff --git a/docs/documentation/platform/pr-workflows.mdx b/docs/documentation/platform/pr-workflows.mdx index ffa85f6c5..610d1fd47 100644 --- a/docs/documentation/platform/pr-workflows.mdx +++ b/docs/documentation/platform/pr-workflows.mdx @@ -33,6 +33,10 @@ First, you would need to create a set of policies for a certain environment. In The enforcement level determines how strict the policy is. A **Hard** enforcement level means that any change that matches the policy will need full approval prior merging. A **Soft** enforcement level allows for break glass functionality on the request. If a change request is bypassed, the approvers will be notified via email. + + Enabling the "Bypass Approvals" toggle during policy creation will create a **Soft** enforcement level. Disabling the toggle makes the enforcement level **Hard**. + + ### Self approvals If the **Self Approvals** option is enabled, users who are designated as approvers on the policy can approve requests that they themselves have submitted. diff --git a/docs/images/platform/pr-workflows/create-change-policy.png b/docs/images/platform/pr-workflows/create-change-policy.png index afe945b0a..fabcb6716 100644 Binary files a/docs/images/platform/pr-workflows/create-change-policy.png and b/docs/images/platform/pr-workflows/create-change-policy.png differ diff --git a/docs/internals/permissions/project-permissions.mdx b/docs/internals/permissions/project-permissions.mdx index dabe0e786..8a12532a4 100644 --- a/docs/internals/permissions/project-permissions.mdx +++ b/docs/internals/permissions/project-permissions.mdx @@ -178,13 +178,14 @@ Supports conditions and permission inversion #### Subject: `secret-approval` -| Action | Description | -| --------------------- | ---------------------------------------------------------------------------- | -| `read` | View approval policies and requests | -| `create` | Create new approval policies | -| `edit` | Modify approval policies | -| `delete` | Remove approval policies | -| `allow-change-bypass` | Allow request creators to bypass policy in break-glass situations | +| Action | Description | +| --------------------- | ----------------------------------------------------------------------------------- | +| `read` | View approval policies and requests | +| `create` | Create new approval policies | +| `edit` | Modify approval policies | +| `delete` | Remove approval policies | +| `allow-change-bypass` | Allow request creators to merge changes without approval in break-glass situations | +| `allow-access-bypass` | Allow request creators to access secrets without approval in break-glass situations | #### Subject: `secret-rotation` diff --git a/frontend/src/context/ProjectPermissionContext/types.ts b/frontend/src/context/ProjectPermissionContext/types.ts index 96ad12ac3..d49b8bc8e 100644 --- a/frontend/src/context/ProjectPermissionContext/types.ts +++ b/frontend/src/context/ProjectPermissionContext/types.ts @@ -29,7 +29,8 @@ export enum ProjectPermissionApprovalActions { Create = "create", Edit = "edit", Delete = "delete", - AllowChangeBypass = "allow-change-bypass" + AllowChangeBypass = "allow-change-bypass", + AllowAccessBypass = "allow-access-bypass" } export enum ProjectPermissionDynamicSecretActions { diff --git a/frontend/src/hooks/api/accessApproval/mutation.tsx b/frontend/src/hooks/api/accessApproval/mutation.tsx index 9fda2377a..c0da7af23 100644 --- a/frontend/src/hooks/api/accessApproval/mutation.tsx +++ b/frontend/src/hooks/api/accessApproval/mutation.tsx @@ -131,20 +131,27 @@ export const useReviewAccessRequest = () => { projectSlug: string; envSlug?: string; requestedBy?: string; + bypassReason?: string; } >({ - mutationFn: async ({ requestId, status }) => { + mutationFn: async ({ requestId, status, bypassReason }) => { const { data } = await apiRequest.post( `/api/v1/access-approvals/requests/${requestId}/review`, { - status + status, + bypassReason } ); return data; }, - onSuccess: (_, { projectSlug, envSlug, requestedBy }) => { + onSuccess: (_, { projectSlug, envSlug, requestedBy, bypassReason }) => { queryClient.invalidateQueries({ - queryKey: accessApprovalKeys.getAccessApprovalRequests(projectSlug, envSlug, requestedBy) + queryKey: accessApprovalKeys.getAccessApprovalRequests( + projectSlug, + envSlug, + requestedBy, + bypassReason + ) }); queryClient.invalidateQueries({ queryKey: accessApprovalKeys.getAccessApprovalRequestCount(projectSlug) diff --git a/frontend/src/hooks/api/accessApproval/queries.tsx b/frontend/src/hooks/api/accessApproval/queries.tsx index 1aa40c588..6370f4a59 100644 --- a/frontend/src/hooks/api/accessApproval/queries.tsx +++ b/frontend/src/hooks/api/accessApproval/queries.tsx @@ -19,8 +19,12 @@ export const accessApprovalKeys = { getAccessApprovalPolicyOfABoard: (workspaceId: string, environment: string) => [{ workspaceId, environment }, "access-approval-policy"] as const, - getAccessApprovalRequests: (projectSlug: string, envSlug?: string, requestedBy?: string) => - [{ projectSlug, envSlug, requestedBy }, "access-approvals-requests"] as const, + getAccessApprovalRequests: ( + projectSlug: string, + envSlug?: string, + requestedBy?: string, + bypassReason?: string + ) => [{ projectSlug, envSlug, requestedBy, bypassReason }, "access-approvals-requests"] as const, getAccessApprovalRequestCount: (projectSlug: string) => [{ projectSlug }, "access-approval-request-count"] as const }; diff --git a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx index cb3cd015d..b2429fdfc 100644 --- a/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx +++ b/frontend/src/pages/project/RoleDetailsBySlugPage/components/ProjectRoleModifySection.utils.tsx @@ -58,7 +58,8 @@ const ApprovalPolicyActionSchema = z.object({ [ProjectPermissionApprovalActions.Edit]: z.boolean().optional(), [ProjectPermissionApprovalActions.Delete]: z.boolean().optional(), [ProjectPermissionApprovalActions.Create]: z.boolean().optional(), - [ProjectPermissionApprovalActions.AllowChangeBypass]: z.boolean().optional() + [ProjectPermissionApprovalActions.AllowChangeBypass]: z.boolean().optional(), + [ProjectPermissionApprovalActions.AllowAccessBypass]: z.boolean().optional() }); const CmekPolicyActionSchema = z.object({ @@ -578,6 +579,7 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { const canEdit = action.includes(ProjectPermissionApprovalActions.Edit); const canRead = action.includes(ProjectPermissionApprovalActions.Read); const canChangeBypass = action.includes(ProjectPermissionApprovalActions.AllowChangeBypass); + const canAccessBypass = action.includes(ProjectPermissionApprovalActions.AllowAccessBypass); if (!formVal[subject]) formVal[subject] = [{}]; @@ -588,6 +590,8 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => { if (canRead) formVal[subject]![0][ProjectPermissionApprovalActions.Read] = true; if (canChangeBypass) formVal[subject]![0][ProjectPermissionApprovalActions.AllowChangeBypass] = true; + if (canAccessBypass) + formVal[subject]![0][ProjectPermissionApprovalActions.AllowAccessBypass] = true; return; } @@ -1212,7 +1216,8 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = { { label: "Create", value: ProjectPermissionApprovalActions.Create }, { label: "Modify", value: ProjectPermissionApprovalActions.Edit }, { label: "Remove", value: ProjectPermissionApprovalActions.Delete }, - { label: "Allow Change Bypass", value: ProjectPermissionApprovalActions.AllowChangeBypass } + { label: "Allow Change Bypass", value: ProjectPermissionApprovalActions.AllowChangeBypass }, + { label: "Allow Access Bypass", value: ProjectPermissionApprovalActions.AllowAccessBypass } ] }, [ProjectPermissionSub.SecretRotation]: { diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx index 9afe4795d..7d5e1540a 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -1,6 +1,6 @@ /* eslint-disable no-nested-ternary */ /* eslint-disable react/jsx-no-useless-fragment */ -import { useMemo, useState } from "react"; +import { useCallback, useMemo, useState } from "react"; import { faCheck, faCheckCircle, @@ -25,6 +25,7 @@ import { } from "@app/components/v2"; import { Badge } from "@app/components/v2/Badge"; import { + ProjectPermissionApprovalActions, ProjectPermissionMemberActions, ProjectPermissionSub, useProjectPermission, @@ -83,8 +84,9 @@ export const AccessApprovalRequest = ({ }) => { const [selectedRequest, setSelectedRequest] = useState< | (TAccessApprovalRequest & { - user: TWorkspaceUser["user"] | null; + user: { firstName?: string; lastName?: string; email?: string } | null; isRequestedByCurrentUser: boolean; + isSelfApproveAllowed: boolean; isApprover: boolean; }) | null @@ -100,6 +102,11 @@ export const AccessApprovalRequest = ({ const { subscription } = useSubscription(); const { currentWorkspace } = useWorkspace(); + const canBypassApprovalPermission = permission.can( + ProjectPermissionApprovalActions.AllowAccessBypass, + ProjectPermissionSub.SecretApproval + ); + const { data: members } = useGetWorkspaceUsers(projectId, true); const membersGroupById = members?.reduce>( (prev, curr) => ({ ...prev, [curr.user.id]: curr }), @@ -118,7 +125,7 @@ export const AccessApprovalRequest = ({ projectSlug }); - const { data: requests } = useGetAccessApprovalRequests({ + const { data: requests, refetch: refetchRequests } = useGetAccessApprovalRequests({ projectSlug, authorProjectMembershipId: requestedByFilter, envSlug: envFilter @@ -143,56 +150,105 @@ export const AccessApprovalRequest = ({ return requests; }, [requests, statusFilter, requestedByFilter, envFilter]); - const generateRequestDetails = (request: TAccessApprovalRequest) => { - const isReviewedByUser = request.reviewers.findIndex(({ member }) => member === user.id) !== -1; - const isRejectedByAnyone = request.reviewers.some( - ({ status }) => status === ApprovalStatus.REJECTED - ); - const isApprover = request.policy.approvers.indexOf(user.id || "") !== -1; - const isAccepted = request.isApproved; - const isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft; - const isRequestedByCurrentUser = request.requestedByUserId === user.id; - const isSelfApproveAllowed = request.policy.allowedSelfApprovals; - const userReviewStatus = request.reviewers.find(({ member }) => member === user.id)?.status; + const generateRequestDetails = useCallback( + (request: TAccessApprovalRequest) => { + const isReviewedByUser = + request.reviewers.findIndex(({ member }) => member === user.id) !== -1; + const isRejectedByAnyone = request.reviewers.some( + ({ status }) => status === ApprovalStatus.REJECTED + ); + const isApprover = request.policy.approvers.indexOf(user.id || "") !== -1; + const isAccepted = request.isApproved; + const isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft; + const isRequestedByCurrentUser = request.requestedByUserId === user.id; + const isSelfApproveAllowed = request.policy.allowedSelfApprovals; + const userReviewStatus = request.reviewers.find(({ member }) => member === user.id)?.status; - let displayData: { label: string; type: "primary" | "danger" | "success" } = { - label: "", - type: "primary" - }; - - const isExpired = - request.privilege && - request.isApproved && - new Date() > new Date(request.privilege.temporaryAccessEndTime || ("" as string)); - - if (isExpired) displayData = { label: "Access Expired", type: "danger" }; - else if (isAccepted) displayData = { label: "Access Granted", type: "success" }; - else if (isRejectedByAnyone) displayData = { label: "Rejected", type: "danger" }; - else if (userReviewStatus === ApprovalStatus.APPROVED) { - displayData = { - label: `Pending ${request.policy.approvals - request.reviewers.length} review${ - request.policy.approvals - request.reviewers.length > 1 ? "s" : "" - }`, - type: "primary" - }; - } else if (!isReviewedByUser) - displayData = { - label: "Review Required", + let displayData: { label: string; type: "primary" | "danger" | "success" } = { + label: "", type: "primary" }; - return { - displayData, - isReviewedByUser, - isRejectedByAnyone, - isApprover, - userReviewStatus, - isAccepted, - isSoftEnforcement, - isRequestedByCurrentUser, - isSelfApproveAllowed - }; - }; + const isExpired = + request.privilege && + request.isApproved && + new Date() > new Date(request.privilege.temporaryAccessEndTime || ("" as string)); + + if (isExpired) displayData = { label: "Access Expired", type: "danger" }; + else if (isAccepted) displayData = { label: "Access Granted", type: "success" }; + else if (isRejectedByAnyone) displayData = { label: "Rejected", type: "danger" }; + else if (userReviewStatus === ApprovalStatus.APPROVED) { + displayData = { + label: `Pending ${request.policy.approvals - request.reviewers.length} review${ + request.policy.approvals - request.reviewers.length > 1 ? "s" : "" + }`, + type: "primary" + }; + } else if (!isReviewedByUser) + displayData = { + label: "Review Required", + type: "primary" + }; + + return { + displayData, + isReviewedByUser, + isRejectedByAnyone, + isApprover, + userReviewStatus, + isAccepted, + isSoftEnforcement, + isRequestedByCurrentUser, + isSelfApproveAllowed + }; + }, + [user] + ); + + const handleSelectRequest = useCallback( + (request: TAccessApprovalRequest) => { + const details = generateRequestDetails(request); + + // Whether the request has already been approved / rejected / reviewed + const isInactive = + details.isAccepted || details.isReviewedByUser || details.isRejectedByAnyone; + + // Whether the current user can bypass policy + const canBypass = + details.isSoftEnforcement && + details.isRequestedByCurrentUser && + canBypassApprovalPermission; + + // Whether the current user can approve + const canApprove = + details.isApprover && (!details.isRequestedByCurrentUser || details.isSelfApproveAllowed); + + if (isInactive || (!canApprove && !canBypass)) return; + + if (membersGroupById?.[request.requestedByUserId].user || details.isRequestedByCurrentUser) { + setSelectedRequest({ + ...request, + user: + details.isRequestedByCurrentUser || !membersGroupById?.[request.requestedByUserId].user + ? user + : membersGroupById?.[request.requestedByUserId].user, + isRequestedByCurrentUser: details.isRequestedByCurrentUser, + isSelfApproveAllowed: details.isSelfApproveAllowed, + isApprover: details.isApprover + }); + } + + handlePopUpOpen("reviewRequest"); + }, + [ + generateRequestDetails, + canBypassApprovalPermission, + membersGroupById, + user, + setSelectedRequest, + handlePopUpOpen + ] + ); return (
@@ -344,50 +400,10 @@ export const AccessApprovalRequest = ({ className="flex w-full cursor-pointer px-8 py-4 hover:bg-mineshaft-700 aria-disabled:opacity-80" role="button" tabIndex={0} - onClick={() => { - if ( - ((!details.isApprover || - details.isReviewedByUser || - details.isRejectedByAnyone || - details.isAccepted) && - !( - details.isSoftEnforcement && - details.isRequestedByCurrentUser && - !details.isAccepted - )) || - (request.requestedByUserId === user.id && !details.isSelfApproveAllowed) - ) - return; - if (membersGroupById?.[request.requestedByUserId].user) { - setSelectedRequest({ - ...request, - user: membersGroupById?.[request.requestedByUserId].user, - isRequestedByCurrentUser: details.isRequestedByCurrentUser, - isApprover: details.isApprover - }); - } - - handlePopUpOpen("reviewRequest"); - }} + onClick={() => handleSelectRequest(request)} onKeyDown={(evt) => { - if ( - !details.isApprover || - details.isAccepted || - details.isReviewedByUser || - details.isRejectedByAnyone - ) - return; if (evt.key === "Enter") { - if (membersGroupById?.[request.requestedByUserId].user) { - setSelectedRequest({ - ...request, - user: membersGroupById?.[request.requestedByUserId].user, - isRequestedByCurrentUser: details.isRequestedByCurrentUser, - isApprover: details.isApprover - }); - } - - handlePopUpOpen("reviewRequest"); + handleSelectRequest(request); } }} > @@ -453,7 +469,9 @@ export const AccessApprovalRequest = ({ onOpenChange={() => { handlePopUpClose("reviewRequest"); setSelectedRequest(null); + refetchRequests(); }} + canBypassApprovalPermission={canBypassApprovalPermission} /> )} diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx index 37239d987..0b9f84008 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx @@ -1,14 +1,16 @@ import { useCallback, useMemo, useState } from "react"; +import { faTriangleExclamation } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import ms from "ms"; +import { twMerge } from "tailwind-merge"; import { createNotification } from "@app/components/notifications"; -import { Button, Checkbox, Modal, ModalContent } from "@app/components/v2"; +import { Button, Checkbox, FormControl, Input, Modal, ModalContent } from "@app/components/v2"; import { Badge } from "@app/components/v2/Badge"; import { ProjectPermissionActions } from "@app/context"; import { useReviewAccessRequest } from "@app/hooks/api"; import { TAccessApprovalRequest } from "@app/hooks/api/accessApproval/types"; import { EnforcementLevel } from "@app/hooks/api/policies/enums"; -import { TWorkspaceUser } from "@app/hooks/api/types"; export const ReviewAccessRequestModal = ({ isOpen, @@ -16,21 +18,26 @@ export const ReviewAccessRequestModal = ({ request, projectSlug, selectedRequester, - selectedEnvSlug + selectedEnvSlug, + canBypassApprovalPermission }: { isOpen: boolean; onOpenChange: (isOpen: boolean) => void; request: TAccessApprovalRequest & { - user: TWorkspaceUser["user"] | null; + user: { firstName?: string; lastName?: string; email?: string } | null; isRequestedByCurrentUser: boolean; + isSelfApproveAllowed: boolean; isApprover: boolean; }; projectSlug: string; selectedRequester: string | undefined; selectedEnvSlug: string | undefined; + canBypassApprovalPermission: boolean; }) => { const [isLoading, setIsLoading] = useState<"approved" | "rejected" | null>(null); - const [byPassApproval, setByPassApproval] = useState(false); + const [bypassApproval, setBypassApproval] = useState(false); + const [bypassReason, setBypassReason] = useState(""); + const isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft; const accessDetails = { @@ -80,31 +87,52 @@ export const ReviewAccessRequestModal = ({ const reviewAccessRequest = useReviewAccessRequest(); - const handleReview = useCallback(async (status: "approved" | "rejected") => { - setIsLoading(status); - try { - await reviewAccessRequest.mutateAsync({ - requestId: request.id, - status, - projectSlug, - envSlug: selectedEnvSlug, - requestedBy: selectedRequester - }); - } catch (error) { - console.error(error); + const handleReview = useCallback( + async (status: "approved" | "rejected") => { + if (bypassApproval && bypassReason.length < 10) { + createNotification({ + title: "Failed to bypass approval", + text: "Reason must be 10 characters or longer", + type: "error" + }); + return; + } + + setIsLoading(status); + try { + await reviewAccessRequest.mutateAsync({ + requestId: request.id, + status, + projectSlug, + envSlug: selectedEnvSlug, + requestedBy: selectedRequester, + bypassReason: bypassApproval ? bypassReason : undefined + }); + + createNotification({ + title: `Request ${status}`, + text: `The request has been ${status}`, + type: status === "approved" ? "success" : "info" + }); + } catch (error) { + console.error(error); + setIsLoading(null); + return; + } + setIsLoading(null); - return; - } - - createNotification({ - title: `Request ${status}`, - text: `The request has been ${status}`, - type: status === "approved" ? "success" : "info" - }); - - setIsLoading(null); - onOpenChange(false); - }, []); + onOpenChange(false); + }, + [ + bypassApproval, + bypassReason, + reviewAccessRequest, + request, + selectedEnvSlug, + selectedRequester, + onOpenChange + ] + ); return ( @@ -115,12 +143,17 @@ export const ReviewAccessRequestModal = ({ >
- - {request.user?.firstName} {request.user?.lastName} ({request.user?.email}) - {" "} + {request.user && + (request.user.firstName || request.user.lastName) && + request.user.email ? ( + + {request.user?.firstName} {request.user?.lastName} ({request.user?.email}) + + ) : ( + A user + )}{" "} is requesting access to the following resource: -
Requested path: @@ -144,12 +177,16 @@ export const ReviewAccessRequestModal = ({
)}
-
- {isSoftEnforcement && request.isRequestedByCurrentUser && !request.isApprover && ( -
- setByPassApproval(checked === true)} - isChecked={byPassApproval} - id="byPassApproval" - checkIndicatorBg="text-white" - className={byPassApproval ? "border-red bg-red hover:bg-red-600" : ""} - > - - Approve without waiting for requirements to be met (bypass policy protection) - - -
- )} + {isSoftEnforcement && + request.isRequestedByCurrentUser && + !(request.isApprover && request.isSelfApproveAllowed) && + canBypassApprovalPermission && ( +
+ setBypassApproval(checked === true)} + isChecked={bypassApproval} + id="byPassApproval" + checkIndicatorBg="text-white" + className={twMerge( + "mr-2", + bypassApproval ? "border-red bg-red hover:bg-red-600" : "" + )} + > + + Approve without waiting for requirements to be met (bypass policy protection) + + + {bypassApproval && ( + + setBypassReason(e.currentTarget.value)} + placeholder="Enter reason for bypass (min 10 chars)" + leftIcon={} + /> + + )} +
+ )}