feat: ui changes for approval to work

This commit is contained in:
=
2025-06-11 14:43:20 +05:30
parent d32b6ad41d
commit f9c00cf442
18 changed files with 1001 additions and 371 deletions

View File

@@ -47,12 +47,11 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
.max(100, "Cannot have more than 100 bypassers")
.optional(),
approvalsRequired: z
.record(
z.number().int(),
z.object({
numberOfApprovals: z.number().int()
})
)
.object({
numberOfApprovals: z.number().int(),
stepNumber: z.number().int()
})
.array()
.optional(),
approvals: z.number().min(1).default(1),
enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard),
@@ -95,7 +94,12 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
approvals: sapPubSchema
.extend({
approvers: z
.object({ type: z.nativeEnum(ApproverType), id: z.string().nullable().optional() })
.object({
type: z.nativeEnum(ApproverType),
id: z.string().nullable().optional(),
sequence: z.number().nullable().optional(),
approvalsRequired: z.number().nullable().optional()
})
.array()
.nullable()
.optional(),
@@ -169,8 +173,17 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
.transform((val) => (val === "" ? "/" : val)),
approvers: z
.discriminatedUnion("type", [
z.object({ type: z.literal(ApproverType.Group), id: z.string() }),
z.object({ type: z.literal(ApproverType.User), id: z.string().optional(), username: z.string().optional() })
z.object({
type: z.literal(ApproverType.Group),
id: z.string(),
sequence: z.number().int().default(1)
}),
z.object({
type: z.literal(ApproverType.User),
id: z.string().optional(),
username: z.string().optional(),
sequence: z.number().int().default(1)
})
])
.array()
.min(1, { message: "At least one approver should be provided" })
@@ -187,12 +200,11 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard),
allowedSelfApprovals: z.boolean().default(true),
approvalsRequired: z
.record(
z.number().int(),
z.object({
numberOfApprovals: z.number().int()
})
)
.object({
numberOfApprovals: z.number().int(),
stepNumber: z.number().int()
})
.array()
.optional()
}),
response: {
@@ -260,7 +272,8 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi
.object({
type: z.nativeEnum(ApproverType),
id: z.string().nullable().optional(),
name: z.string().nullable().optional()
name: z.string().nullable().optional(),
approvalsRequired: z.number().nullable().optional()
})
.array()
.nullable()

View File

@@ -112,7 +112,15 @@ export const registerAccessApprovalRequestRouter = async (server: FastifyZodProv
id: z.string(),
name: z.string(),
approvals: z.number(),
approvers: z.string().array(),
approvers: z
.object({
userId: z.string().nullable().optional(),
sequence: z.number().nullable().optional(),
approvalsRequired: z.number().nullable().optional(),
email: z.string().nullable().optional(),
username: z.string().nullable().optional()
})
.array(),
bypassers: z.string().array(),
secretPath: z.string().nullish(),
envId: z.string(),

View File

@@ -48,6 +48,9 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => {
.select(tx.ref("username").withSchema("bypasserUsers").as("bypasserUsername"))
.select(tx.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover))
.select(tx.ref("approverGroupId").withSchema(TableName.AccessApprovalPolicyApprover))
.select(tx.ref("sequence").withSchema(TableName.AccessApprovalPolicyApprover).as("approverSequence"))
.select(tx.ref("approvalsRequired").withSchema(TableName.AccessApprovalPolicyApprover))
.select(tx.ref("approverGroupId").withSchema(TableName.AccessApprovalPolicyApprover))
.select(tx.ref("bypasserUserId").withSchema(TableName.AccessApprovalPolicyBypasser))
.select(tx.ref("bypasserGroupId").withSchema(TableName.AccessApprovalPolicyBypasser))
.select(tx.ref("name").withSchema(TableName.Environment).as("envName"))
@@ -80,17 +83,21 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => {
{
key: "approverUserId",
label: "approvers" as const,
mapper: ({ approverUserId: id }) => ({
mapper: ({ approverUserId: id, approverSequence, approvalsRequired }) => ({
id,
type: "user"
type: "user",
sequence: approverSequence,
approvalsRequired
})
},
{
key: "approverGroupId",
label: "approvers" as const,
mapper: ({ approverGroupId: id }) => ({
mapper: ({ approverGroupId: id, approverSequence, approvalsRequired }) => ({
id,
type: "group"
type: "group",
sequence: approverSequence,
approvalsRequired
})
}
]
@@ -129,18 +136,22 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => {
{
key: "approverUserId",
label: "approvers" as const,
mapper: ({ approverUserId: id, approverUsername }) => ({
mapper: ({ approverUserId: id, approverUsername, approverSequence, approvalsRequired }) => ({
id,
type: ApproverType.User,
name: approverUsername
name: approverUsername,
sequence: approverSequence,
approvalsRequired
})
},
{
key: "approverGroupId",
label: "approvers" as const,
mapper: ({ approverGroupId: id }) => ({
mapper: ({ approverGroupId: id, approverSequence, approvalsRequired }) => ({
id,
type: ApproverType.Group
type: ApproverType.Group,
sequence: approverSequence,
approvalsRequired
})
},
{

View File

@@ -4,6 +4,7 @@ import { ActionProjectType } from "@app/db/schemas";
import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service";
import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { groupBy } from "@app/lib/fn";
import { TOrgMembershipDALFactory } from "@app/services/org-membership/org-membership-dal";
import { TProjectDALFactory } from "@app/services/project/project-dal";
import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal";
@@ -30,7 +31,6 @@ import {
TListAccessApprovalPoliciesDTO,
TUpdateAccessApprovalPolicy
} from "./access-approval-policy-types";
import { groupBy } from "@app/lib/fn";
type TAccessApprovalPolicyServiceFactoryDep = {
projectDAL: TProjectDALFactory;
@@ -44,7 +44,7 @@ type TAccessApprovalPolicyServiceFactoryDep = {
userDAL: Pick<TUserDALFactory, "find">;
accessApprovalRequestDAL: Pick<TAccessApprovalRequestDALFactory, "update" | "find">;
additionalPrivilegeDAL: Pick<TProjectUserAdditionalPrivilegeDALFactory, "delete">;
accessApprovalRequestReviewerDAL: Pick<TAccessApprovalRequestReviewerDALFactory, "update">;
accessApprovalRequestReviewerDAL: Pick<TAccessApprovalRequestReviewerDALFactory, "update" | "delete">;
orgMembershipDAL: Pick<TOrgMembershipDALFactory, "find">;
};
@@ -176,6 +176,7 @@ export const accessApprovalPolicyServiceFactory = ({
}
}
const approvalsRequiredGroupByStepNumber = groupBy(approvalsRequired || [], (i) => i.stepNumber);
const accessApproval = await accessApprovalPolicyDAL.transaction(async (tx) => {
const doc = await accessApprovalPolicyDAL.create(
{
@@ -195,7 +196,9 @@ export const accessApprovalPolicyServiceFactory = ({
approverUserId: el.id,
policyId: doc.id,
sequence: el.sequence,
approvalsRequired: el.sequence ? approvalsRequired?.[el.sequence]?.numberOfApprovals : approvals
approvalsRequired: el.sequence
? approvalsRequiredGroupByStepNumber?.[el.sequence]?.[0]?.numberOfApprovals
: approvals
})),
tx
);
@@ -207,7 +210,9 @@ export const accessApprovalPolicyServiceFactory = ({
approverGroupId: el.id,
policyId: doc.id,
sequence: el.sequence,
approvalsRequired: el.sequence ? approvalsRequired?.[el.sequence]?.numberOfApprovals : approvals
approvalsRequired: el.sequence
? approvalsRequiredGroupByStepNumber?.[el.sequence]?.[0]?.numberOfApprovals
: approvals
})),
tx
);
@@ -284,7 +289,6 @@ export const accessApprovalPolicyServiceFactory = ({
id: string;
sequence?: number;
}[];
const userApproverNames = approvers.filter(
(approver) => approver.type === ApproverType.User && approver.username
) as { username: string; sequence?: number }[];
@@ -385,6 +389,7 @@ export const accessApprovalPolicyServiceFactory = ({
}
}
const approvalsRequiredGroupByStepNumber = groupBy(approvalsRequired || [], (i) => i.stepNumber);
const updatedPolicy = await accessApprovalPolicyDAL.transaction(async (tx) => {
const doc = await accessApprovalPolicyDAL.updateById(
accessApprovalPolicy.id,
@@ -427,13 +432,14 @@ export const accessApprovalPolicyServiceFactory = ({
}))
);
}
await accessApprovalPolicyApproverDAL.insertMany(
approverUserIds.map((el) => ({
approverUserId: el.id,
policyId: doc.id,
sequence: el.sequence,
approvalsRequired: el.sequence ? approvalsRequired?.[el.sequence]?.numberOfApprovals : approvals
approvalsRequired: el.sequence
? approvalsRequiredGroupByStepNumber?.[el.sequence]?.[0]?.numberOfApprovals
: approvals
})),
tx
);
@@ -445,7 +451,9 @@ export const accessApprovalPolicyServiceFactory = ({
approverGroupId: el.id,
policyId: doc.id,
sequence: el.sequence,
approvalsRequired: el.sequence ? approvalsRequired?.[el.sequence]?.numberOfApprovals : approvals
approvalsRequired: el.sequence
? approvalsRequiredGroupByStepNumber?.[el.sequence]?.[0]?.numberOfApprovals
: approvals
})),
tx
);

View File

@@ -39,7 +39,7 @@ export type TCreateAccessApprovalPolicy = {
name: string;
enforcementLevel: EnforcementLevel;
allowedSelfApprovals: boolean;
approvalsRequired?: Record<number, { numberOfApprovals: number }>;
approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[];
} & Omit<TProjectPermission, "projectId">;
export type TUpdateAccessApprovalPolicy = {
@@ -57,7 +57,7 @@ export type TUpdateAccessApprovalPolicy = {
name?: string;
enforcementLevel?: EnforcementLevel;
allowedSelfApprovals: boolean;
approvalsRequired?: Record<number, { numberOfApprovals: number }>;
approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[];
} & Omit<TProjectPermission, "projectId">;
export type TDeleteAccessApprovalPolicy = {

View File

@@ -39,12 +39,16 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
`${TableName.AccessApprovalRequest}.id`,
`${TableName.AccessApprovalRequestReviewer}.requestId`
)
.leftJoin(
TableName.AccessApprovalPolicyApprover,
`${TableName.AccessApprovalPolicy}.id`,
`${TableName.AccessApprovalPolicyApprover}.policyId`
)
.leftJoin<TUsers>(
db(TableName.Users).as("accessApprovalPolicyApproverUser"),
`${TableName.AccessApprovalPolicyApprover}.approverUserId`,
"accessApprovalPolicyApproverUser.id"
)
.leftJoin(
TableName.UserGroupMembership,
`${TableName.AccessApprovalPolicyApprover}.approverGroupId`,
@@ -82,13 +86,18 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
db.ref("envId").withSchema(TableName.AccessApprovalPolicy).as("policyEnvId"),
db.ref("deletedAt").withSchema(TableName.AccessApprovalPolicy).as("policyDeletedAt")
)
.select(db.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover))
.select(db.ref("sequence").withSchema(TableName.AccessApprovalPolicyApprover).as("approverSequence"))
.select(db.ref("approvalsRequired").withSchema(TableName.AccessApprovalPolicyApprover))
.select(db.ref("userId").withSchema(TableName.UserGroupMembership).as("approverGroupUserId"))
.select(db.ref("bypasserUserId").withSchema(TableName.AccessApprovalPolicyBypasser))
.select(db.ref("userId").withSchema("bypasserUserGroupMembership").as("bypasserGroupUserId"))
.select(
db.ref("email").withSchema("accessApprovalPolicyApproverUser").as("approverEmail"),
db.ref("email").withSchema(TableName.Users).as("approverGroupEmail"),
db.ref("username").withSchema("accessApprovalPolicyApproverUser").as("approverUsername"),
db.ref("username").withSchema(TableName.Users).as("approverGroupUsername")
)
.select(
db.ref("projectId").withSchema(TableName.Environment),
db.ref("slug").withSchema(TableName.Environment).as("envSlug"),
@@ -173,11 +182,33 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
label: "reviewers" as const,
mapper: ({ reviewerUserId: userId, reviewerStatus: status }) => (userId ? { userId, status } : undefined)
},
{ key: "approverUserId", label: "approvers" as const, mapper: ({ approverUserId }) => approverUserId },
{
key: "approverUserId",
label: "approvers" as const,
mapper: ({ approverUserId, approverSequence, approvalsRequired, approverUsername, approverEmail }) => ({
userId: approverUserId,
sequence: approverSequence,
approvalsRequired,
email: approverEmail,
username: approverUsername
})
},
{
key: "approverGroupUserId",
label: "approvers" as const,
mapper: ({ approverGroupUserId }) => approverGroupUserId
mapper: ({
approverGroupUserId,
approverSequence,
approvalsRequired,
approverGroupEmail,
approverGroupUsername
}) => ({
userId: approverGroupUserId,
sequence: approverSequence,
approvalsRequired,
email: approverGroupEmail,
username: approverGroupUsername
})
},
{ key: "bypasserUserId", label: "bypassers" as const, mapper: ({ bypasserUserId }) => bypasserUserId },
{
@@ -192,7 +223,11 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
return formattedDocs.map((doc) => ({
...doc,
policy: { ...doc.policy, approvers: doc.approvers, bypassers: doc.bypassers }
policy: {
...doc.policy,
approvers: doc.approvers.filter((el) => el.userId).sort((a, b) => (a.sequence || 0) - (b.sequence || 0)),
bypassers: doc.bypassers
}
}));
} catch (error) {
throw new DatabaseError({ error, name: "FindRequestsWithPrivilege" });
@@ -272,6 +307,8 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
.select(selectAllTableCols(TableName.AccessApprovalRequest))
.select(
tx.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover),
tx.ref("sequence").withSchema(TableName.AccessApprovalPolicyApprover).as("approverSequence"),
tx.ref("approvalsRequired").withSchema(TableName.AccessApprovalPolicyApprover),
tx.ref("userId").withSchema(TableName.UserGroupMembership),
tx.ref("email").withSchema("accessApprovalPolicyApproverUser").as("approverEmail"),
tx.ref("email").withSchema("accessApprovalPolicyGroupApproverUser").as("approverGroupEmail"),
@@ -367,13 +404,17 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
approverEmail: email,
approverUsername: username,
approverLastName: lastName,
approverFirstName: firstName
approverFirstName: firstName,
approverSequence,
approvalsRequired
}) => ({
userId: approverUserId,
email,
firstName,
lastName,
username
username,
sequence: approverSequence,
approvalsRequired
})
},
{
@@ -384,13 +425,17 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
approverGroupEmail: email,
approverGroupUsername: username,
approverGroupLastName: lastName,
approverFirstName: firstName
approverFirstName: firstName,
approverSequence,
approvalsRequired
}) => ({
userId,
email,
firstName,
lastName,
username
username,
sequence: approverSequence,
approvalsRequired
})
},
{
@@ -434,7 +479,9 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => {
...formattedDoc[0],
policy: {
...formattedDoc[0].policy,
approvers: formattedDoc[0].approvers,
approvers: formattedDoc[0].approvers
.filter((el) => el.userId)
.sort((a, b) => (a.sequence || 0) - (b.sequence || 0)),
bypassers: formattedDoc[0].bypassers
}
};

View File

@@ -4,6 +4,7 @@ import msFn from "ms";
import { ActionProjectType, ProjectMembershipRole } from "@app/db/schemas";
import { getConfig } from "@app/lib/config/env";
import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors";
import { groupBy } from "@app/lib/fn";
import { ms } from "@app/lib/ms";
import { alphaNumericNanoId } from "@app/lib/nanoid";
import { EnforcementLevel } from "@app/lib/types";
@@ -358,7 +359,6 @@ export const accessApprovalRequestServiceFactory = ({
const cannotBypassUnderSoftEnforcement = !(isSoftEnforcement && canBypass);
const isApprover = policy.approvers.find((approver) => approver.userId === actorId);
// If user is (not an approver OR cant self approve) AND can't bypass policy
if ((!isApprover || (!policy.allowedSelfApprovals && isSelfApproval)) && cannotBypassUnderSoftEnforcement) {
throw new BadRequestError({
@@ -383,6 +383,41 @@ export const accessApprovalRequestServiceFactory = ({
if (existingReviews.some((review) => review.status === ApprovalStatus.REJECTED)) {
throw new BadRequestError({ message: "The request has already been rejected by another reviewer" });
}
const reviewsGroupById = groupBy(
existingReviews.filter((review) => review.status === ApprovalStatus.APPROVED),
(i) => i.reviewerUserId
);
const approvedSequences = policy.approvers.reduce(
(acc, curr) => {
const hasApproved = reviewsGroupById?.[curr.userId as string]?.[0];
if (acc?.[acc.length - 1]?.step === curr.sequence) {
if (hasApproved) {
acc[acc.length - 1].approvals += 1;
}
return acc;
}
acc.push({
step: curr.sequence || 1,
approvals: hasApproved ? 1 : 0,
requiredApprovals: curr.approvalsRequired || 1
});
return acc;
},
[] as { step: number; approvals: number; requiredApprovals: number }[]
);
const presentSequence = approvedSequences.find((el) => el.approvals < el.requiredApprovals) || {
step: 1,
approvals: 0,
requiredApprovals: 1
};
if (presentSequence) {
const isApproverOfTheSequence = policy.approvers.find(
(el) => el.sequence === presentSequence.step && el.userId === actorId
);
if (!isApproverOfTheSequence) throw new BadRequestError({ message: "You are not reviewer in this step" });
}
const reviewStatus = await accessApprovalRequestReviewerDAL.transaction(async (tx) => {
const isBreakGlassApprovalAttempt =
@@ -426,11 +461,14 @@ export const accessApprovalRequestServiceFactory = ({
);
}
const otherReviews = existingReviews.filter((er) => er.reviewerUserId !== actorId);
const allUniqueReviews = [...otherReviews, reviewForThisActorProcessing];
if (status === ApprovalStatus.REJECTED) {
await accessApprovalRequestDAL.updateById(accessApprovalRequest.id, { status: ApprovalStatus.REJECTED }, tx);
return reviewForThisActorProcessing;
}
const approvedReviews = allUniqueReviews.filter((r) => r.status === ApprovalStatus.APPROVED);
const meetsStandardApprovalThreshold = approvedReviews.length >= policy.approvals;
const meetsStandardApprovalThreshold =
(presentSequence?.approvals || 0) + 1 >= presentSequence.requiredApprovals &&
approvedSequences.at(-1)?.step === presentSequence?.step;
if (
reviewForThisActorProcessing.status === ApprovalStatus.APPROVED &&

View File

@@ -36,12 +36,12 @@ export const getDefaultOnPremFeatures = (): TFeatureSet => ({
oidcSSO: false,
scim: false,
ldap: false,
groups: false,
groups: true,
status: null,
trial_end: null,
has_used_trial: true,
secretApproval: false,
secretRotation: false,
secretApproval: true,
secretRotation: true,
caCrl: false,
instanceUserManagement: false,
externalKms: false,

View File

@@ -64,6 +64,10 @@ export const FilterableSelect = <T,>({
control: (base) => ({
...base,
transition: "none"
}),
menuPortal: (provided) => ({
...provided,
zIndex: 9999
})
}}
tabSelectsValue={tabSelectsValue}

View File

@@ -25,7 +25,8 @@ export const useCreateAccessApprovalPolicy = () => {
name,
secretPath,
enforcementLevel,
allowedSelfApprovals
allowedSelfApprovals,
approvalsRequired
}) => {
const { data } = await apiRequest.post("/api/v1/access-approvals/policies", {
environment,
@@ -36,7 +37,8 @@ export const useCreateAccessApprovalPolicy = () => {
secretPath,
name,
enforcementLevel,
allowedSelfApprovals
allowedSelfApprovals,
approvalsRequired
});
return data;
},
@@ -60,7 +62,8 @@ export const useUpdateAccessApprovalPolicy = () => {
name,
secretPath,
enforcementLevel,
allowedSelfApprovals
allowedSelfApprovals,
approvalsRequired
}) => {
const { data } = await apiRequest.patch(`/api/v1/access-approvals/policies/${id}`, {
approvals,
@@ -69,7 +72,8 @@ export const useUpdateAccessApprovalPolicy = () => {
secretPath,
name,
enforcementLevel,
allowedSelfApprovals
allowedSelfApprovals,
approvalsRequired
});
return data;
},

View File

@@ -33,6 +33,8 @@ export enum BypasserType {
export type Approver = {
id: string;
type: ApproverType;
sequence?: number;
approvals?: number;
};
export type Bypasser = {
@@ -78,7 +80,13 @@ export type TAccessApprovalRequest = {
id: string;
name: string;
approvals: number;
approvers: string[];
approvers: {
userId: string;
sequence?: number;
approvalsRequired?: number;
username: string;
email: string;
}[];
bypassers: string[];
secretPath?: string | null;
envId: string;
@@ -88,7 +96,7 @@ export type TAccessApprovalRequest = {
};
reviewers: {
member: string;
userId: string;
status: string;
}[];
@@ -163,6 +171,7 @@ export type TCreateAccessPolicyDTO = {
secretPath?: string;
enforcementLevel?: EnforcementLevel;
allowedSelfApprovals: boolean;
approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[];
};
export type TUpdateAccessPolicyDTO = {
@@ -177,6 +186,7 @@ export type TUpdateAccessPolicyDTO = {
allowedSelfApprovals: boolean;
// for invalidating list
projectSlug: string;
approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[];
};
export type TDeleteSecretPolicyDTO = {

View File

@@ -225,7 +225,7 @@ export const useGetSecretApprovalRequestCount = ({
}) =>
useQuery({
queryKey: secretApprovalRequestKeys.count({ workspaceId }),
refetchInterval: 5000,
refetchInterval: 15000,
queryFn: () => fetchSecretApprovalRequestCount({ workspaceId }),
enabled: Boolean(workspaceId) && (options?.enabled ?? true)
});

View File

@@ -27,8 +27,8 @@ import {
} from "@app/hooks/api/auth/queries";
import { MfaMethod } from "@app/hooks/api/auth/types";
import { fetchOrganizations } from "@app/hooks/api/organization/queries";
import { ProjectType } from "@app/hooks/api/workspace/types";
import { isLoggedIn } from "@app/hooks/api/reactQuery";
import { ProjectType } from "@app/hooks/api/workspace/types";
// eslint-disable-next-line new-cap
const client = new jsrp.client();

View File

@@ -87,6 +87,7 @@ export const AccessApprovalRequest = ({
isRequestedByCurrentUser: boolean;
isSelfApproveAllowed: boolean;
isApprover: boolean;
isDisabled?: boolean;
})
| null
>(null);
@@ -147,16 +148,17 @@ export const AccessApprovalRequest = ({
const generateRequestDetails = useCallback(
(request: TAccessApprovalRequest) => {
const isReviewedByUser =
request.reviewers.findIndex(({ member }) => member === user.id) !== -1;
request.reviewers.findIndex(({ userId }) => userId === user.id) !== -1;
const isRejectedByAnyone = request.reviewers.some(
({ status }) => status === ApprovalStatus.REJECTED
);
const isApprover = request.policy.approvers.indexOf(user.id || "") !== -1;
const isApprover =
request.policy.approvers.findIndex((el) => el.userId === 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 userReviewStatus = request.reviewers.find(({ userId }) => userId === user.id)?.status;
const canBypass =
!request.policy.bypassers.length || request.policy.bypassers.includes(user.id);
@@ -205,21 +207,6 @@ export const AccessApprovalRequest = ({
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 && details.canBypass;
// 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,
@@ -381,9 +368,6 @@ export const AccessApprovalRequest = ({
return (
<div
aria-disabled={
details.isReviewedByUser || details.isRejectedByAnyone || details.isAccepted
}
key={request.id}
className="flex w-full cursor-pointer px-8 py-4 hover:bg-mineshaft-700 aria-disabled:opacity-80"
role="button"
@@ -450,9 +434,11 @@ export const AccessApprovalRequest = ({
{!!selectedRequest && (
<ReviewAccessRequestModal
selectedEnvSlug={envFilter}
policies={policies || []}
selectedRequester={requestedByFilter}
projectSlug={projectSlug}
request={selectedRequest}
members={members || []}
isOpen={popUp.reviewRequest.isOpen}
onOpenChange={() => {
handlePopUpClose("reviewRequest");

View File

@@ -1,16 +1,48 @@
import { useCallback, useMemo, useState } from "react";
import { faTriangleExclamation } from "@fortawesome/free-solid-svg-icons";
import {
faCheckCircle,
faCircle,
faTriangleExclamation,
faUsers,
faXmarkCircle
} 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, FormControl, Input, Modal, ModalContent } from "@app/components/v2";
import {
Button,
Checkbox,
FormControl,
Input,
Modal,
ModalContent,
Popover,
PopoverContent,
PopoverTrigger,
Tooltip
} 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 { ProjectPermissionActions, useUser, useWorkspace } from "@app/context";
import { useListWorkspaceGroups, useReviewAccessRequest } from "@app/hooks/api";
import {
Approver,
ApproverType,
TAccessApprovalPolicy,
TAccessApprovalRequest
} from "@app/hooks/api/accessApproval/types";
import { EnforcementLevel } from "@app/hooks/api/policies/enums";
import { ApprovalStatus, TWorkspaceUser } from "@app/hooks/api/types";
import { groupBy } from "@app/lib/fn/array";
const getReviewedStatusSymbol = (status?: ApprovalStatus) => {
if (status === ApprovalStatus.APPROVED)
return <FontAwesomeIcon icon={faCheckCircle} size="xs" style={{ color: "#15803d" }} />;
if (status === ApprovalStatus.REJECTED)
return <FontAwesomeIcon icon={faXmarkCircle} size="xs" style={{ color: "#b91c1c" }} />;
return <FontAwesomeIcon icon={faCircle} size="xs" style={{ color: "#c2410c" }} />;
};
export const ReviewAccessRequestModal = ({
isOpen,
@@ -19,7 +51,9 @@ export const ReviewAccessRequestModal = ({
projectSlug,
selectedRequester,
selectedEnvSlug,
canBypass
canBypass,
policies = [],
members = []
}: {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
@@ -33,10 +67,15 @@ export const ReviewAccessRequestModal = ({
selectedRequester: string | undefined;
selectedEnvSlug: string | undefined;
canBypass: boolean;
policies: TAccessApprovalPolicy[];
members: TWorkspaceUser[];
}) => {
const [isLoading, setIsLoading] = useState<"approved" | "rejected" | null>(null);
const [bypassApproval, setBypassApproval] = useState(false);
const [bypassReason, setBypassReason] = useState("");
const { currentWorkspace } = useWorkspace();
const { data: groupMemberships = [] } = useListWorkspaceGroups(currentWorkspace?.id || "");
const { user } = useUser();
const isSoftEnforcement = request.policy.enforcementLevel === EnforcementLevel.Soft;
@@ -134,6 +173,54 @@ export const ReviewAccessRequestModal = ({
]
);
const approverSequence = useMemo(() => {
const policy = policies.find((el) => el.id === request.policy.id);
const reviewesGroupById = groupBy(request.reviewers, (i) => i.userId);
const membersGroupById = groupBy(members, (i) => i.user.id);
const projectGroupsGroupById = groupBy(groupMemberships, (i) => i.group.id);
const approversBySequence = policy?.approvers?.reduce(
(acc, curr) => {
if (acc.length > 1 && acc[acc.length - 1].sequence === curr.sequence) {
acc[acc.length - 1][curr.type]?.push(curr);
return acc;
}
const approvals = curr.approvals || policy.approvals;
const sequence = curr.sequence || 1;
acc.push(
curr.type === ApproverType.User
? { user: [curr], group: [], sequence, approvals }
: { group: [curr], user: [], sequence, approvals }
);
return acc;
},
[] as {
user: Approver[];
group: Approver[];
sequence?: number;
approvals?: number;
}[]
);
const approvers = approversBySequence?.map((approverChain) => {
const reviewers = request.policy.approvers
.filter((el) => (el.sequence || 1) === approverChain.sequence)
.map((el) => ({ ...el, status: reviewesGroupById?.[el.userId]?.[0]?.status }));
const hasApproved =
reviewers.filter((el) => el.status === "approved").length >=
(approverChain?.approvals || 1);
const hasRejected = reviewers.filter((el) => el.status === ApprovalStatus.REJECTED).length;
return { ...approverChain, reviewers, hasApproved, hasRejected };
});
return { approvers, membersGroupById, projectGroupsGroupById };
}, [request, policies]);
const hasRejected = request.reviewers.find((el) => el.status === ApprovalStatus.REJECTED);
const isReviewedByMe = request.reviewers.find((i) => i.userId === user.id);
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<ModalContent
@@ -141,106 +228,228 @@ export const ReviewAccessRequestModal = ({
title="Review Request"
subTitle="Review the request and approve or deny access."
>
<div className="text-sm">
<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>
<Badge>{accessDetails.env + accessDetails.secretPath || ""}</Badge>
</div>
<div className="mb-1">
<span className="font-bold">Permissions: </span>
<Badge>{requestedAccess}</Badge>
</div>
<div>
<span className="font-bold">Access Type: </span>
<span>{getAccessLabel()}</span>
</div>
{request.note && (
<div className="mt-1">
<span className="font-bold">User Note: </span>
<span>{request.note}</span>
<div className="mb-4 rounded-r border-l-2 border-l-primary bg-mineshaft-300/5 px-4 py-2.5 text-sm">
{request.user &&
(request.user.firstName || request.user.lastName) &&
request.user.email ? (
<span className="inline font-bold">
{request.user?.firstName} {request.user?.lastName} ({request.user?.email})
</span>
) : (
<span>A user</span>
)}{" "}
is requesting access to the following resource:
</div>
<div className="">
<div className="mb-2 mt-4 text-mineshaft-200">
<div className="grid grid-cols-2 gap-4">
<div>
<div className="mb-1 text-xs font-semibold uppercase">Environment</div>
<div>{accessDetails.env || "-"}</div>
</div>
)}
<div>
<div className="mb-1 text-xs font-semibold uppercase">Secret Path</div>
<div>{accessDetails.secretPath || "-"}</div>
</div>
<div>
<div className="mb-1 text-xs font-semibold uppercase">Access Type</div>
<div>{getAccessLabel()}</div>
</div>
<div>
<div className="mb-1 text-xs font-semibold uppercase">Permission</div>
<div>{requestedAccess}</div>
</div>
<div className="col-span-2">
<div className="mb-1 text-xs font-semibold uppercase">Note</div>
<div>{request.note || "-"}</div>
</div>
</div>
</div>
<div className="space-x-2">
<Button
isLoading={isLoading === "approved"}
isDisabled={
!!isLoading ||
(!(
request.isApprover &&
(!request.isRequestedByCurrentUser || request.isSelfApproveAllowed)
) &&
!bypassApproval)
}
onClick={() => handleReview("approved")}
className="mt-4"
size="sm"
colorSchema={!request.isApprover && isSoftEnforcement ? "danger" : "primary"}
>
Approve Request
</Button>
<Button
isLoading={isLoading === "rejected"}
isDisabled={!!isLoading}
onClick={() => handleReview("rejected")}
className="mt-4 border-transparent bg-transparent text-mineshaft-200 hover:border-red hover:bg-red/20 hover:text-mineshaft-200"
size="sm"
>
Reject Request
</Button>
</div>
{isSoftEnforcement &&
request.isRequestedByCurrentUser &&
!(request.isApprover && request.isSelfApproveAllowed) &&
canBypass && (
<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"
<div className="mb-4 border-b-2 border-mineshaft-500 py-2 text-lg">Approvers</div>
<div className="thin-scrollbar max-h-64 overflow-y-auto rounded p-2">
{approverSequence?.approvers?.map((approver, index) => (
<div
key={`approval-list-${index + 1}`}
className="relative mb-2 flex rounded border border-mineshaft-500 bg-mineshaft-700 p-4"
>
<div>
<div
className={twMerge(
"mr-8 flex h-8 w-8 items-center justify-center border border-bunker-300 bg-bunker-800 text-white",
approver.hasApproved && "border-green-400 text-green-400",
approver.hasRejected && "border-red-500 text-red-500"
)}
>
<Input
value={bypassReason}
onChange={(e) => setBypassReason(e.currentTarget.value)}
placeholder="Enter reason for bypass (min 10 chars)"
leftIcon={<FontAwesomeIcon icon={faTriangleExclamation} />}
<div className="text-lg">{index + 1}</div>
</div>
{index !== (approverSequence?.approvers?.length || 0) - 1 && (
<div
className={twMerge(
"absolute bottom-0 left-8 h-6 border-r border-gray-400",
approver.hasApproved && "border-green-400",
approver.hasRejected && "border-red-500"
)}
/>
</FormControl>
)}
)}
{index !== 0 && (
<div
className={twMerge(
"absolute left-8 top-0 h-4 border-r border-gray-400",
approver.hasApproved && "border-green-400",
approver.hasRejected && "border-red-500"
)}
/>
)}
</div>
<div className="grid flex-grow grid-cols-3">
<div>
<div className="mb-1 text-xs font-semibold uppercase">Users</div>
<div>
{approver?.user
?.map(
(el) => approverSequence?.membersGroupById?.[el.id]?.[0]?.user?.username
)
.join(",") || "-"}
</div>
</div>
<div>
<div className="mb-1 text-xs font-semibold uppercase">Groups</div>
<div>
{approver?.group
?.map(
(el) =>
approverSequence?.projectGroupsGroupById?.[el.id]?.[0]?.group?.name
)
.join(",") || "-"}
</div>
</div>
<div className="flex items-center">
<div>
<div className="mb-1 text-xs font-semibold uppercase">Approvals Required</div>
<div>{approver.approvals || "-"}</div>
</div>
<div className="ml-16">
<Popover>
<PopoverTrigger>
<FontAwesomeIcon
icon={faUsers}
className={twMerge(
approver.hasApproved && "border-green-400 text-green-400",
approver.hasRejected && "border-red-500 text-red-500"
)}
/>
</PopoverTrigger>
<PopoverContent hideCloseBtn className="pt-3">
<div>
<div className="mb-1 text-sm text-bunker-300">Reviewers</div>
<div className="thin-scrollbar flex max-h-64 flex-col gap-1 overflow-y-auto rounded">
{approver.reviewers.map((el) => (
<div className="flex items-center gap-2 bg-mineshaft-700 p-1 text-sm">
<div className="flex-grow">{el.username}</div>
<Tooltip
content={`Status: ${el?.status || ApprovalStatus.PENDING}`}
>
{getReviewedStatusSymbol(el?.status as ApprovalStatus)}
</Tooltip>
</div>
))}
</div>
</div>
</PopoverContent>
</Popover>
</div>
</div>
</div>
</div>
)}
))}
</div>
{hasRejected || isReviewedByMe ? (
<div
className={twMerge(
"mb-4 rounded-r border-l-2 border-l-red-500 bg-mineshaft-300/5 px-4 py-2.5 text-sm",
isReviewedByMe && "border-l-green-400"
)}
>
{isReviewedByMe
? "You have reviewed this request."
: "This request has been rejected."}
</div>
) : (
<>
<div className="space-x-2">
<Button
isLoading={isLoading === "approved"}
isDisabled={
Boolean(isLoading) ||
(!(
request.isApprover &&
(!request.isRequestedByCurrentUser || request.isSelfApproveAllowed)
) &&
!bypassApproval)
}
onClick={() => handleReview("approved")}
className="mt-4"
size="sm"
colorSchema={!request.isApprover && isSoftEnforcement ? "danger" : "primary"}
>
Approve Request
</Button>
<Button
isLoading={isLoading === "rejected"}
isDisabled={
!!isLoading ||
(!(
request.isApprover &&
(!request.isRequestedByCurrentUser || request.isSelfApproveAllowed)
) &&
!bypassApproval)
}
onClick={() => handleReview("rejected")}
className="mt-4 border-transparent bg-transparent text-mineshaft-200 hover:border-red hover:bg-red/20 hover:text-mineshaft-200"
size="sm"
>
Reject Request
</Button>
</div>
{isSoftEnforcement &&
request.isRequestedByCurrentUser &&
!(request.isApprover && request.isSelfApproveAllowed) &&
canBypass && (
<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>

View File

@@ -188,9 +188,6 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => {
<Th>Name</Th>
<Th>Environment</Th>
<Th>Secret Path</Th>
<Th className="w-[18%]">Eligible Approvers</Th>
<Th className="w-[18%]">Eligible Group Approvers</Th>
<Th>Approval Required</Th>
<Th>
<DropdownMenu>
<DropdownMenuTrigger>

View File

@@ -1,6 +1,9 @@
import { useEffect, useMemo } from "react";
import { Controller, useForm } from "react-hook-form";
import { useEffect, useMemo, useRef, useState } from "react";
import { Controller, useFieldArray, useForm } from "react-hook-form";
import { faGripVertical, faTrash } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { zodResolver } from "@hookform/resolvers/zod";
import { twMerge } from "tailwind-merge";
import { z } from "zod";
import { createNotification } from "@app/components/notifications";
@@ -8,12 +11,15 @@ import {
Button,
FilterableSelect,
FormControl,
IconButton,
Input,
Modal,
ModalContent,
Select,
SelectItem,
Switch
Switch,
Tag,
Tooltip
} from "@app/components/v2";
import { useWorkspace } from "@app/context";
import { getMemberLabel } from "@app/helpers/members";
@@ -28,6 +34,7 @@ import {
useUpdateAccessApprovalPolicy
} from "@app/hooks/api/accessApproval";
import {
Approver,
ApproverType,
BypasserType,
TAccessApprovalPolicy
@@ -68,10 +75,28 @@ const formSchema = z
.default([]),
policyType: z.nativeEnum(PolicyType),
enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard),
allowedSelfApprovals: z.boolean().default(true)
allowedSelfApprovals: z.boolean().default(true),
sequenceApprovers: z
.object({
user: z
.object({ type: z.literal(ApproverType.User), id: z.string() })
.array()
.default([]),
group: z
.object({ type: z.literal(ApproverType.Group), id: z.string() })
.array()
.default([]),
approvals: z.number().min(1).default(1)
})
.array()
.default([])
.optional()
})
.superRefine((data, ctx) => {
if (!(data.groupApprovers.length || data.userApprovers.length)) {
if (
data.policyType === PolicyType.ChangePolicy &&
!(data.groupApprovers.length || data.userApprovers.length)
) {
ctx.addIssue({
path: ["userApprovers"],
code: z.ZodIssueCode.custom,
@@ -95,6 +120,9 @@ export const AccessPolicyForm = ({
projectSlug,
editValues
}: Props) => {
const [draggedItem, setDraggedItem] = useState<number | null>(null);
const [dragOverItem, setDragOverItem] = useState<number | null>(null);
const modalContainer = useRef<HTMLDivElement>(null);
const {
control,
handleSubmit,
@@ -103,6 +131,7 @@ export const AccessPolicyForm = ({
formState: { isSubmitting }
} = useForm<TFormSchema>({
resolver: zodResolver(formSchema),
// @ts-expect-error due to collision of approver type
values: editValues
? {
...editValues,
@@ -124,15 +153,47 @@ export const AccessPolicyForm = ({
?.filter((bypasser) => bypasser.type === BypasserType.Group)
.map(({ id, type }) => ({ id, type: type as BypasserType.Group })) || [],
approvals: editValues?.approvals,
allowedSelfApprovals: editValues?.allowedSelfApprovals
allowedSelfApprovals: editValues?.allowedSelfApprovals,
sequenceApprovers: editValues.approvers
?.sort((a, b) => (a?.sequence || 0) - (b?.sequence || 0))
.reduce(
(acc, curr) => {
if (acc.length > 1 && acc[acc.length - 1].sequence === curr.sequence) {
acc[acc.length - 1][curr.type]?.push(curr);
return acc;
}
const approvals = curr.approvals || editValues.approvals;
acc.push(
curr.type === ApproverType.User
? {
user: [curr],
group: [],
sequence: 1,
approvals
}
: { group: [curr], user: [], sequence: 1, approvals }
);
return acc;
},
[] as { user: Approver[]; group: Approver[]; sequence?: number; approvals: number }[]
)
}
: undefined
: undefined,
defaultValues: {
sequenceApprovers: [{ approvals: 1 }]
}
});
const sequenceApproversFieldArray = useFieldArray({
control,
name: "sequenceApprovers"
});
const { currentWorkspace } = useWorkspace();
const { data: groups } = useListWorkspaceGroups(projectId);
const environments = currentWorkspace?.environments || [];
const isEditMode = Boolean(editValues);
const isAccessPolicyType = watch("policyType") === PolicyType.AccessPolicy;
useEffect(() => {
if (!isOpen || !isEditMode) reset({});
@@ -157,6 +218,7 @@ export const AccessPolicyForm = ({
userApprovers,
groupBypassers,
userBypassers,
sequenceApprovers,
...data
}: TFormSchema) => {
if (!projectId) return;
@@ -175,7 +237,15 @@ export const AccessPolicyForm = ({
} else {
await createAccessApprovalPolicy({
...data,
approvers: [...userApprovers, ...groupApprovers],
approvers: sequenceApprovers?.flatMap((approvers, index) =>
approvers.user
.map((el) => ({ ...el, sequence: index + 1 }) as Approver)
.concat(approvers.group.map((el) => ({ ...el, sequence: index + 1 })))
),
approvalsRequired: sequenceApprovers?.map((el, index) => ({
stepNumber: index + 1,
numberOfApprovals: el.approvals
})),
bypassers: bypassers.length > 0 ? bypassers : undefined,
environment: environment.slug,
projectSlug
@@ -201,6 +271,7 @@ export const AccessPolicyForm = ({
groupApprovers,
userBypassers,
groupBypassers,
sequenceApprovers,
...data
}: TFormSchema) => {
if (!projectId || !projectSlug) return;
@@ -221,7 +292,15 @@ export const AccessPolicyForm = ({
await updateAccessApprovalPolicy({
id: editValues?.id,
...data,
approvers: [...userApprovers, ...groupApprovers],
approvers: sequenceApprovers?.flatMap((approvers, index) =>
approvers.user
.map((el) => ({ ...el, sequence: index + 1 }) as Approver)
.concat(approvers.group.map((el) => ({ ...el, sequence: index + 1 })))
),
approvalsRequired: sequenceApprovers?.map((el, index) => ({
stepNumber: index + 1,
numberOfApprovals: el.approvals
})),
bypassers: bypassers.length > 0 ? bypassers : undefined,
environment: environment.slug,
projectSlug
@@ -285,16 +364,46 @@ export const AccessPolicyForm = ({
[groups]
);
const handleDragStart = (_: React.DragEvent, index: number) => {
setDraggedItem(index);
};
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
setDragOverItem(index);
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
if (draggedItem === null || dragOverItem === null || draggedItem === dragOverItem) {
setDraggedItem(null);
setDragOverItem(null);
return;
}
sequenceApproversFieldArray.move(draggedItem, dragOverItem);
setDraggedItem(null);
setDragOverItem(null);
};
const handleDragEnd = () => {
setDraggedItem(null);
setDragOverItem(null);
};
return (
<Modal isOpen={isOpen} onOpenChange={onToggle}>
<ModalContent
className="max-w-2xl"
className="max-w-3xl"
bodyClassName="overflow-visible"
ref={modalContainer}
title={isEditMode ? `Edit ${policyName}` : "Create Policy"}
>
<div className="flex flex-col space-y-3">
<form onSubmit={handleSubmit(handleFormSubmit)}>
<div className="grid grid-cols-2 gap-x-3">
<div className="flex items-center gap-x-3">
<Controller
control={control}
name="policyType"
@@ -306,6 +415,7 @@ export const AccessPolicyForm = ({
isError={Boolean(error)}
tooltipText="Change policies govern secret changes within a given environment and secret path. Access policies allow underprivileged user to request access to environment/secret path."
errorText={error?.message}
className="flex-grow"
>
<Select
isDisabled={isEditMode}
@@ -324,25 +434,30 @@ export const AccessPolicyForm = ({
</FormControl>
)}
/>
<Controller
control={control}
name="approvals"
defaultValue={1}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Minimum Approvals Required"
isError={Boolean(error)}
errorText={error?.message}
>
<Input
{...field}
type="number"
min={1}
onChange={(el) => field.onChange(parseInt(el.target.value, 10))}
/>
</FormControl>
)}
/>
{!isAccessPolicyType && (
<Controller
control={control}
name="approvals"
defaultValue={1}
render={({ field, fieldState: { error } }) => (
<FormControl
label="Min. Approvals Required"
isError={Boolean(error)}
errorText={error?.message}
className="flex-grow"
>
<Input
{...field}
type="number"
min={1}
onChange={(el) => field.onChange(parseInt(el.target.value, 10))}
/>
</FormControl>
)}
/>
)}
</div>
<div className="flex items-center gap-x-3">
<Controller
control={control}
name="name"
@@ -351,6 +466,7 @@ export const AccessPolicyForm = ({
label="Policy Name"
isError={Boolean(error)}
errorText={error?.message}
className="flex-grow"
>
<Input {...field} value={field.value || ""} />
</FormControl>
@@ -366,6 +482,7 @@ export const AccessPolicyForm = ({
label="Secret Path"
isError={Boolean(error)}
errorText={error?.message}
className="flex-grow"
>
<Input {...field} value={field.value || ""} />
</FormControl>
@@ -400,62 +517,199 @@ export const AccessPolicyForm = ({
Select members or groups that are allowed to approve requests from this policy.
</p>
</div>
<div className="flex gap-2">
<Controller
control={control}
name="userApprovers"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="User Approvers"
isError={Boolean(error)}
errorText={error?.message}
className="w-1/2"
>
<FilterableSelect
menuPlacement="top"
isMulti
placeholder="Select members..."
options={memberOptions}
getOptionValue={(option) => option.id}
getOptionLabel={(option) => {
const member = members?.find((m) => m.user.id === option.id);
{isAccessPolicyType ? (
<>
<div className="thin-scrollbar max-h-64 space-y-2 overflow-y-auto rounded">
{sequenceApproversFieldArray.fields.map((el, index) => (
<div
className={twMerge(
"rounded border border-mineshaft-500 bg-mineshaft-700 p-3 pb-0",
dragOverItem === index ? "border-2 border-blue-400" : "",
draggedItem === index ? "opacity-50" : ""
)}
key={el.id}
onDragOver={(e) => handleDragOver(e, index)}
onDrop={handleDrop}
>
<div className="mb-3 flex items-center justify-between">
<Tag>Step {index + 1}</Tag>
<div className="flex items-center gap-3">
<div className="inline text-xs text-mineshaft-400">Min. Approvals</div>
<div className="mr-2 w-20 border-r border-mineshaft-400 pr-3">
<Controller
control={control}
name={`sequenceApprovers.${index}.approvals` as const}
defaultValue={1}
render={({ field }) => (
<Input
{...field}
type="number"
size="xs"
min={1}
onChange={(val) => field.onChange(parseInt(val.target.value, 10))}
/>
)}
/>
</div>
<Tooltip content="Remove step">
<IconButton
ariaLabel="delete"
variant="plain"
onClick={() => sequenceApproversFieldArray.remove(index)}
className="text-red-500 hover:text-gray-200"
>
<FontAwesomeIcon icon={faTrash} />
</IconButton>
</Tooltip>
<Tooltip content="Drag to reorder permission">
<div
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragEnd={handleDragEnd}
className="mr-2 cursor-move text-gray-400 hover:text-gray-200"
>
<FontAwesomeIcon icon={faGripVertical} />
</div>
</Tooltip>
</div>
</div>
<div className="flex gap-2">
<Controller
control={control}
name={`sequenceApprovers.${index}.user` as const}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="User Approvers"
isError={Boolean(error)}
errorText={error?.message}
className="flex-grow"
>
<FilterableSelect
menuPortalTarget={modalContainer.current}
menuPlacement="top"
isMulti
placeholder="Select members..."
options={memberOptions}
getOptionValue={(option) => option.id}
getOptionLabel={(option) => {
const member = members?.find((m) => m.user.id === option.id);
if (!member) return option.id;
if (!member) return option.id;
return getMemberLabel(member);
}}
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="groupApprovers"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Group Approvers"
isError={Boolean(error)}
errorText={error?.message}
className="w-1/2"
return getMemberLabel(member);
}}
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>
<Controller
control={control}
name={`sequenceApprovers.${index}.group` as const}
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Group Approvers"
isError={Boolean(error)}
errorText={error?.message}
className="flex-grow"
>
<FilterableSelect
menuPortalTarget={modalContainer.current}
menuPlacement="top"
isMulti
placeholder="Select groups..."
options={groupOptions}
getOptionValue={(option) => option.id}
getOptionLabel={(option) =>
groups?.find(({ group }) => group.id === option.id)?.group.name ??
option.id
}
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>
</div>
</div>
))}
</div>
<div className="my-2">
<Button
size="xs"
variant="outline_bg"
onClick={() =>
sequenceApproversFieldArray.append({
approvals: 1,
user: [],
group: []
})
}
>
<FilterableSelect
menuPlacement="top"
isMulti
placeholder="Select groups..."
options={groupOptions}
getOptionValue={(option) => option.id}
getOptionLabel={(option) =>
groups?.find(({ group }) => group.id === option.id)?.group.name ?? option.id
}
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>
</div>
Add Step
</Button>
</div>
</>
) : (
<div className="flex gap-2">
<Controller
control={control}
name="userApprovers"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="User Approvers"
isError={Boolean(error)}
errorText={error?.message}
className="w-1/2"
>
<FilterableSelect
menuPlacement="top"
isMulti
placeholder="Select members..."
options={memberOptions}
getOptionValue={(option) => option.id}
getOptionLabel={(option) => {
const member = members?.find((m) => m.user.id === option.id);
if (!member) return option.id;
return getMemberLabel(member);
}}
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>
<Controller
control={control}
name="groupApprovers"
render={({ field: { value, onChange }, fieldState: { error } }) => (
<FormControl
label="Group Approvers"
isError={Boolean(error)}
errorText={error?.message}
className="w-1/2"
>
<FilterableSelect
menuPlacement="top"
isMulti
placeholder="Select groups..."
options={groupOptions}
getOptionValue={(option) => option.id}
getOptionLabel={(option) =>
groups?.find(({ group }) => group.id === option.id)?.group.name ??
option.id
}
value={value}
onChange={onChange}
/>
</FormControl>
)}
/>
</div>
)}
<Controller
control={control}
name="allowedSelfApprovals"

View File

@@ -10,7 +10,6 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
Td,
Tooltip,
Tr
} from "@app/components/v2";
import { Badge } from "@app/components/v2/Badge";
@@ -18,6 +17,7 @@ import { ProjectPermissionSub } from "@app/context";
import { ProjectPermissionActions } from "@app/context/ProjectPermissionContext/types";
import { getMemberLabel } from "@app/helpers/members";
import { policyDetails } from "@app/helpers/policies";
import { useToggle } from "@app/hooks";
import { Approver } from "@app/hooks/api/accessApproval/types";
import { TGroupMembership } from "@app/hooks/api/groups/types";
import { EnforcementLevel, PolicyType } from "@app/hooks/api/policies/enums";
@@ -53,113 +53,154 @@ export const ApprovalPolicyRow = ({
onEdit,
onDelete
}: Props) => {
const [isExpanded, setIsExpanded] = useToggle();
const labels = useMemo(() => {
const usersInPolicy = policy.approvers
?.filter((approver) => approver.type === ApproverType.User)
.map((approver) => approver.id);
const sortedSteps = policy.approvers?.sort((a, b) => (a?.sequence || 0) - (b?.sequence || 0));
const entityInSameSequence = sortedSteps?.reduce(
(acc, curr) => {
if (acc.length > 1 && acc[acc.length - 1].sequence === curr.sequence) {
acc[acc.length - 1][curr.type]?.push(curr);
return acc;
}
const approvals = curr.approvals || policy.approvals;
acc.push(
curr.type === ApproverType.User
? { user: [curr], group: [], sequence: 1, approvals }
: { group: [curr], user: [], sequence: 1, approvals }
);
return acc;
},
[] as { user: Approver[]; group: Approver[]; sequence?: number; approvals: number }[]
);
const groupsInPolicy = policy.approvers
?.filter((approver) => approver.type === ApproverType.Group)
.map((approver) => approver.id);
const memberLabels = usersInPolicy?.length
? members
.filter((member) => usersInPolicy?.includes(member.user.id))
return entityInSameSequence?.map((el) => {
return {
sequence: el.sequence || policy.approvals,
userLabels: members
?.filter((member) => el.user.find((i) => i.id === member.user.id))
.map((member) => getMemberLabel(member))
.join(", ")
: null;
const groupLabels = groupsInPolicy?.length
? groups
.filter(({ group }) => groupsInPolicy?.includes(group.id))
.join(","),
groupLabels: groups
?.filter(({ group }) => el.group.find((i) => i.id === group.id))
.map(({ group }) => group.name)
.join(", ")
: null;
return {
members: memberLabels,
groups: groupLabels
};
.join(","),
approvals: el.approvals
};
});
}, [policy, members, groups]);
return (
<Tr>
<Td>{policy.name}</Td>
<Td>{policy.environment.slug}</Td>
<Td>{policy.secretPath || "*"}</Td>
<Td className="max-w-0">
<Tooltip
side="left"
content={labels.members ?? "No users are assigned as approvers for this policy"}
>
<p className="truncate">{labels.members ?? "-"}</p>
</Tooltip>
</Td>
<Td className="max-w-0">
<Tooltip
side="left"
content={labels.groups ?? "No groups are assigned as approvers for this policy"}
>
<p className="truncate">{labels.groups ?? "-"}</p>
</Tooltip>
</Td>
<Td>{policy.approvals}</Td>
<Td>
<Badge className={policyDetails[policy.policyType].className}>
{policyDetails[policy.policyType].name}
</Badge>
</Td>
<Td>
<DropdownMenu>
<DropdownMenuTrigger asChild className="cursor-pointer rounded-lg">
<div className="flex items-center justify-center transition-transform duration-300 ease-in-out hover:scale-125 hover:text-primary-400 data-[state=open]:scale-125 data-[state=open]:text-primary-400">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="min-w-[100%] p-1">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SecretApproval}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
<>
<Tr
isHoverable
isSelectable
role="button"
tabIndex={0}
onKeyDown={(evt) => {
if (evt.key === "Enter") setIsExpanded.toggle();
}}
onClick={() => setIsExpanded.toggle()}
>
<Td>{policy.name}</Td>
<Td>{policy.environment.slug}</Td>
<Td>{policy.secretPath || "*"}</Td>
<Td>
<Badge className={policyDetails[policy.policyType].className}>
{policyDetails[policy.policyType].name}
</Badge>
</Td>
<Td>
<DropdownMenu>
<DropdownMenuTrigger asChild className="cursor-pointer rounded-lg">
<div className="flex items-center justify-center transition-transform duration-300 ease-in-out hover:scale-125 hover:text-primary-400 data-[state=open]:scale-125 data-[state=open]:text-primary-400">
<FontAwesomeIcon size="sm" icon={faEllipsis} />
</div>
</DropdownMenuTrigger>
<DropdownMenuContent align="center" className="min-w-[100%] p-1">
<ProjectPermissionCan
I={ProjectPermissionActions.Edit}
a={ProjectPermissionSub.SecretApproval}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
!isAllowed && "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
disabled={!isAllowed}
>
Edit Policy
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.SecretApproval}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
)}
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
disabled={!isAllowed}
>
Delete Policy
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
{isExpanded && (
<Tr>
<Td colSpan={5} className="rounded bg-mineshaft-900">
<div className="mb-4 border-b-2 border-mineshaft-500 py-2 text-lg">Approvers</div>
{labels?.map((el, index) => (
<div
key={`approval-list-${index + 1}`}
className="relative mb-2 flex rounded border border-mineshaft-500 bg-mineshaft-700 p-4"
>
<div>
<div className="mr-8 flex h-8 w-8 items-center justify-center border border-bunker-300 bg-bunker-800 text-white">
<div className="text-lg">{index + 1}</div>
</div>
{index !== labels.length - 1 && (
<div className="absolute bottom-0 left-8 h-6 border-r border-gray-400" />
)}
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
disabled={!isAllowed}
>
Edit Policy
</DropdownMenuItem>
)}
</ProjectPermissionCan>
<ProjectPermissionCan
I={ProjectPermissionActions.Delete}
a={ProjectPermissionSub.SecretApproval}
>
{(isAllowed) => (
<DropdownMenuItem
className={twMerge(
isAllowed
? "hover:!bg-red-500 hover:!text-white"
: "pointer-events-none cursor-not-allowed opacity-50"
{index !== 0 && (
<div className="absolute left-8 top-0 h-4 border-r border-gray-400" />
)}
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
disabled={!isAllowed}
>
Delete Policy
</DropdownMenuItem>
)}
</ProjectPermissionCan>
</DropdownMenuContent>
</DropdownMenu>
</Td>
</Tr>
</div>
<div className="grid flex-grow grid-cols-3">
<div>
<div className="mb-1 text-xs font-semibold uppercase">Users</div>
<div>{el.userLabels || "-"}</div>
</div>
<div>
<div className="mb-1 text-xs font-semibold uppercase">Groups</div>
<div>{el.groupLabels || "-"}</div>
</div>
<div>
<div className="mb-1 text-xs font-semibold uppercase">Approvals Required</div>
<div>{el.approvals || "-"}</div>
</div>
</div>
</div>
))}
</Td>
</Tr>
)}
</>
);
};