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()
|
requestId: z.string().trim()
|
||||||
}),
|
}),
|
||||||
body: z.object({
|
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: {
|
response: {
|
||||||
200: z.object({
|
200: z.object({
|
||||||
@@ -170,7 +171,8 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
|
|||||||
actorOrgId: req.permission.orgId,
|
actorOrgId: req.permission.orgId,
|
||||||
actorAuthMethod: req.permission.authMethod,
|
actorAuthMethod: req.permission.authMethod,
|
||||||
requestId: req.params.requestId,
|
requestId: req.params.requestId,
|
||||||
status: req.body.status
|
status: req.body.status,
|
||||||
|
bypassReason: req.body.bypassReason
|
||||||
});
|
});
|
||||||
|
|
||||||
return { review };
|
return { review };
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import { getConfig } from "@app/lib/config/env";
|
|||||||
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
|
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
|
||||||
import { ms } from "@app/lib/ms";
|
import { ms } from "@app/lib/ms";
|
||||||
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
import { alphaNumericNanoId } from "@app/lib/nanoid";
|
||||||
|
import { EnforcementLevel } from "@app/lib/types";
|
||||||
import { triggerWorkflowIntegrationNotification } from "@app/lib/workflow-integrations/trigger-notification";
|
import { triggerWorkflowIntegrationNotification } from "@app/lib/workflow-integrations/trigger-notification";
|
||||||
import { TriggerFeature } from "@app/lib/workflow-integrations/types";
|
import { TriggerFeature } from "@app/lib/workflow-integrations/types";
|
||||||
import { TKmsServiceFactory } from "@app/services/kms/kms-service";
|
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 { TAccessApprovalPolicyDALFactory } from "../access-approval-policy/access-approval-policy-dal";
|
||||||
import { TGroupDALFactory } from "../group/group-dal";
|
import { TGroupDALFactory } from "../group/group-dal";
|
||||||
import { TPermissionServiceFactory } from "../permission/permission-service";
|
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 { TProjectUserAdditionalPrivilegeDALFactory } from "../project-user-additional-privilege/project-user-additional-privilege-dal";
|
||||||
import { ProjectUserAdditionalPrivilegeTemporaryMode } from "../project-user-additional-privilege/project-user-additional-privilege-types";
|
import { ProjectUserAdditionalPrivilegeTemporaryMode } from "../project-user-additional-privilege/project-user-additional-privilege-types";
|
||||||
import { TAccessApprovalRequestDALFactory } from "./access-approval-request-dal";
|
import { TAccessApprovalRequestDALFactory } from "./access-approval-request-dal";
|
||||||
@@ -323,26 +325,22 @@ export const accessApprovalRequestServiceFactory = ({
|
|||||||
status,
|
status,
|
||||||
actorId,
|
actorId,
|
||||||
actorAuthMethod,
|
actorAuthMethod,
|
||||||
actorOrgId
|
actorOrgId,
|
||||||
|
bypassReason
|
||||||
}: TReviewAccessRequestDTO) => {
|
}: TReviewAccessRequestDTO) => {
|
||||||
const accessApprovalRequest = await accessApprovalRequestDAL.findById(requestId);
|
const accessApprovalRequest = await accessApprovalRequestDAL.findById(requestId);
|
||||||
if (!accessApprovalRequest) {
|
if (!accessApprovalRequest) {
|
||||||
throw new NotFoundError({ message: `Secret approval request with ID '${requestId}' not found` });
|
throw new NotFoundError({ message: `Secret approval request with ID '${requestId}' not found` });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { policy } = accessApprovalRequest;
|
const { policy, environment } = accessApprovalRequest;
|
||||||
if (policy.deletedAt) {
|
if (policy.deletedAt) {
|
||||||
throw new BadRequestError({
|
throw new BadRequestError({
|
||||||
message: "The policy associated with this access request has been deleted."
|
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,
|
actor,
|
||||||
actorId,
|
actorId,
|
||||||
projectId: accessApprovalRequest.projectId,
|
projectId: accessApprovalRequest.projectId,
|
||||||
@@ -355,6 +353,20 @@ export const accessApprovalRequestServiceFactory = ({
|
|||||||
throw new ForbiddenRequestError({ message: "You are not a member of this project" });
|
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 (
|
if (
|
||||||
!hasRole(ProjectMembershipRole.Admin) &&
|
!hasRole(ProjectMembershipRole.Admin) &&
|
||||||
accessApprovalRequest.requestedByUserId !== actorId && // The request wasn't made by the current user
|
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" });
|
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 });
|
const existingReviews = await accessApprovalRequestReviewerDAL.find({ requestId: accessApprovalRequest.id });
|
||||||
if (existingReviews.some((review) => review.status === ApprovalStatus.REJECTED)) {
|
if (existingReviews.some((review) => review.status === ApprovalStatus.REJECTED)) {
|
||||||
throw new BadRequestError({ message: "The request has already been rejected by another reviewer" });
|
throw new BadRequestError({ message: "The request has already been rejected by another reviewer" });
|
||||||
}
|
}
|
||||||
|
|
||||||
const reviewStatus = await accessApprovalRequestReviewerDAL.transaction(async (tx) => {
|
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,
|
requestId: accessApprovalRequest.id,
|
||||||
reviewerUserId: actorId
|
reviewerUserId: actorId
|
||||||
},
|
},
|
||||||
tx
|
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,
|
status,
|
||||||
requestId: accessApprovalRequest.id,
|
requestId: accessApprovalRequest.id,
|
||||||
@@ -385,19 +425,26 @@ export const accessApprovalRequestServiceFactory = ({
|
|||||||
},
|
},
|
||||||
tx
|
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 (
|
||||||
if (approvedReviews.length === policy.approvals) {
|
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) {
|
if (accessApprovalRequest.isTemporary && !accessApprovalRequest.temporaryRange) {
|
||||||
throw new BadRequestError({ message: "Temporary range is required for temporary access" });
|
throw new BadRequestError({ message: "Temporary range is required for temporary access" });
|
||||||
}
|
}
|
||||||
|
|
||||||
let privilegeId: string | null = null;
|
|
||||||
|
|
||||||
if (!accessApprovalRequest.isTemporary && !accessApprovalRequest.temporaryRange) {
|
if (!accessApprovalRequest.isTemporary && !accessApprovalRequest.temporaryRange) {
|
||||||
// Permanent access
|
// Permanent access
|
||||||
const privilege = await additionalPrivilegeDAL.create(
|
const privilege = await additionalPrivilegeDAL.create(
|
||||||
@@ -409,7 +456,7 @@ export const accessApprovalRequestServiceFactory = ({
|
|||||||
},
|
},
|
||||||
tx
|
tx
|
||||||
);
|
);
|
||||||
privilegeId = privilege.id;
|
privilegeIdToSet = privilege.id;
|
||||||
} else {
|
} else {
|
||||||
// Temporary access
|
// Temporary access
|
||||||
const relativeTempAllocatedTimeInMs = ms(accessApprovalRequest.temporaryRange!);
|
const relativeTempAllocatedTimeInMs = ms(accessApprovalRequest.temporaryRange!);
|
||||||
@@ -421,23 +468,57 @@ export const accessApprovalRequestServiceFactory = ({
|
|||||||
projectId: accessApprovalRequest.projectId,
|
projectId: accessApprovalRequest.projectId,
|
||||||
slug: `requested-privilege-${slugify(alphaNumericNanoId(12))}`,
|
slug: `requested-privilege-${slugify(alphaNumericNanoId(12))}`,
|
||||||
permissions: JSON.stringify(accessApprovalRequest.permissions),
|
permissions: JSON.stringify(accessApprovalRequest.permissions),
|
||||||
isTemporary: true,
|
isTemporary: true, // Explicitly set to true for the privilege
|
||||||
temporaryMode: ProjectUserAdditionalPrivilegeTemporaryMode.Relative,
|
temporaryMode: ProjectUserAdditionalPrivilegeTemporaryMode.Relative,
|
||||||
temporaryRange: accessApprovalRequest.temporaryRange!,
|
temporaryRange: accessApprovalRequest.temporaryRange!,
|
||||||
temporaryAccessStartTime: startTime,
|
temporaryAccessStartTime: startTime,
|
||||||
temporaryAccessEndTime: new Date(new Date(startTime).getTime() + relativeTempAllocatedTimeInMs)
|
temporaryAccessEndTime: new Date(startTime.getTime() + relativeTempAllocatedTimeInMs)
|
||||||
},
|
},
|
||||||
tx
|
tx
|
||||||
);
|
);
|
||||||
privilegeId = privilege.id;
|
privilegeIdToSet = privilege.id;
|
||||||
}
|
}
|
||||||
|
await accessApprovalRequestDAL.updateById(accessApprovalRequest.id, { privilegeId: privilegeIdToSet }, tx);
|
||||||
await accessApprovalRequestDAL.updateById(accessApprovalRequest.id, { privilegeId }, 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;
|
return reviewStatus;
|
||||||
|
|||||||
@@ -17,6 +17,8 @@ export type TGetAccessRequestCountDTO = {
|
|||||||
export type TReviewAccessRequestDTO = {
|
export type TReviewAccessRequestDTO = {
|
||||||
requestId: string;
|
requestId: string;
|
||||||
status: ApprovalStatus;
|
status: ApprovalStatus;
|
||||||
|
envName?: string;
|
||||||
|
bypassReason?: string;
|
||||||
} & Omit<TProjectPermission, "projectId">;
|
} & Omit<TProjectPermission, "projectId">;
|
||||||
|
|
||||||
export type TCreateAccessApprovalRequestDTO = {
|
export type TCreateAccessApprovalRequestDTO = {
|
||||||
|
|||||||
@@ -61,7 +61,8 @@ const buildAdminPermissionRules = () => {
|
|||||||
ProjectPermissionApprovalActions.Edit,
|
ProjectPermissionApprovalActions.Edit,
|
||||||
ProjectPermissionApprovalActions.Create,
|
ProjectPermissionApprovalActions.Create,
|
||||||
ProjectPermissionApprovalActions.Delete,
|
ProjectPermissionApprovalActions.Delete,
|
||||||
ProjectPermissionApprovalActions.AllowChangeBypass
|
ProjectPermissionApprovalActions.AllowChangeBypass,
|
||||||
|
ProjectPermissionApprovalActions.AllowAccessBypass
|
||||||
],
|
],
|
||||||
ProjectPermissionSub.SecretApproval
|
ProjectPermissionSub.SecretApproval
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -39,7 +39,8 @@ export enum ProjectPermissionApprovalActions {
|
|||||||
Create = "create",
|
Create = "create",
|
||||||
Edit = "edit",
|
Edit = "edit",
|
||||||
Delete = "delete",
|
Delete = "delete",
|
||||||
AllowChangeBypass = "allow-change-bypass"
|
AllowChangeBypass = "allow-change-bypass",
|
||||||
|
AllowAccessBypass = "allow-access-bypass"
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ProjectPermissionCmekActions {
|
export enum ProjectPermissionCmekActions {
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ interface SecretApprovalRequestBypassedTemplateProps
|
|||||||
environment: string;
|
environment: string;
|
||||||
bypassReason: string;
|
bypassReason: string;
|
||||||
approvalUrl: string;
|
approvalUrl: string;
|
||||||
|
requestType: "change" | "access";
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SecretApprovalRequestBypassedTemplate = ({
|
export const SecretApprovalRequestBypassedTemplate = ({
|
||||||
@@ -22,7 +23,8 @@ export const SecretApprovalRequestBypassedTemplate = ({
|
|||||||
secretPath,
|
secretPath,
|
||||||
environment,
|
environment,
|
||||||
bypassReason,
|
bypassReason,
|
||||||
approvalUrl
|
approvalUrl,
|
||||||
|
requestType = "change"
|
||||||
}: SecretApprovalRequestBypassedTemplateProps) => {
|
}: SecretApprovalRequestBypassedTemplateProps) => {
|
||||||
return (
|
return (
|
||||||
<BaseEmailWrapper
|
<BaseEmailWrapper
|
||||||
@@ -39,8 +41,9 @@ export const SecretApprovalRequestBypassedTemplate = ({
|
|||||||
<Link href={`mailto:${requesterEmail}`} className="text-slate-700 no-underline">
|
<Link href={`mailto:${requesterEmail}`} className="text-slate-700 no-underline">
|
||||||
{requesterEmail}
|
{requesterEmail}
|
||||||
</Link>
|
</Link>
|
||||||
) has merged a secret to <strong>{secretPath}</strong> in the <strong>{environment}</strong> environment
|
) has {requestType === "change" ? "merged" : "accessed"} a secret {requestType === "change" ? "to" : "in"}{" "}
|
||||||
without obtaining the required approval.
|
<strong>{secretPath}</strong> in the <strong>{environment}</strong> environment without obtaining the required
|
||||||
|
approval.
|
||||||
</Text>
|
</Text>
|
||||||
<Text className="text-[14px] text-slate-700 leading-[24px]">
|
<Text className="text-[14px] text-slate-700 leading-[24px]">
|
||||||
<strong className="text-black">The following reason was provided for bypassing the policy:</strong> "
|
<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."
|
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:
|
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.
|
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>
|
<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>
|
</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.
|
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
|
### 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.
|
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`
|
#### Subject: `secret-approval`
|
||||||
|
|
||||||
| Action | Description |
|
| Action | Description |
|
||||||
| --------------------- | ---------------------------------------------------------------------------- |
|
| --------------------- | ----------------------------------------------------------------------------------- |
|
||||||
| `read` | View approval policies and requests |
|
| `read` | View approval policies and requests |
|
||||||
| `create` | Create new approval policies |
|
| `create` | Create new approval policies |
|
||||||
| `edit` | Modify approval policies |
|
| `edit` | Modify approval policies |
|
||||||
| `delete` | Remove approval policies |
|
| `delete` | Remove approval policies |
|
||||||
| `allow-change-bypass` | Allow request creators to bypass policy in break-glass situations |
|
| `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`
|
#### Subject: `secret-rotation`
|
||||||
|
|
||||||
|
|||||||
@@ -29,7 +29,8 @@ export enum ProjectPermissionApprovalActions {
|
|||||||
Create = "create",
|
Create = "create",
|
||||||
Edit = "edit",
|
Edit = "edit",
|
||||||
Delete = "delete",
|
Delete = "delete",
|
||||||
AllowChangeBypass = "allow-change-bypass"
|
AllowChangeBypass = "allow-change-bypass",
|
||||||
|
AllowAccessBypass = "allow-access-bypass"
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum ProjectPermissionDynamicSecretActions {
|
export enum ProjectPermissionDynamicSecretActions {
|
||||||
|
|||||||
@@ -131,20 +131,27 @@ export const useReviewAccessRequest = () => {
|
|||||||
projectSlug: string;
|
projectSlug: string;
|
||||||
envSlug?: string;
|
envSlug?: string;
|
||||||
requestedBy?: string;
|
requestedBy?: string;
|
||||||
|
bypassReason?: string;
|
||||||
}
|
}
|
||||||
>({
|
>({
|
||||||
mutationFn: async ({ requestId, status }) => {
|
mutationFn: async ({ requestId, status, bypassReason }) => {
|
||||||
const { data } = await apiRequest.post(
|
const { data } = await apiRequest.post(
|
||||||
`/api/v1/access-approvals/requests/${requestId}/review`,
|
`/api/v1/access-approvals/requests/${requestId}/review`,
|
||||||
{
|
{
|
||||||
status
|
status,
|
||||||
|
bypassReason
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
return data;
|
return data;
|
||||||
},
|
},
|
||||||
onSuccess: (_, { projectSlug, envSlug, requestedBy }) => {
|
onSuccess: (_, { projectSlug, envSlug, requestedBy, bypassReason }) => {
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: accessApprovalKeys.getAccessApprovalRequests(projectSlug, envSlug, requestedBy)
|
queryKey: accessApprovalKeys.getAccessApprovalRequests(
|
||||||
|
projectSlug,
|
||||||
|
envSlug,
|
||||||
|
requestedBy,
|
||||||
|
bypassReason
|
||||||
|
)
|
||||||
});
|
});
|
||||||
queryClient.invalidateQueries({
|
queryClient.invalidateQueries({
|
||||||
queryKey: accessApprovalKeys.getAccessApprovalRequestCount(projectSlug)
|
queryKey: accessApprovalKeys.getAccessApprovalRequestCount(projectSlug)
|
||||||
|
|||||||
@@ -19,8 +19,12 @@ export const accessApprovalKeys = {
|
|||||||
getAccessApprovalPolicyOfABoard: (workspaceId: string, environment: string) =>
|
getAccessApprovalPolicyOfABoard: (workspaceId: string, environment: string) =>
|
||||||
[{ workspaceId, environment }, "access-approval-policy"] as const,
|
[{ workspaceId, environment }, "access-approval-policy"] as const,
|
||||||
|
|
||||||
getAccessApprovalRequests: (projectSlug: string, envSlug?: string, requestedBy?: string) =>
|
getAccessApprovalRequests: (
|
||||||
[{ projectSlug, envSlug, requestedBy }, "access-approvals-requests"] as const,
|
projectSlug: string,
|
||||||
|
envSlug?: string,
|
||||||
|
requestedBy?: string,
|
||||||
|
bypassReason?: string
|
||||||
|
) => [{ projectSlug, envSlug, requestedBy, bypassReason }, "access-approvals-requests"] as const,
|
||||||
getAccessApprovalRequestCount: (projectSlug: string) =>
|
getAccessApprovalRequestCount: (projectSlug: string) =>
|
||||||
[{ projectSlug }, "access-approval-request-count"] as const
|
[{ projectSlug }, "access-approval-request-count"] as const
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -58,7 +58,8 @@ const ApprovalPolicyActionSchema = z.object({
|
|||||||
[ProjectPermissionApprovalActions.Edit]: z.boolean().optional(),
|
[ProjectPermissionApprovalActions.Edit]: z.boolean().optional(),
|
||||||
[ProjectPermissionApprovalActions.Delete]: z.boolean().optional(),
|
[ProjectPermissionApprovalActions.Delete]: z.boolean().optional(),
|
||||||
[ProjectPermissionApprovalActions.Create]: 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({
|
const CmekPolicyActionSchema = z.object({
|
||||||
@@ -578,6 +579,7 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
|
|||||||
const canEdit = action.includes(ProjectPermissionApprovalActions.Edit);
|
const canEdit = action.includes(ProjectPermissionApprovalActions.Edit);
|
||||||
const canRead = action.includes(ProjectPermissionApprovalActions.Read);
|
const canRead = action.includes(ProjectPermissionApprovalActions.Read);
|
||||||
const canChangeBypass = action.includes(ProjectPermissionApprovalActions.AllowChangeBypass);
|
const canChangeBypass = action.includes(ProjectPermissionApprovalActions.AllowChangeBypass);
|
||||||
|
const canAccessBypass = action.includes(ProjectPermissionApprovalActions.AllowAccessBypass);
|
||||||
|
|
||||||
if (!formVal[subject]) formVal[subject] = [{}];
|
if (!formVal[subject]) formVal[subject] = [{}];
|
||||||
|
|
||||||
@@ -588,6 +590,8 @@ export const rolePermission2Form = (permissions: TProjectPermission[] = []) => {
|
|||||||
if (canRead) formVal[subject]![0][ProjectPermissionApprovalActions.Read] = true;
|
if (canRead) formVal[subject]![0][ProjectPermissionApprovalActions.Read] = true;
|
||||||
if (canChangeBypass)
|
if (canChangeBypass)
|
||||||
formVal[subject]![0][ProjectPermissionApprovalActions.AllowChangeBypass] = true;
|
formVal[subject]![0][ProjectPermissionApprovalActions.AllowChangeBypass] = true;
|
||||||
|
if (canAccessBypass)
|
||||||
|
formVal[subject]![0][ProjectPermissionApprovalActions.AllowAccessBypass] = true;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1212,7 +1216,8 @@ export const PROJECT_PERMISSION_OBJECT: TProjectPermissionObject = {
|
|||||||
{ label: "Create", value: ProjectPermissionApprovalActions.Create },
|
{ label: "Create", value: ProjectPermissionApprovalActions.Create },
|
||||||
{ label: "Modify", value: ProjectPermissionApprovalActions.Edit },
|
{ label: "Modify", value: ProjectPermissionApprovalActions.Edit },
|
||||||
{ label: "Remove", value: ProjectPermissionApprovalActions.Delete },
|
{ 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]: {
|
[ProjectPermissionSub.SecretRotation]: {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
/* eslint-disable no-nested-ternary */
|
/* eslint-disable no-nested-ternary */
|
||||||
/* eslint-disable react/jsx-no-useless-fragment */
|
/* eslint-disable react/jsx-no-useless-fragment */
|
||||||
import { useMemo, useState } from "react";
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import {
|
import {
|
||||||
faCheck,
|
faCheck,
|
||||||
faCheckCircle,
|
faCheckCircle,
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
} from "@app/components/v2";
|
} from "@app/components/v2";
|
||||||
import { Badge } from "@app/components/v2/Badge";
|
import { Badge } from "@app/components/v2/Badge";
|
||||||
import {
|
import {
|
||||||
|
ProjectPermissionApprovalActions,
|
||||||
ProjectPermissionMemberActions,
|
ProjectPermissionMemberActions,
|
||||||
ProjectPermissionSub,
|
ProjectPermissionSub,
|
||||||
useProjectPermission,
|
useProjectPermission,
|
||||||
@@ -83,8 +84,9 @@ export const AccessApprovalRequest = ({
|
|||||||
}) => {
|
}) => {
|
||||||
const [selectedRequest, setSelectedRequest] = useState<
|
const [selectedRequest, setSelectedRequest] = useState<
|
||||||
| (TAccessApprovalRequest & {
|
| (TAccessApprovalRequest & {
|
||||||
user: TWorkspaceUser["user"] | null;
|
user: { firstName?: string; lastName?: string; email?: string } | null;
|
||||||
isRequestedByCurrentUser: boolean;
|
isRequestedByCurrentUser: boolean;
|
||||||
|
isSelfApproveAllowed: boolean;
|
||||||
isApprover: boolean;
|
isApprover: boolean;
|
||||||
})
|
})
|
||||||
| null
|
| null
|
||||||
@@ -100,6 +102,11 @@ export const AccessApprovalRequest = ({
|
|||||||
const { subscription } = useSubscription();
|
const { subscription } = useSubscription();
|
||||||
const { currentWorkspace } = useWorkspace();
|
const { currentWorkspace } = useWorkspace();
|
||||||
|
|
||||||
|
const canBypassApprovalPermission = permission.can(
|
||||||
|
ProjectPermissionApprovalActions.AllowAccessBypass,
|
||||||
|
ProjectPermissionSub.SecretApproval
|
||||||
|
);
|
||||||
|
|
||||||
const { data: members } = useGetWorkspaceUsers(projectId, true);
|
const { data: members } = useGetWorkspaceUsers(projectId, true);
|
||||||
const membersGroupById = members?.reduce<Record<string, TWorkspaceUser>>(
|
const membersGroupById = members?.reduce<Record<string, TWorkspaceUser>>(
|
||||||
(prev, curr) => ({ ...prev, [curr.user.id]: curr }),
|
(prev, curr) => ({ ...prev, [curr.user.id]: curr }),
|
||||||
@@ -118,7 +125,7 @@ export const AccessApprovalRequest = ({
|
|||||||
projectSlug
|
projectSlug
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: requests } = useGetAccessApprovalRequests({
|
const { data: requests, refetch: refetchRequests } = useGetAccessApprovalRequests({
|
||||||
projectSlug,
|
projectSlug,
|
||||||
authorProjectMembershipId: requestedByFilter,
|
authorProjectMembershipId: requestedByFilter,
|
||||||
envSlug: envFilter
|
envSlug: envFilter
|
||||||
@@ -143,56 +150,105 @@ export const AccessApprovalRequest = ({
|
|||||||
return requests;
|
return requests;
|
||||||
}, [requests, statusFilter, requestedByFilter, envFilter]);
|
}, [requests, statusFilter, requestedByFilter, envFilter]);
|
||||||
|
|
||||||
const generateRequestDetails = (request: TAccessApprovalRequest) => {
|
const generateRequestDetails = useCallback(
|
||||||
const isReviewedByUser = request.reviewers.findIndex(({ member }) => member === user.id) !== -1;
|
(request: TAccessApprovalRequest) => {
|
||||||
const isRejectedByAnyone = request.reviewers.some(
|
const isReviewedByUser =
|
||||||
({ status }) => status === ApprovalStatus.REJECTED
|
request.reviewers.findIndex(({ member }) => member === user.id) !== -1;
|
||||||
);
|
const isRejectedByAnyone = request.reviewers.some(
|
||||||
const isApprover = request.policy.approvers.indexOf(user.id || "") !== -1;
|
({ status }) => status === ApprovalStatus.REJECTED
|
||||||
const isAccepted = request.isApproved;
|
);
|
||||||
const isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft;
|
const isApprover = request.policy.approvers.indexOf(user.id || "") !== -1;
|
||||||
const isRequestedByCurrentUser = request.requestedByUserId === user.id;
|
const isAccepted = request.isApproved;
|
||||||
const isSelfApproveAllowed = request.policy.allowedSelfApprovals;
|
const isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft;
|
||||||
const userReviewStatus = request.reviewers.find(({ member }) => member === user.id)?.status;
|
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" } = {
|
let displayData: { label: string; type: "primary" | "danger" | "success" } = {
|
||||||
label: "",
|
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",
|
|
||||||
type: "primary"
|
type: "primary"
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
const isExpired =
|
||||||
displayData,
|
request.privilege &&
|
||||||
isReviewedByUser,
|
request.isApproved &&
|
||||||
isRejectedByAnyone,
|
new Date() > new Date(request.privilege.temporaryAccessEndTime || ("" as string));
|
||||||
isApprover,
|
|
||||||
userReviewStatus,
|
if (isExpired) displayData = { label: "Access Expired", type: "danger" };
|
||||||
isAccepted,
|
else if (isAccepted) displayData = { label: "Access Granted", type: "success" };
|
||||||
isSoftEnforcement,
|
else if (isRejectedByAnyone) displayData = { label: "Rejected", type: "danger" };
|
||||||
isRequestedByCurrentUser,
|
else if (userReviewStatus === ApprovalStatus.APPROVED) {
|
||||||
isSelfApproveAllowed
|
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 (
|
return (
|
||||||
<div>
|
<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"
|
className="flex w-full cursor-pointer px-8 py-4 hover:bg-mineshaft-700 aria-disabled:opacity-80"
|
||||||
role="button"
|
role="button"
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
onClick={() => {
|
onClick={() => handleSelectRequest(request)}
|
||||||
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");
|
|
||||||
}}
|
|
||||||
onKeyDown={(evt) => {
|
onKeyDown={(evt) => {
|
||||||
if (
|
|
||||||
!details.isApprover ||
|
|
||||||
details.isAccepted ||
|
|
||||||
details.isReviewedByUser ||
|
|
||||||
details.isRejectedByAnyone
|
|
||||||
)
|
|
||||||
return;
|
|
||||||
if (evt.key === "Enter") {
|
if (evt.key === "Enter") {
|
||||||
if (membersGroupById?.[request.requestedByUserId].user) {
|
handleSelectRequest(request);
|
||||||
setSelectedRequest({
|
|
||||||
...request,
|
|
||||||
user: membersGroupById?.[request.requestedByUserId].user,
|
|
||||||
isRequestedByCurrentUser: details.isRequestedByCurrentUser,
|
|
||||||
isApprover: details.isApprover
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
handlePopUpOpen("reviewRequest");
|
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -453,7 +469,9 @@ export const AccessApprovalRequest = ({
|
|||||||
onOpenChange={() => {
|
onOpenChange={() => {
|
||||||
handlePopUpClose("reviewRequest");
|
handlePopUpClose("reviewRequest");
|
||||||
setSelectedRequest(null);
|
setSelectedRequest(null);
|
||||||
|
refetchRequests();
|
||||||
}}
|
}}
|
||||||
|
canBypassApprovalPermission={canBypassApprovalPermission}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,14 +1,16 @@
|
|||||||
import { useCallback, useMemo, useState } from "react";
|
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 ms from "ms";
|
||||||
|
import { twMerge } from "tailwind-merge";
|
||||||
|
|
||||||
import { createNotification } from "@app/components/notifications";
|
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 { Badge } from "@app/components/v2/Badge";
|
||||||
import { ProjectPermissionActions } from "@app/context";
|
import { ProjectPermissionActions } from "@app/context";
|
||||||
import { useReviewAccessRequest } from "@app/hooks/api";
|
import { useReviewAccessRequest } from "@app/hooks/api";
|
||||||
import { TAccessApprovalRequest } from "@app/hooks/api/accessApproval/types";
|
import { TAccessApprovalRequest } from "@app/hooks/api/accessApproval/types";
|
||||||
import { EnforcementLevel } from "@app/hooks/api/policies/enums";
|
import { EnforcementLevel } from "@app/hooks/api/policies/enums";
|
||||||
import { TWorkspaceUser } from "@app/hooks/api/types";
|
|
||||||
|
|
||||||
export const ReviewAccessRequestModal = ({
|
export const ReviewAccessRequestModal = ({
|
||||||
isOpen,
|
isOpen,
|
||||||
@@ -16,21 +18,26 @@ export const ReviewAccessRequestModal = ({
|
|||||||
request,
|
request,
|
||||||
projectSlug,
|
projectSlug,
|
||||||
selectedRequester,
|
selectedRequester,
|
||||||
selectedEnvSlug
|
selectedEnvSlug,
|
||||||
|
canBypassApprovalPermission
|
||||||
}: {
|
}: {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
onOpenChange: (isOpen: boolean) => void;
|
onOpenChange: (isOpen: boolean) => void;
|
||||||
request: TAccessApprovalRequest & {
|
request: TAccessApprovalRequest & {
|
||||||
user: TWorkspaceUser["user"] | null;
|
user: { firstName?: string; lastName?: string; email?: string } | null;
|
||||||
isRequestedByCurrentUser: boolean;
|
isRequestedByCurrentUser: boolean;
|
||||||
|
isSelfApproveAllowed: boolean;
|
||||||
isApprover: boolean;
|
isApprover: boolean;
|
||||||
};
|
};
|
||||||
projectSlug: string;
|
projectSlug: string;
|
||||||
selectedRequester: string | undefined;
|
selectedRequester: string | undefined;
|
||||||
selectedEnvSlug: string | undefined;
|
selectedEnvSlug: string | undefined;
|
||||||
|
canBypassApprovalPermission: boolean;
|
||||||
}) => {
|
}) => {
|
||||||
const [isLoading, setIsLoading] = useState<"approved" | "rejected" | null>(null);
|
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 isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft;
|
||||||
|
|
||||||
const accessDetails = {
|
const accessDetails = {
|
||||||
@@ -80,31 +87,52 @@ export const ReviewAccessRequestModal = ({
|
|||||||
|
|
||||||
const reviewAccessRequest = useReviewAccessRequest();
|
const reviewAccessRequest = useReviewAccessRequest();
|
||||||
|
|
||||||
const handleReview = useCallback(async (status: "approved" | "rejected") => {
|
const handleReview = useCallback(
|
||||||
setIsLoading(status);
|
async (status: "approved" | "rejected") => {
|
||||||
try {
|
if (bypassApproval && bypassReason.length < 10) {
|
||||||
await reviewAccessRequest.mutateAsync({
|
createNotification({
|
||||||
requestId: request.id,
|
title: "Failed to bypass approval",
|
||||||
status,
|
text: "Reason must be 10 characters or longer",
|
||||||
projectSlug,
|
type: "error"
|
||||||
envSlug: selectedEnvSlug,
|
});
|
||||||
requestedBy: selectedRequester
|
return;
|
||||||
});
|
}
|
||||||
} catch (error) {
|
|
||||||
console.error(error);
|
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);
|
setIsLoading(null);
|
||||||
return;
|
onOpenChange(false);
|
||||||
}
|
},
|
||||||
|
[
|
||||||
createNotification({
|
bypassApproval,
|
||||||
title: `Request ${status}`,
|
bypassReason,
|
||||||
text: `The request has been ${status}`,
|
reviewAccessRequest,
|
||||||
type: status === "approved" ? "success" : "info"
|
request,
|
||||||
});
|
selectedEnvSlug,
|
||||||
|
selectedRequester,
|
||||||
setIsLoading(null);
|
onOpenChange
|
||||||
onOpenChange(false);
|
]
|
||||||
}, []);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
||||||
@@ -115,12 +143,17 @@ export const ReviewAccessRequestModal = ({
|
|||||||
>
|
>
|
||||||
<div className="text-sm">
|
<div className="text-sm">
|
||||||
<span>
|
<span>
|
||||||
<span className="font-bold">
|
{request.user &&
|
||||||
{request.user?.firstName} {request.user?.lastName} ({request.user?.email})
|
(request.user.firstName || request.user.lastName) &&
|
||||||
</span>{" "}
|
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:
|
is requesting access to the following resource:
|
||||||
</span>
|
</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-2 mt-4 border-l border-blue-500 bg-blue-500/20 px-3 py-2 text-mineshaft-200">
|
||||||
<div className="mb-1 lowercase">
|
<div className="mb-1 lowercase">
|
||||||
<span className="font-bold capitalize">Requested path: </span>
|
<span className="font-bold capitalize">Requested path: </span>
|
||||||
@@ -144,12 +177,16 @@ export const ReviewAccessRequestModal = ({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-x-2">
|
<div className="space-x-2">
|
||||||
<Button
|
<Button
|
||||||
isLoading={isLoading === "approved"}
|
isLoading={isLoading === "approved"}
|
||||||
isDisabled={
|
isDisabled={
|
||||||
!!isLoading || (!request.isApprover && !byPassApproval && isSoftEnforcement)
|
!!isLoading ||
|
||||||
|
(!(
|
||||||
|
request.isApprover &&
|
||||||
|
(!request.isRequestedByCurrentUser || request.isSelfApproveAllowed)
|
||||||
|
) &&
|
||||||
|
!bypassApproval)
|
||||||
}
|
}
|
||||||
onClick={() => handleReview("approved")}
|
onClick={() => handleReview("approved")}
|
||||||
className="mt-4"
|
className="mt-4"
|
||||||
@@ -168,21 +205,42 @@ export const ReviewAccessRequestModal = ({
|
|||||||
Reject Request
|
Reject Request
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
{isSoftEnforcement && request.isRequestedByCurrentUser && !request.isApprover && (
|
{isSoftEnforcement &&
|
||||||
<div className="mt-4">
|
request.isRequestedByCurrentUser &&
|
||||||
<Checkbox
|
!(request.isApprover && request.isSelfApproveAllowed) &&
|
||||||
onCheckedChange={(checked) => setByPassApproval(checked === true)}
|
canBypassApprovalPermission && (
|
||||||
isChecked={byPassApproval}
|
<div className="mt-2 flex flex-col space-y-2">
|
||||||
id="byPassApproval"
|
<Checkbox
|
||||||
checkIndicatorBg="text-white"
|
onCheckedChange={(checked) => setBypassApproval(checked === true)}
|
||||||
className={byPassApproval ? "border-red bg-red hover:bg-red-600" : ""}
|
isChecked={bypassApproval}
|
||||||
>
|
id="byPassApproval"
|
||||||
<span className="text-sm text-red">
|
checkIndicatorBg="text-white"
|
||||||
Approve without waiting for requirements to be met (bypass policy protection)
|
className={twMerge(
|
||||||
</span>
|
"mr-2",
|
||||||
</Checkbox>
|
bypassApproval ? "border-red bg-red hover:bg-red-600" : ""
|
||||||
</div>
|
)}
|
||||||
)}
|
>
|
||||||
|
<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>
|
</div>
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
Reference in New Issue
Block a user