mirror of
https://github.com/awatertrevi/infisical.git
synced 2026-09-22 13:39:35 +00:00
Merge pull request #3643 from Infisical/ENG-2801
feat(policies): Approval Request Break-Glass Bypass
This commit is contained in:
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -17,6 +17,8 @@ export type TGetAccessRequestCountDTO = {
|
||||
export type TReviewAccessRequestDTO = {
|
||||
requestId: string;
|
||||
status: ApprovalStatus;
|
||||
envName?: string;
|
||||
bypassReason?: string;
|
||||
} & Omit<TProjectPermission, "projectId">;
|
||||
|
||||
export type TCreateAccessApprovalRequestDTO = {
|
||||
|
||||
@@ -61,7 +61,8 @@ const buildAdminPermissionRules = () => {
|
||||
ProjectPermissionApprovalActions.Edit,
|
||||
ProjectPermissionApprovalActions.Create,
|
||||
ProjectPermissionApprovalActions.Delete,
|
||||
ProjectPermissionApprovalActions.AllowChangeBypass
|
||||
ProjectPermissionApprovalActions.AllowChangeBypass,
|
||||
ProjectPermissionApprovalActions.AllowAccessBypass
|
||||
],
|
||||
ProjectPermissionSub.SecretApproval
|
||||
);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 (
|
||||
<BaseEmailWrapper
|
||||
@@ -39,8 +41,9 @@ export const SecretApprovalRequestBypassedTemplate = ({
|
||||
<Link href={`mailto:${requesterEmail}`} className="text-slate-700 no-underline">
|
||||
{requesterEmail}
|
||||
</Link>
|
||||
) has merged a secret to <strong>{secretPath}</strong> in the <strong>{environment}</strong> environment
|
||||
without obtaining the required approval.
|
||||
) has {requestType === "change" ? "merged" : "accessed"} a secret {requestType === "change" ? "to" : "in"}{" "}
|
||||
<strong>{secretPath}</strong> in the <strong>{environment}</strong> environment without obtaining the required
|
||||
approval.
|
||||
</Text>
|
||||
<Text className="text-[14px] text-slate-700 leading-[24px]">
|
||||
<strong className="text-black">The following reason was provided for bypassing the policy:</strong> "
|
||||
|
||||
@@ -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.
|
||||

|
||||

|
||||
|
||||
@@ -19,9 +19,8 @@ This functionality works in the following way:
|
||||

|
||||
|
||||
<Info>
|
||||
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.
|
||||
</Info>
|
||||
|
||||
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.
|
||||

|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
<Note>
|
||||
Enabling the "Bypass Approvals" toggle during policy creation will create a **Soft** enforcement level. Disabling the toggle makes the enforcement level **Hard**.
|
||||
</Note>
|
||||
|
||||
### 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.
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 129 KiB |
@@ -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`
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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]: {
|
||||
|
||||
@@ -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<Record<string, TWorkspaceUser>>(
|
||||
(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 (
|
||||
<div>
|
||||
@@ -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}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||
@@ -115,12 +143,17 @@ export const ReviewAccessRequestModal = ({
|
||||
>
|
||||
<div className="text-sm">
|
||||
<span>
|
||||
<span className="font-bold">
|
||||
{request.user?.firstName} {request.user?.lastName} ({request.user?.email})
|
||||
</span>{" "}
|
||||
{request.user &&
|
||||
(request.user.firstName || request.user.lastName) &&
|
||||
request.user.email ? (
|
||||
<span className="font-bold">
|
||||
{request.user?.firstName} {request.user?.lastName} ({request.user?.email})
|
||||
</span>
|
||||
) : (
|
||||
<span>A user</span>
|
||||
)}{" "}
|
||||
is requesting access to the following resource:
|
||||
</span>
|
||||
|
||||
<div className="mb-2 mt-4 border-l border-blue-500 bg-blue-500/20 px-3 py-2 text-mineshaft-200">
|
||||
<div className="mb-1 lowercase">
|
||||
<span className="font-bold capitalize">Requested path: </span>
|
||||
@@ -144,12 +177,16 @@ export const ReviewAccessRequestModal = ({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-x-2">
|
||||
<Button
|
||||
isLoading={isLoading === "approved"}
|
||||
isDisabled={
|
||||
!!isLoading || (!request.isApprover && !byPassApproval && isSoftEnforcement)
|
||||
!!isLoading ||
|
||||
(!(
|
||||
request.isApprover &&
|
||||
(!request.isRequestedByCurrentUser || request.isSelfApproveAllowed)
|
||||
) &&
|
||||
!bypassApproval)
|
||||
}
|
||||
onClick={() => handleReview("approved")}
|
||||
className="mt-4"
|
||||
@@ -168,21 +205,42 @@ export const ReviewAccessRequestModal = ({
|
||||
Reject Request
|
||||
</Button>
|
||||
</div>
|
||||
{isSoftEnforcement && request.isRequestedByCurrentUser && !request.isApprover && (
|
||||
<div className="mt-4">
|
||||
<Checkbox
|
||||
onCheckedChange={(checked) => setByPassApproval(checked === true)}
|
||||
isChecked={byPassApproval}
|
||||
id="byPassApproval"
|
||||
checkIndicatorBg="text-white"
|
||||
className={byPassApproval ? "border-red bg-red hover:bg-red-600" : ""}
|
||||
>
|
||||
<span className="text-sm text-red">
|
||||
Approve without waiting for requirements to be met (bypass policy protection)
|
||||
</span>
|
||||
</Checkbox>
|
||||
</div>
|
||||
)}
|
||||
{isSoftEnforcement &&
|
||||
request.isRequestedByCurrentUser &&
|
||||
!(request.isApprover && request.isSelfApproveAllowed) &&
|
||||
canBypassApprovalPermission && (
|
||||
<div className="mt-2 flex flex-col space-y-2">
|
||||
<Checkbox
|
||||
onCheckedChange={(checked) => setBypassApproval(checked === true)}
|
||||
isChecked={bypassApproval}
|
||||
id="byPassApproval"
|
||||
checkIndicatorBg="text-white"
|
||||
className={twMerge(
|
||||
"mr-2",
|
||||
bypassApproval ? "border-red bg-red hover:bg-red-600" : ""
|
||||
)}
|
||||
>
|
||||
<span className="text-xs text-red">
|
||||
Approve without waiting for requirements to be met (bypass policy protection)
|
||||
</span>
|
||||
</Checkbox>
|
||||
{bypassApproval && (
|
||||
<FormControl
|
||||
label="Reason for bypass"
|
||||
className="mt-2"
|
||||
isRequired
|
||||
tooltipText="Enter a reason for bypassing the secret change policy"
|
||||
>
|
||||
<Input
|
||||
value={bypassReason}
|
||||
onChange={(e) => setBypassReason(e.currentTarget.value)}
|
||||
placeholder="Enter reason for bypass (min 10 chars)"
|
||||
leftIcon={<FontAwesomeIcon icon={faTriangleExclamation} />}
|
||||
/>
|
||||
</FormControl>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ModalContent>
|
||||
</Modal>
|
||||
|
||||
Reference in New Issue
Block a user