diff --git a/backend/src/db/migrations/20250603094506_access-request-sequential.ts b/backend/src/db/migrations/20250603094506_access-request-sequential.ts new file mode 100644 index 000000000..d023cc08c --- /dev/null +++ b/backend/src/db/migrations/20250603094506_access-request-sequential.ts @@ -0,0 +1,44 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; + +export async function up(knex: Knex): Promise { + const hasStepColumn = await knex.schema.hasColumn(TableName.AccessApprovalPolicyApprover, "sequence"); + const hasApprovalRequiredColumn = await knex.schema.hasColumn( + TableName.AccessApprovalPolicyApprover, + "approvalsRequired" + ); + if (!hasStepColumn || !hasApprovalRequiredColumn) { + await knex.schema.alterTable(TableName.AccessApprovalPolicyApprover, (t) => { + if (!hasStepColumn) t.integer("sequence").defaultTo(1); + if (!hasApprovalRequiredColumn) t.integer("approvalsRequired").nullable(); + }); + } + + // set rejected status for all access request that was rejected and still has status pending + const subquery = knex(TableName.AccessApprovalRequest) + .leftJoin( + TableName.AccessApprovalRequestReviewer, + `${TableName.AccessApprovalRequestReviewer}.requestId`, + `${TableName.AccessApprovalRequest}.id` + ) + .where(`${TableName.AccessApprovalRequest}.status` as "status", "pending") + .where(`${TableName.AccessApprovalRequestReviewer}.status` as "status", "rejected") + .select(`${TableName.AccessApprovalRequest}.id`); + + await knex(TableName.AccessApprovalRequest).where("id", "in", subquery).update("status", "rejected"); +} + +export async function down(knex: Knex): Promise { + const hasStepColumn = await knex.schema.hasColumn(TableName.AccessApprovalPolicyApprover, "sequence"); + const hasApprovalRequiredColumn = await knex.schema.hasColumn( + TableName.AccessApprovalPolicyApprover, + "approvalsRequired" + ); + if (hasStepColumn || hasApprovalRequiredColumn) { + await knex.schema.alterTable(TableName.AccessApprovalPolicyApprover, (t) => { + if (hasStepColumn) t.dropColumn("sequence"); + if (hasApprovalRequiredColumn) t.dropColumn("approvalsRequired"); + }); + } +} diff --git a/backend/src/db/schemas/access-approval-policies-approvers.ts b/backend/src/db/schemas/access-approval-policies-approvers.ts index 1ecd80513..7bc3a7e81 100644 --- a/backend/src/db/schemas/access-approval-policies-approvers.ts +++ b/backend/src/db/schemas/access-approval-policies-approvers.ts @@ -13,7 +13,9 @@ export const AccessApprovalPoliciesApproversSchema = z.object({ createdAt: z.date(), updatedAt: z.date(), approverUserId: z.string().uuid().nullable().optional(), - approverGroupId: z.string().uuid().nullable().optional() + approverGroupId: z.string().uuid().nullable().optional(), + sequence: z.number().default(0).nullable().optional(), + approvalsRequired: z.number().default(1).nullable().optional() }); export type TAccessApprovalPoliciesApprovers = z.infer; diff --git a/backend/src/ee/routes/v1/access-approval-policy-router.ts b/backend/src/ee/routes/v1/access-approval-policy-router.ts index 2553a0efc..74545579c 100644 --- a/backend/src/ee/routes/v1/access-approval-policy-router.ts +++ b/backend/src/ee/routes/v1/access-approval-policy-router.ts @@ -23,12 +23,26 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi environment: z.string(), 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() .max(100, "Cannot have more than 100 approvers") - .min(1, { message: "At least one approver should be provided" }), + .min(1, { message: "At least one approver should be provided" }) + .refine( + // @ts-expect-error this is ok + (el) => el.every((i) => Boolean(i?.id) || Boolean(i?.username)), + "Must provide either username or id" + ), bypassers: z .discriminatedUnion("type", [ z.object({ type: z.literal(BypasserType.Group), id: z.string() }), @@ -37,6 +51,13 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi .array() .max(100, "Cannot have more than 100 bypassers") .optional(), + approvalsRequired: z + .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), allowedSelfApprovals: z.boolean().default(true) @@ -78,7 +99,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(), @@ -152,12 +178,26 @@ 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" }) - .max(100, "Cannot have more than 100 approvers"), + .max(100, "Cannot have more than 100 approvers") + .refine( + // @ts-expect-error this is ok + (el) => el.every((i) => Boolean(i?.id) || Boolean(i?.username)), + "Must provide either username or id" + ), bypassers: z .discriminatedUnion("type", [ z.object({ type: z.literal(BypasserType.Group), id: z.string() }), @@ -168,7 +208,14 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi .optional(), approvals: z.number().min(1).optional(), enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard), - allowedSelfApprovals: z.boolean().default(true) + allowedSelfApprovals: z.boolean().default(true), + approvalsRequired: z + .object({ + numberOfApprovals: z.number().int(), + stepNumber: z.number().int() + }) + .array() + .optional() }), response: { 200: z.object({ @@ -235,7 +282,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() diff --git a/backend/src/ee/routes/v1/access-approval-request-router.ts b/backend/src/ee/routes/v1/access-approval-request-router.ts index 5b3a08b4b..65fcf3c86 100644 --- a/backend/src/ee/routes/v1/access-approval-request-router.ts +++ b/backend/src/ee/routes/v1/access-approval-request-router.ts @@ -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(), diff --git a/backend/src/ee/routes/v1/scim-router.ts b/backend/src/ee/routes/v1/scim-router.ts index 5fa0d19e8..52e4f8e1f 100644 --- a/backend/src/ee/routes/v1/scim-router.ts +++ b/backend/src/ee/routes/v1/scim-router.ts @@ -270,7 +270,6 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { }), body: z.object({ schemas: z.array(z.string()), - id: z.string().trim(), userName: z.string().trim(), name: z .object({ @@ -278,7 +277,6 @@ export const registerScimRouter = async (server: FastifyZodProvider) => { givenName: z.string().trim().optional() }) .optional(), - displayName: z.string().trim(), emails: z .array( z.object({ diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts index c61d209c3..dbcc5ed14 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-dal.ts @@ -48,6 +48,8 @@ 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("bypasserUserId").withSchema(TableName.AccessApprovalPolicyBypasser)) .select(tx.ref("bypasserGroupId").withSchema(TableName.AccessApprovalPolicyBypasser)) .select(tx.ref("name").withSchema(TableName.Environment).as("envName")) @@ -80,23 +82,31 @@ 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 }) } ] }); + if (!formattedDoc?.[0]) return; - return formattedDoc?.[0]; + return { + ...formattedDoc?.[0], + approvers: formattedDoc?.[0]?.approvers.sort((a, b) => (a.sequence || 1) - (b.sequence || 1)) + }; } catch (error) { throw new DatabaseError({ error, name: "FindById" }); } @@ -129,18 +139,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 }) }, { @@ -163,7 +177,10 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => { ] }); - return formattedDocs; + return formattedDocs.map((el) => ({ + ...el, + approvers: el?.approvers.sort((a, b) => (a.sequence || 1) - (b.sequence || 1)) + })); } catch (error) { throw new DatabaseError({ error, name: "Find" }); } diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts index 71d15ce1c..7ff9d65cc 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-service.ts @@ -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"; @@ -41,9 +42,9 @@ type TAccessApprovalPolicyServiceFactoryDep = { projectMembershipDAL: Pick; groupDAL: TGroupDALFactory; userDAL: Pick; - accessApprovalRequestDAL: Pick; + accessApprovalRequestDAL: Pick; additionalPrivilegeDAL: Pick; - accessApprovalRequestReviewerDAL: Pick; + accessApprovalRequestReviewerDAL: Pick; orgMembershipDAL: Pick; }; @@ -76,27 +77,23 @@ export const accessApprovalPolicyServiceFactory = ({ projectSlug, environment, enforcementLevel, - allowedSelfApprovals + allowedSelfApprovals, + approvalsRequired }: TCreateAccessApprovalPolicy) => { const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); if (!project) throw new NotFoundError({ message: `Project with slug '${projectSlug}' not found` }); // If there is a group approver people might be added to the group later to meet the approvers quota - const groupApprovers = approvers - .filter((approver) => approver.type === ApproverType.Group) - .map((approver) => approver.id) as string[]; + const groupApprovers = approvers.filter((approver) => approver.type === ApproverType.Group); - const userApprovers = approvers - .filter((approver) => approver.type === ApproverType.User) - .map((approver) => approver.id) - .filter(Boolean) as string[]; + const userApprovers = approvers.filter((approver) => approver.type === ApproverType.User && approver.id) as { + id: string; + sequence?: number; + }[]; - const userApproverNames = approvers - .map((approver) => (approver.type === ApproverType.User ? approver.username : undefined)) - .filter(Boolean) as string[]; - - if (!groupApprovers && approvals > userApprovers.length + userApproverNames.length) - throw new BadRequestError({ message: "Approvals cannot be greater than approvers" }); + const userApproverNames = approvers.filter( + (approver) => approver.type === ApproverType.User && approver.username + ) as { username: string; sequence?: number }[]; const { permission } = await permissionService.getProjectPermission({ actor, @@ -116,14 +113,13 @@ export const accessApprovalPolicyServiceFactory = ({ let approverUserIds = userApprovers; if (userApproverNames.length) { - const approverUsers = await userDAL.find({ + const approverUsersInDB = await userDAL.find({ $in: { - username: userApproverNames + username: userApproverNames.map((el) => el.username) } }); - - const approverNamesFromDb = approverUsers.map((user) => user.username); - const invalidUsernames = userApproverNames.filter((username) => !approverNamesFromDb.includes(username)); + const approverUsersInDBGroupByUsername = groupBy(approverUsersInDB, (i) => i.username); + const invalidUsernames = userApproverNames.filter((el) => !approverUsersInDBGroupByUsername?.[el.username]?.[0]); if (invalidUsernames.length) { throw new BadRequestError({ @@ -131,32 +127,13 @@ export const accessApprovalPolicyServiceFactory = ({ }); } - approverUserIds = approverUserIds.concat(approverUsers.map((user) => user.id)); - } - - const usersPromises: Promise< - { - id: string; - email: string | null | undefined; - username: string; - firstName: string | null | undefined; - lastName: string | null | undefined; - isPartOfGroup: boolean; - }[] - >[] = []; - const verifyAllApprovers = [...approverUserIds]; - - for (const groupId of groupApprovers) { - usersPromises.push( - groupDAL.findAllGroupPossibleMembers({ orgId: actorOrgId, groupId, offset: 0 }).then((group) => group.members) + approverUserIds = approverUserIds.concat( + userApproverNames.map((el) => ({ + id: approverUsersInDBGroupByUsername[el.username]?.[0].id, + sequence: el.sequence + })) ); } - const verifyGroupApprovers = (await Promise.all(usersPromises)) - .flat() - .filter((user) => user.isPartOfGroup) - .map((user) => user.id); - verifyAllApprovers.push(...verifyGroupApprovers); - let groupBypassers: string[] = []; let bypasserUserIds: string[] = []; @@ -195,6 +172,7 @@ export const accessApprovalPolicyServiceFactory = ({ } } + const approvalsRequiredGroupByStepNumber = groupBy(approvalsRequired || [], (i) => i.stepNumber); const accessApproval = await accessApprovalPolicyDAL.transaction(async (tx) => { const doc = await accessApprovalPolicyDAL.create( { @@ -210,9 +188,13 @@ export const accessApprovalPolicyServiceFactory = ({ if (approverUserIds.length) { await accessApprovalPolicyApproverDAL.insertMany( - approverUserIds.map((userId) => ({ - approverUserId: userId, - policyId: doc.id + approverUserIds.map((el) => ({ + approverUserId: el.id, + policyId: doc.id, + sequence: el.sequence, + approvalsRequired: el.sequence + ? approvalsRequiredGroupByStepNumber?.[el.sequence]?.[0]?.numberOfApprovals + : approvals })), tx ); @@ -220,9 +202,13 @@ export const accessApprovalPolicyServiceFactory = ({ if (groupApprovers) { await accessApprovalPolicyApproverDAL.insertMany( - groupApprovers.map((groupId) => ({ - approverGroupId: groupId, - policyId: doc.id + groupApprovers.map((el) => ({ + approverGroupId: el.id, + policyId: doc.id, + sequence: el.sequence, + approvalsRequired: el.sequence + ? approvalsRequiredGroupByStepNumber?.[el.sequence]?.[0]?.numberOfApprovals + : approvals })), tx ); @@ -290,22 +276,22 @@ export const accessApprovalPolicyServiceFactory = ({ actorAuthMethod, approvals, enforcementLevel, - allowedSelfApprovals + allowedSelfApprovals, + approvalsRequired }: TUpdateAccessApprovalPolicy) => { - const groupApprovers = approvers - .filter((approver) => approver.type === ApproverType.Group) - .map((approver) => approver.id) as string[]; + const groupApprovers = approvers.filter((approver) => approver.type === ApproverType.Group); - const userApprovers = approvers - .filter((approver) => approver.type === ApproverType.User) - .map((approver) => approver.id) - .filter(Boolean) as string[]; - - const userApproverNames = approvers - .map((approver) => (approver.type === ApproverType.User ? approver.username : undefined)) - .filter(Boolean) as string[]; + const userApprovers = approvers.filter((approver) => approver.type === ApproverType.User && approver.id) as { + id: string; + sequence?: number; + }[]; + const userApproverNames = approvers.filter( + (approver) => approver.type === ApproverType.User && approver.username + ) as { username: string; sequence?: number }[]; const accessApprovalPolicy = await accessApprovalPolicyDAL.findById(policyId); + if (!accessApprovalPolicy) throw new BadRequestError({ message: "Approval policy not found" }); + const currentApprovals = approvals || accessApprovalPolicy.approvals; if ( groupApprovers?.length === 0 && @@ -401,6 +387,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, @@ -417,16 +404,18 @@ export const accessApprovalPolicyServiceFactory = ({ await accessApprovalPolicyApproverDAL.delete({ policyId: doc.id }, tx); if (userApprovers.length || userApproverNames.length) { - let userApproverIds = userApprovers; + let approverUserIds = userApprovers; if (userApproverNames.length) { - const approverUsers = await userDAL.find({ + const approverUsersInDB = await userDAL.find({ $in: { - username: userApproverNames + username: userApproverNames.map((el) => el.username) } }); + const approverUsersInDBGroupByUsername = groupBy(approverUsersInDB, (i) => i.username); - const approverNamesFromDb = approverUsers.map((user) => user.username); - const invalidUsernames = userApproverNames.filter((username) => !approverNamesFromDb.includes(username)); + const invalidUsernames = userApproverNames.filter( + (el) => !approverUsersInDBGroupByUsername?.[el.username]?.[0] + ); if (invalidUsernames.length) { throw new BadRequestError({ @@ -434,13 +423,21 @@ export const accessApprovalPolicyServiceFactory = ({ }); } - userApproverIds = userApproverIds.concat(approverUsers.map((user) => user.id)); + approverUserIds = approverUserIds.concat( + userApproverNames.map((el) => ({ + id: approverUsersInDBGroupByUsername[el.username]?.[0].id, + sequence: el.sequence + })) + ); } - await accessApprovalPolicyApproverDAL.insertMany( - userApproverIds.map((userId) => ({ - approverUserId: userId, - policyId: doc.id + approverUserIds.map((el) => ({ + approverUserId: el.id, + policyId: doc.id, + sequence: el.sequence, + approvalsRequired: el.sequence + ? approvalsRequiredGroupByStepNumber?.[el.sequence]?.[0]?.numberOfApprovals + : approvals })), tx ); @@ -448,9 +445,13 @@ export const accessApprovalPolicyServiceFactory = ({ if (groupApprovers) { await accessApprovalPolicyApproverDAL.insertMany( - groupApprovers.map((groupId) => ({ - approverGroupId: groupId, - policyId: doc.id + groupApprovers.map((el) => ({ + approverGroupId: el.id, + policyId: doc.id, + sequence: el.sequence, + approvalsRequired: el.sequence + ? approvalsRequiredGroupByStepNumber?.[el.sequence]?.[0]?.numberOfApprovals + : approvals })), tx ); @@ -478,6 +479,8 @@ export const accessApprovalPolicyServiceFactory = ({ ); } + await accessApprovalRequestDAL.resetReviewByPolicyId(doc.id, tx); + return doc; }); return { diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts index cef7f68f4..bdb50dde1 100644 --- a/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-types.ts @@ -27,7 +27,10 @@ export type TCreateAccessApprovalPolicy = { approvals: number; secretPath: string; environment: string; - approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; username?: string })[]; + approvers: ( + | { type: ApproverType.Group; id: string; sequence?: number } + | { type: ApproverType.User; id?: string; username?: string; sequence?: number } + )[]; bypassers?: ( | { type: BypasserType.Group; id: string } | { type: BypasserType.User; id?: string; username?: string } @@ -36,12 +39,16 @@ export type TCreateAccessApprovalPolicy = { name: string; enforcementLevel: EnforcementLevel; allowedSelfApprovals: boolean; + approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[]; } & Omit; export type TUpdateAccessApprovalPolicy = { policyId: string; approvals?: number; - approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; username?: string })[]; + approvers: ( + | { type: ApproverType.Group; id: string; sequence?: number } + | { type: ApproverType.User; id?: string; username?: string; sequence?: number } + )[]; bypassers?: ( | { type: BypasserType.Group; id: string } | { type: BypasserType.User; id?: string; username?: string } @@ -50,6 +57,7 @@ export type TUpdateAccessApprovalPolicy = { name?: string; enforcementLevel?: EnforcementLevel; allowedSelfApprovals: boolean; + approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[]; } & Omit; export type TDeleteAccessApprovalPolicy = { diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts b/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts index bfd07bdcf..cb163f0c2 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-dal.ts @@ -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( + 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"), @@ -164,8 +173,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { permissions: doc.privilegePermissions } : null, - - isApproved: !!doc.policyDeletedAt || !!doc.privilegeId || doc.status !== ApprovalStatus.PENDING + isApproved: doc.status === ApprovalStatus.APPROVED }), childrenMapper: [ { @@ -173,11 +181,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 +222,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 +306,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 +403,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 +424,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 +478,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 } }; @@ -495,7 +541,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { req.status === ApprovalStatus.PENDING ); - // an approval is finalized if there are any rejections, a privilege ID is set or the number of approvals is equal to the number of approvals required + // an approval is finalized if there are any rejections, a privilege ID is set or the number of approvals is equal to the number of approvals required. const finalizedApprovals = formattedRequests.filter( (req) => req.privilegeId || @@ -509,5 +555,27 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { } }; - return { ...accessApprovalRequestOrm, findById, findRequestsWithPrivilegeByPolicyIds, getCount }; + const resetReviewByPolicyId = async (policyId: string, tx?: Knex) => { + try { + await (tx || db)(TableName.AccessApprovalRequestReviewer) + .leftJoin( + TableName.AccessApprovalRequest, + `${TableName.AccessApprovalRequest}.id`, + `${TableName.AccessApprovalRequestReviewer}.requestId` + ) + .where(`${TableName.AccessApprovalRequest}.status` as "status", ApprovalStatus.PENDING) + .where(`${TableName.AccessApprovalRequest}.policyId` as "policyId", policyId) + .del(); + } catch (error) { + throw new DatabaseError({ error, name: "ResetReviewByPolicyId" }); + } + }; + + return { + ...accessApprovalRequestOrm, + findById, + findRequestsWithPrivilegeByPolicyIds, + getCount, + resetReviewByPolicyId + }; }; diff --git a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts index d03cc64c0..8fafc6b85 100644 --- a/backend/src/ee/services/access-approval-request/access-approval-request-service.ts +++ b/backend/src/ee/services/access-approval-request/access-approval-request-service.ts @@ -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({ @@ -380,8 +380,44 @@ export const accessApprovalRequestServiceFactory = ({ } const existingReviews = await accessApprovalRequestReviewerDAL.find({ requestId: accessApprovalRequest.id }); - if (existingReviews.some((review) => review.status === ApprovalStatus.REJECTED)) { - throw new BadRequestError({ message: "The request has already been rejected by another reviewer" }); + if (accessApprovalRequest.status !== ApprovalStatus.PENDING) { + throw new BadRequestError({ message: "The request has been closed" }); + } + + 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) => { @@ -426,11 +462,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 && diff --git a/backend/src/services/user/user-service.ts b/backend/src/services/user/user-service.ts index 39300fec5..0f6ae3bf8 100644 --- a/backend/src/services/user/user-service.ts +++ b/backend/src/services/user/user-service.ts @@ -106,9 +106,11 @@ export const userServiceFactory = ({ code }); + const userEmails = user?.email ? await userDAL.find({ email: user.email }) : []; + await userDAL.updateById(user.id, { isEmailVerified: true, - username: usersByusername.length === 1 && user.email ? user.email.toLowerCase() : undefined + username: userEmails?.length === 1 && userEmails?.[0]?.id === user.id ? user.email.toLowerCase() : undefined }); }; diff --git a/docs/documentation/platform/access-controls/access-requests.mdx b/docs/documentation/platform/access-controls/access-requests.mdx index 58b21d4eb..cc0add8d8 100644 --- a/docs/documentation/platform/access-controls/access-requests.mdx +++ b/docs/documentation/platform/access-controls/access-requests.mdx @@ -6,21 +6,29 @@ 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. This functionality works in the following way: + 1. A project administrator sets up an access policy that assigns access managers (also known as eligible approvers) to a certain sensitive folder or environment. -![Create Access Request Policy Modal](/images/platform/access-controls/create-access-request-policy.png) -![Access Request Policies](/images/platform/access-controls/access-request-policies.png) + ![Create Access Request Policy Modal](/images/platform/access-controls/create-access-request-policy.png) + + + A step policy enables a sequential approval workflow in which approvals + must follow the designated chain. + + + ![Access Request Policies](/images/platform/access-controls/access-request-policies.png) 2. When a developer requests access to one of such sensitive resources, the request is visible in the dashboard, and the corresponding eligible approvers get an email notification about it. -![Access Request Create](/images/platform/access-controls/request-access.png) -![Access Request Dashboard](/images/platform/access-controls/access-requests-pending.png) + ![Access Request Create](/images/platform/access-controls/request-access.png) + ![Access Request Dashboard](/images/platform/access-controls/access-requests-pending.png) -4. An eligible approver can approve or reject the access request. -{/* ![Access Request Review](/images/platform/access-controls/review-access-request.png) */} -![Access Request Bypass](/images/platform/access-controls/access-request-bypass.png) +3. An eligible approver can approve or reject the access request. + {/* ![Access Request Review](/images/platform/access-controls/review-access-request.png) */} + ![Access Request Bypass](/images/platform/access-controls/access-request-bypass.png) - 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. + If the access request matches with a policy that allows break-glass approval + bypasses, the requester may bypass the policy and get access to the resource + without full approval. 5. As soon as the request is approved, developer is able to access the sought resources. -![Access Request Dashboard](/images/platform/access-controls/access-requests-completed.png) diff --git a/docs/images/platform/access-controls/access-request-bypass.png b/docs/images/platform/access-controls/access-request-bypass.png index 248150574..549190f86 100644 Binary files a/docs/images/platform/access-controls/access-request-bypass.png and b/docs/images/platform/access-controls/access-request-bypass.png differ diff --git a/docs/images/platform/access-controls/access-request-policies.png b/docs/images/platform/access-controls/access-request-policies.png index a0eca9dfd..53fbff420 100644 Binary files a/docs/images/platform/access-controls/access-request-policies.png and b/docs/images/platform/access-controls/access-request-policies.png differ diff --git a/docs/images/platform/access-controls/access-requests-pending.png b/docs/images/platform/access-controls/access-requests-pending.png index d75f669e2..85ac66505 100644 Binary files a/docs/images/platform/access-controls/access-requests-pending.png and b/docs/images/platform/access-controls/access-requests-pending.png differ diff --git a/docs/images/platform/access-controls/create-access-request-policy.png b/docs/images/platform/access-controls/create-access-request-policy.png index 56f9840cf..2e0aa22bb 100644 Binary files a/docs/images/platform/access-controls/create-access-request-policy.png and b/docs/images/platform/access-controls/create-access-request-policy.png differ diff --git a/docs/images/platform/access-controls/request-access.png b/docs/images/platform/access-controls/request-access.png index 63c76dbd5..9c28851d2 100644 Binary files a/docs/images/platform/access-controls/request-access.png and b/docs/images/platform/access-controls/request-access.png differ diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d435cf0d7..edbf5673f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -1150,9 +1150,9 @@ } }, "node_modules/@eslint-community/eslint-utils": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", - "integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==", + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.7.0.tgz", + "integrity": "sha512-dyybb3AcajC7uha6CvhdVRJqaKyn7w2YKqKyAN37NKYgZT36w+iRb0Dymmc5qEJ549c/S31cMMSFd75bteCpCw==", "dev": true, "license": "MIT", "dependencies": { @@ -3640,9 +3640,9 @@ } }, "node_modules/@stylistic/eslint-plugin": { - "version": "2.12.1", - "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-2.12.1.tgz", - "integrity": "sha512-fubZKIHSPuo07FgRTn6S4Nl0uXPRPYVNpyZzIDGfp7Fny6JjNus6kReLD7NI380JXi4HtUTSOZ34LBuNPO1XLQ==", + "version": "2.13.0", + "resolved": "https://registry.npmjs.org/@stylistic/eslint-plugin/-/eslint-plugin-2.13.0.tgz", + "integrity": "sha512-RnO1SaiCFHn666wNz2QfZEFxvmiNRqhzaMXHXxXXKt+MEP7aajlPxUSMIQpKAaJfverpovEYqjBOXDq6dDcaOQ==", "dev": true, "license": "MIT", "dependencies": { @@ -3904,9 +3904,9 @@ } }, "node_modules/@tailwindcss/typography": { - "version": "0.5.15", - "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.15.tgz", - "integrity": "sha512-AqhlCXl+8grUz8uqExv5OTtgpjuVIwFTSXTrh8y9/pw6q2ek7fJ+Y8ZEVw7EB2DCcuCOtEjf9w3+J3rzts01uA==", + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/@tailwindcss/typography/-/typography-0.5.16.tgz", + "integrity": "sha512-0wDLwCVF5V3x3b1SGXPCDcdsbDHMBe+lkFzBRaHeLvNi+nrrnZ1lA18u+OTWO8iSWU2GxUOCvlXtDuqftc1oiA==", "dev": true, "license": "MIT", "dependencies": { @@ -3916,7 +3916,7 @@ "postcss-selector-parser": "6.0.10" }, "peerDependencies": { - "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20" + "tailwindcss": ">=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1" } }, "node_modules/@tailwindcss/typography/node_modules/postcss-selector-parser": { @@ -3934,13 +3934,13 @@ } }, "node_modules/@tanstack/eslint-plugin-router": { - "version": "1.87.6", - "resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-router/-/eslint-plugin-router-1.87.6.tgz", - "integrity": "sha512-HoJYMI8Jcsdk4Q357bSFykDIpmU+PCAhm9IQpbcPF+wuRITHBBivLy6poaM9X184ng6FDHUOTbt6L8ZF6dYfVw==", + "version": "1.120.17", + "resolved": "https://registry.npmjs.org/@tanstack/eslint-plugin-router/-/eslint-plugin-router-1.120.17.tgz", + "integrity": "sha512-dYnfQ2on0i9JOonvuP6y6AH47Yhj3zQHxa5rCuLOrpKRUR3Mjpp+4AB70ltY1DKBVWtp1moyruJqueD8kzHD5g==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/utils": "^8.18.0" + "@typescript-eslint/utils": "^8.23.0" }, "funding": { "type": "github", @@ -4474,21 +4474,21 @@ "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==" }, "node_modules/@typescript-eslint/eslint-plugin": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.18.0.tgz", - "integrity": "sha512-NR2yS7qUqCL7AIxdJUQf2MKKNDVNaig/dEB0GBLU7D+ZdHgK1NoH/3wsgO3OnPVipn51tG3MAwaODEGil70WEw==", + "version": "8.34.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.34.0.tgz", + "integrity": "sha512-QXwAlHlbcAwNlEEMKQS2RCgJsgXrTJdjXT08xEgbPFa2yYQgVjBymxP5DrfrE7X7iodSzd9qBUHUycdyVJTW1w==", "dev": true, "license": "MIT", "dependencies": { "@eslint-community/regexpp": "^4.10.0", - "@typescript-eslint/scope-manager": "8.18.0", - "@typescript-eslint/type-utils": "8.18.0", - "@typescript-eslint/utils": "8.18.0", - "@typescript-eslint/visitor-keys": "8.18.0", + "@typescript-eslint/scope-manager": "8.34.0", + "@typescript-eslint/type-utils": "8.34.0", + "@typescript-eslint/utils": "8.34.0", + "@typescript-eslint/visitor-keys": "8.34.0", "graphemer": "^1.4.0", - "ignore": "^5.3.1", + "ignore": "^7.0.0", "natural-compare": "^1.4.0", - "ts-api-utils": "^1.3.0" + "ts-api-utils": "^2.1.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4498,22 +4498,32 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "@typescript-eslint/parser": "^8.0.0 || ^8.0.0-alpha.0", + "@typescript-eslint/parser": "^8.34.0", "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" } }, "node_modules/@typescript-eslint/parser": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.18.0.tgz", - "integrity": "sha512-hgUZ3kTEpVzKaK3uNibExUYm6SKKOmTU2BOxBSvOYwtJEPdVQ70kZJpPjstlnhCHcuc2WGfSbpKlb/69ttyN5Q==", + "version": "8.34.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.34.0.tgz", + "integrity": "sha512-vxXJV1hVFx3IXz/oy2sICsJukaBrtDEQSBiV48/YIV5KWjX1dO+bcIr/kCPrW6weKXvsaGKFNlwH0v2eYdRRbA==", "dev": true, - "license": "MITClause", + "license": "MIT", "dependencies": { - "@typescript-eslint/scope-manager": "8.18.0", - "@typescript-eslint/types": "8.18.0", - "@typescript-eslint/typescript-estree": "8.18.0", - "@typescript-eslint/visitor-keys": "8.18.0", + "@typescript-eslint/scope-manager": "8.34.0", + "@typescript-eslint/types": "8.34.0", + "@typescript-eslint/typescript-estree": "8.34.0", + "@typescript-eslint/visitor-keys": "8.34.0", "debug": "^4.3.4" }, "engines": { @@ -4525,18 +4535,40 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "typescript": ">=4.8.4 <5.9.0" } }, - "node_modules/@typescript-eslint/scope-manager": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.18.0.tgz", - "integrity": "sha512-PNGcHop0jkK2WVYGotk/hxj+UFLhXtGPiGtiaWgVBVP1jhMoMCHlTyJA+hEj4rszoSdLTK3fN4oOatrL0Cp+Xw==", + "node_modules/@typescript-eslint/project-service": { + "version": "8.34.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.34.0.tgz", + "integrity": "sha512-iEgDALRf970/B2YExmtPMPF54NenZUf4xpL3wsCRx/lgjz6ul/l13R81ozP/ZNuXfnLCS+oPmG7JIxfdNYKELw==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.18.0", - "@typescript-eslint/visitor-keys": "8.18.0" + "@typescript-eslint/tsconfig-utils": "^8.34.0", + "@typescript-eslint/types": "^8.34.0", + "debug": "^4.3.4" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.34.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.34.0.tgz", + "integrity": "sha512-9Ac0X8WiLykl0aj1oYQNcLZjHgBojT6cW68yAgZ19letYu+Hxd0rE0veI1XznSSst1X5lwnxhPbVdwjDRIomRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.34.0", + "@typescript-eslint/visitor-keys": "8.34.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4546,17 +4578,34 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.34.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.34.0.tgz", + "integrity": "sha512-+W9VYHKFIzA5cBeooqQxqNriAP0QeQ7xTiDuIOr71hzgffm3EL2hxwWBIIj4GuofIbKxGNarpKqIq6Q6YrShOA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <5.9.0" + } + }, "node_modules/@typescript-eslint/type-utils": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.18.0.tgz", - "integrity": "sha512-er224jRepVAVLnMF2Q7MZJCq5CsdH2oqjP4dT7K6ij09Kyd+R21r7UVJrF0buMVdZS5QRhDzpvzAxHxabQadow==", + "version": "8.34.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.34.0.tgz", + "integrity": "sha512-n7zSmOcUVhcRYC75W2pnPpbO1iwhJY3NLoHEtbJwJSNlVAZuwqu05zY3f3s2SDWWDSo9FdN5szqc73DCtDObAg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/typescript-estree": "8.18.0", - "@typescript-eslint/utils": "8.18.0", + "@typescript-eslint/typescript-estree": "8.34.0", + "@typescript-eslint/utils": "8.34.0", "debug": "^4.3.4", - "ts-api-utils": "^1.3.0" + "ts-api-utils": "^2.1.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4567,13 +4616,13 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/types": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.18.0.tgz", - "integrity": "sha512-FNYxgyTCAnFwTrzpBGq+zrnoTO4x0c1CKYY5MuUTzpScqmY5fmsh2o3+57lqdI3NZucBDCzDgdEbIaNfAjAHQA==", + "version": "8.34.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.34.0.tgz", + "integrity": "sha512-9V24k/paICYPniajHfJ4cuAWETnt7Ssy+R0Rbcqo5sSFr3QEZ/8TSoUi9XeXVBGXCaLtwTOKSLGcInCAvyZeMA==", "dev": true, "license": "MIT", "engines": { @@ -4585,20 +4634,22 @@ } }, "node_modules/@typescript-eslint/typescript-estree": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.18.0.tgz", - "integrity": "sha512-rqQgFRu6yPkauz+ms3nQpohwejS8bvgbPyIDq13cgEDbkXt4LH4OkDMT0/fN1RUtzG8e8AKJyDBoocuQh8qNeg==", + "version": "8.34.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.34.0.tgz", + "integrity": "sha512-rOi4KZxI7E0+BMqG7emPSK1bB4RICCpF7QD3KCLXn9ZvWoESsOMlHyZPAHyG04ujVplPaHbmEvs34m+wjgtVtg==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.18.0", - "@typescript-eslint/visitor-keys": "8.18.0", + "@typescript-eslint/project-service": "8.34.0", + "@typescript-eslint/tsconfig-utils": "8.34.0", + "@typescript-eslint/types": "8.34.0", + "@typescript-eslint/visitor-keys": "8.34.0", "debug": "^4.3.4", "fast-glob": "^3.3.2", "is-glob": "^4.0.3", "minimatch": "^9.0.4", "semver": "^7.6.0", - "ts-api-utils": "^1.3.0" + "ts-api-utils": "^2.1.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4608,13 +4659,13 @@ "url": "https://opencollective.com/typescript-eslint" }, "peerDependencies": { - "typescript": ">=4.8.4 <5.8.0" + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -4638,16 +4689,16 @@ } }, "node_modules/@typescript-eslint/utils": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.18.0.tgz", - "integrity": "sha512-p6GLdY383i7h5b0Qrfbix3Vc3+J2k6QWw6UMUeY5JGfm3C5LbZ4QIZzJNoNOfgyRe0uuYKjvVOsO/jD4SJO+xg==", + "version": "8.34.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.34.0.tgz", + "integrity": "sha512-8L4tWatGchV9A1cKbjaavS6mwYwp39jql8xUmIIKJdm+qiaeHy5KMKlBrf30akXAWBzn2SqKsNOtSENWUwg7XQ==", "dev": true, "license": "MIT", "dependencies": { - "@eslint-community/eslint-utils": "^4.4.0", - "@typescript-eslint/scope-manager": "8.18.0", - "@typescript-eslint/types": "8.18.0", - "@typescript-eslint/typescript-estree": "8.18.0" + "@eslint-community/eslint-utils": "^4.7.0", + "@typescript-eslint/scope-manager": "8.34.0", + "@typescript-eslint/types": "8.34.0", + "@typescript-eslint/typescript-estree": "8.34.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -4658,17 +4709,17 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/@typescript-eslint/visitor-keys": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.18.0.tgz", - "integrity": "sha512-pCh/qEA8Lb1wVIqNvBke8UaRjJ6wrAWkJO5yyIbs8Yx6TNGYyfNjOo61tLv+WwLvoLPp4BQ8B7AHKijl8NGUfw==", + "version": "8.34.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.34.0.tgz", + "integrity": "sha512-qHV7pW7E85A0x6qyrFn+O+q1k1p3tQCsqIZ1KZ5ESLXY57aTvUd3/a4rdPTeXisvhXn2VQG0VSKUqs8KHF2zcA==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/types": "8.18.0", + "@typescript-eslint/types": "8.34.0", "eslint-visitor-keys": "^4.2.0" }, "engines": { @@ -12624,9 +12675,9 @@ } }, "node_modules/sucrase/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", + "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", "dev": true, "license": "MIT", "dependencies": { @@ -12733,9 +12784,9 @@ } }, "node_modules/tailwindcss": { - "version": "3.4.16", - "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.16.tgz", - "integrity": "sha512-TI4Cyx7gDiZ6r44ewaJmt0o6BrMCT5aK5e0rmJ/G9Xq3w7CX/5VXl/zIPEJZFUK5VEqwByyhqNPycPlvcK4ZNw==", + "version": "3.4.17", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.17.tgz", + "integrity": "sha512-w33E2aCvSDP0tW9RZuNXadXlkHXqFzSkQew/aIa2i/Sj8fThxwovwlXHSPXTbAHwEIhBFXAedUhP2tueAKP8Og==", "dev": true, "license": "MIT", "dependencies": { @@ -12883,16 +12934,16 @@ } }, "node_modules/ts-api-utils": { - "version": "1.4.3", - "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.4.3.tgz", - "integrity": "sha512-i3eMG77UTMD0hZhgRS562pv83RC6ukSAC2GMNWc+9dieh/+jDM5u5YG+NHX6VNDRHQcHwmsTHctP9LhbC3WxVw==", + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.1.0.tgz", + "integrity": "sha512-CUgTZL1irw8u29bzrOD/nH85jqyc74D6SshFgujOIA7osm2Rz7dYH77agkx7H4FBNxDq7Cjf+IjaX/8zwFW+ZQ==", "dev": true, "license": "MIT", "engines": { - "node": ">=16" + "node": ">=18.12" }, "peerDependencies": { - "typescript": ">=4.2.0" + "typescript": ">=4.8.4" } }, "node_modules/ts-interface-checker": { @@ -13550,15 +13601,15 @@ } }, "node_modules/typescript-eslint": { - "version": "8.18.0", - "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.18.0.tgz", - "integrity": "sha512-Xq2rRjn6tzVpAyHr3+nmSg1/9k9aIHnJ2iZeOH7cfGOWqTkXTm3kwpQglEuLGdNrYvPF+2gtAs+/KF5rjVo+WQ==", + "version": "8.34.0", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.34.0.tgz", + "integrity": "sha512-MRpfN7uYjTrTGigFCt8sRyNqJFhjN0WwZecldaqhWm+wy0gaRt8Edb/3cuUy0zdq2opJWT6iXINKAtewnDOltQ==", "dev": true, "license": "MIT", "dependencies": { - "@typescript-eslint/eslint-plugin": "8.18.0", - "@typescript-eslint/parser": "8.18.0", - "@typescript-eslint/utils": "8.18.0" + "@typescript-eslint/eslint-plugin": "8.34.0", + "@typescript-eslint/parser": "8.34.0", + "@typescript-eslint/utils": "8.34.0" }, "engines": { "node": "^18.18.0 || ^20.9.0 || >=21.1.0" @@ -13569,7 +13620,7 @@ }, "peerDependencies": { "eslint": "^8.57.0 || ^9.0.0", - "typescript": ">=4.8.4 <5.8.0" + "typescript": ">=4.8.4 <5.9.0" } }, "node_modules/unbox-primitive": { @@ -13908,9 +13959,9 @@ } }, "node_modules/vite": { - "version": "5.4.18", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.18.tgz", - "integrity": "sha512-1oDcnEp3lVyHCuQ2YFelM4Alm2o91xNoMncRm1U7S+JdYfYOvbiGZ3/CxGttrOu2M/KcGz7cRC2DoNUA6urmMA==", + "version": "5.4.19", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", + "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", "dev": true, "license": "MIT", "dependencies": { diff --git a/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx b/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx index 8f51c2867..47a5acd56 100644 --- a/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx +++ b/frontend/src/components/v2/FilterableSelect/FilterableSelect.tsx @@ -64,6 +64,10 @@ export const FilterableSelect = ({ control: (base) => ({ ...base, transition: "none" + }), + menuPortal: (provided) => ({ + ...provided, + zIndex: 9999 }) }} tabSelectsValue={tabSelectsValue} diff --git a/frontend/src/hooks/api/accessApproval/mutation.tsx b/frontend/src/hooks/api/accessApproval/mutation.tsx index e4224e110..3b05ff61b 100644 --- a/frontend/src/hooks/api/accessApproval/mutation.tsx +++ b/frontend/src/hooks/api/accessApproval/mutation.tsx @@ -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; }, diff --git a/frontend/src/hooks/api/accessApproval/types.ts b/frontend/src/hooks/api/accessApproval/types.ts index 54a53b2ab..40725a16c 100644 --- a/frontend/src/hooks/api/accessApproval/types.ts +++ b/frontend/src/hooks/api/accessApproval/types.ts @@ -1,5 +1,6 @@ import { EnforcementLevel, PolicyType } from "../policies/enums"; import { TProjectPermission } from "../roles/types"; +import { ApprovalStatus } from "../secretApprovalRequest/types"; import { WorkspaceEnv } from "../workspace/types"; export type TAccessApprovalPolicy = { @@ -33,6 +34,8 @@ export enum BypasserType { export type Approver = { id: string; type: ApproverType; + sequence?: number; + approvals?: number; }; export type Bypasser = { @@ -73,12 +76,18 @@ export type TAccessApprovalRequest = { permissions: TProjectPermission[]; isApproved: boolean; } | null; - + status: ApprovalStatus; policy: { 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 +97,7 @@ export type TAccessApprovalRequest = { }; reviewers: { - member: string; + userId: string; status: string; }[]; @@ -163,6 +172,7 @@ export type TCreateAccessPolicyDTO = { secretPath?: string; enforcementLevel?: EnforcementLevel; allowedSelfApprovals: boolean; + approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[]; }; export type TUpdateAccessPolicyDTO = { @@ -177,6 +187,7 @@ export type TUpdateAccessPolicyDTO = { allowedSelfApprovals: boolean; // for invalidating list projectSlug: string; + approvalsRequired?: { numberOfApprovals: number; stepNumber: number }[]; }; export type TDeleteSecretPolicyDTO = { diff --git a/frontend/src/hooks/api/secretApprovalRequest/queries.tsx b/frontend/src/hooks/api/secretApprovalRequest/queries.tsx index 39fa1058a..e6ba62a6b 100644 --- a/frontend/src/hooks/api/secretApprovalRequest/queries.tsx +++ b/frontend/src/hooks/api/secretApprovalRequest/queries.tsx @@ -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) }); diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx index ee7f93d87..86483e2fd 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/AccessApprovalRequest.tsx @@ -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 (
- {details.isApprover && ( - - {details.displayData.label} - - )} + + {details.displayData.label} +
@@ -450,9 +432,11 @@ export const AccessApprovalRequest = ({ {!!selectedRequest && ( { handlePopUpClose("reviewRequest"); diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx index 0d15e016f..e6df8d814 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/AccessApprovalRequest/components/ReviewAccessModal.tsx @@ -1,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 ; + if (status === ApprovalStatus.REJECTED) + return ; + return ; +}; 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,79 @@ 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 && 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 }; + }); + const currentSequenceApprover = approvers?.find((el) => !el.hasApproved); + const currentSequence = currentSequenceApprover?.sequence || 1; + const isMyReviewInThisSequence = currentSequenceApprover?.reviewers.find( + (i) => i.userId === user.id + ); + + return { + approvers, + membersGroupById, + projectGroupsGroupById, + currentSequence, + isMyReviewInThisSequence + }; + }, [request, policies]); + + const hasRejected = request.status === ApprovalStatus.REJECTED; + const hasApproved = request.status === ApprovalStatus.APPROVED; + const isReviewedByMe = request.reviewers.find((i) => i.userId === user.id); + + const shouldBlockRequestActions = + hasRejected || + hasApproved || + isReviewedByMe || + (!approverSequence?.isMyReviewInThisSequence && !canBypass); + + const renderCompletedMessages = () => { + if (hasRejected) return "This request has been rejected."; + if (hasApproved) return "This request has been approved."; + if (isReviewedByMe) return "You have reviewed this request."; + return "You are not the reviewer in this step."; + }; + return ( -
- - {request.user && - (request.user.firstName || request.user.lastName) && - request.user.email ? ( - - {request.user?.firstName} {request.user?.lastName} ({request.user?.email}) - - ) : ( - A user - )}{" "} - is requesting access to the following resource: - -
-
- Requested path: - {accessDetails.env + accessDetails.secretPath || ""} -
- -
- Permissions: - {requestedAccess} -
- -
- Access Type: - {getAccessLabel()} -
- - {request.note && ( -
- User Note: - {request.note} +
+ {request.user && + (request.user.firstName || request.user.lastName) && + request.user.email ? ( + + {request.user?.firstName} {request.user?.lastName} ({request.user?.email}) + + ) : ( + A user + )}{" "} + is requesting access to the following resource: +
+
+
+
+
+
Environment
+
{accessDetails.env || "-"}
- )} +
+
Secret Path
+
{accessDetails.secretPath || "-"}
+
+
+
Access Type
+
{getAccessLabel()}
+
+
+
Permission
+
{requestedAccess}
+
+
+
Note
+
{request.note || "-"}
+
+
-
- - -
- {isSoftEnforcement && - request.isRequestedByCurrentUser && - !(request.isApprover && request.isSelfApproveAllowed) && - canBypass && ( -
- setBypassApproval(checked === true)} - isChecked={bypassApproval} - id="byPassApproval" - checkIndicatorBg="text-white" - className={twMerge( - "mr-2", - bypassApproval ? "border-red bg-red hover:bg-red-600" : "" - )} - > - - Approve without waiting for requirements to be met (bypass policy protection) - - - {bypassApproval && ( - - setBypassReason(e.currentTarget.value)} - placeholder="Enter reason for bypass (min 10 chars)" - leftIcon={} - /> - +
Approvers
+
+ {approverSequence?.approvers?.map((approver, index) => ( +
+
+
+ {index + 1} +
+ {index !== (approverSequence?.approvers?.length || 0) - 1 && ( +
+ )} + {index !== 0 && ( +
+ )} +
+
+
+
Users
+
+ {approver?.user + ?.map( + (el) => approverSequence?.membersGroupById?.[el.id]?.[0]?.user?.username + ) + .join(",") || "-"} +
+
+
+
Groups
+
+ {approver?.group + ?.map( + (el) => + approverSequence?.projectGroupsGroupById?.[el.id]?.[0]?.group?.name + ) + .join(",") || "-"} +
+
+
+
+
Approvals Required
+
{approver.approvals || "-"}
+
+
+ + + + + +
+
Reviewers
+
+ {approver.reviewers.map((el, idx) => ( +
+
{el.username}
+ + {getReviewedStatusSymbol(el?.status as ApprovalStatus)} + +
+ ))} +
+
+
+
+
+
+
+
+ ))} +
+ {approverSequence.isMyReviewInThisSequence && + request.status === ApprovalStatus.PENDING && ( +
+ Awaiting review from you.
)} + {shouldBlockRequestActions ? ( +
+ {renderCompletedMessages()} +
+ ) : ( + <> +
+ + +
+ {isSoftEnforcement && + request.isRequestedByCurrentUser && + !(request.isApprover && request.isSelfApproveAllowed) && + canBypass && ( +
+ setBypassApproval(checked === true)} + isChecked={bypassApproval} + id="byPassApproval" + checkIndicatorBg="text-white" + className={twMerge( + "mr-2", + bypassApproval ? "border-red bg-red hover:bg-red-600" : "" + )} + > + + Approve without waiting for requirements to be met (bypass policy + protection) + + + {bypassApproval && ( + + setBypassReason(e.currentTarget.value)} + placeholder="Enter reason for bypass (min 10 chars)" + leftIcon={} + /> + + )} +
+ )} + + )}
diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx index ee837adbb..3d1ba70ef 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx @@ -188,9 +188,6 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => { Name Environment Secret Path - Eligible Approvers - Eligible Group Approvers - Approval Required diff --git a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx index 057504696..61fdeeb04 100644 --- a/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx +++ b/frontend/src/pages/secret-manager/SecretApprovalsPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx @@ -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(null); + const [dragOverItem, setDragOverItem] = useState(null); + const modalContainer = useRef(null); const { control, handleSubmit, @@ -104,7 +132,7 @@ export const AccessPolicyForm = ({ } = useForm({ resolver: zodResolver(formSchema), values: editValues - ? { + ? ({ ...editValues, environment: editValues.environment, userApprovers: @@ -124,15 +152,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 - } - : undefined + allowedSelfApprovals: editValues?.allowedSelfApprovals, + sequenceApprovers: editValues.approvers + ?.sort((a, b) => (a?.sequence || 0) - (b?.sequence || 0)) + .reduce( + (acc, curr) => { + if (acc.length && 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 }[] + ) + } as TFormSchema) + : 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 +217,7 @@ export const AccessPolicyForm = ({ userApprovers, groupBypassers, userBypassers, + sequenceApprovers, ...data }: TFormSchema) => { if (!projectId) return; @@ -175,7 +236,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 +270,7 @@ export const AccessPolicyForm = ({ groupApprovers, userBypassers, groupBypassers, + sequenceApprovers, ...data }: TFormSchema) => { if (!projectId || !projectSlug) return; @@ -221,7 +291,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 +363,45 @@ 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 (
-
+
field.onChange(parseInt(el.target.value, 10))} - /> - - )} - /> + {!isAccessPolicyType && ( + ( + + field.onChange(parseInt(el.target.value, 10))} + /> + + )} + /> + )} +
+
@@ -366,6 +480,7 @@ export const AccessPolicyForm = ({ label="Secret Path" isError={Boolean(error)} errorText={error?.message} + className="flex-grow" > @@ -400,62 +515,199 @@ export const AccessPolicyForm = ({ Select members or groups that are allowed to approve requests from this policy.

-
- ( - - option.id} - getOptionLabel={(option) => { - const member = members?.find((m) => m.user.id === option.id); + {isAccessPolicyType ? ( + <> +
+ {sequenceApproversFieldArray.fields.map((el, index) => ( +
handleDragOver(e, index)} + onDrop={handleDrop} + > +
+ Step {index + 1} +
+
Min. Approvals
+
+ ( + field.onChange(parseInt(val.target.value, 10))} + /> + )} + /> +
+ + sequenceApproversFieldArray.remove(index)} + className="text-red-500 hover:text-gray-200" + > + + + + +
handleDragStart(e, index)} + onDragEnd={handleDragEnd} + className="mr-2 cursor-move text-gray-400 hover:text-gray-200" + > + +
+
+
+
+
+ ( + + 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} - /> - - )} - /> - ( - + + )} + /> + ( + + option.id} + getOptionLabel={(option) => + groups?.find(({ group }) => group.id === option.id)?.group.name ?? + option.id + } + value={value} + onChange={onChange} + /> + + )} + /> +
+
+ ))} +
+
+
+ Add Step + +
+ + ) : ( +
+ ( + + 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} + /> + + )} + /> + ( + + option.id} + getOptionLabel={(option) => + groups?.find(({ group }) => group.id === option.id)?.group.name ?? + option.id + } + value={value} + onChange={onChange} + /> + + )} + /> +
+ )} { + 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 && 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 ( - - {policy.name} - {policy.environment.slug} - {policy.secretPath || "*"} - - -

{labels.members ?? "-"}

-
- - - -

{labels.groups ?? "-"}

-
- - {policy.approvals} - - - {policyDetails[policy.policyType].name} - - - - - -
- -
-
- - - {(isAllowed) => ( - + { + if (evt.key === "Enter") setIsExpanded.toggle(); + }} + onClick={() => setIsExpanded.toggle()} + > + {policy.name} + {policy.environment.slug} + {policy.secretPath || "*"} + + + {policyDetails[policy.policyType].name} + + + + + +
+ +
+
+ + + {(isAllowed) => ( + { + e.stopPropagation(); + onEdit(); + }} + disabled={!isAllowed} + > + Edit Policy + + )} + + + {(isAllowed) => ( + { + e.stopPropagation(); + onDelete(); + }} + disabled={!isAllowed} + > + Delete Policy + + )} + + +
+ + + {isExpanded && ( + + +
Approvers
+ {labels?.map((el, index) => ( +
+
+
+
{index + 1}
+
+ {index !== labels.length - 1 && ( +
)} - onClick={(e) => { - e.stopPropagation(); - onEdit(); - }} - disabled={!isAllowed} - > - Edit Policy - - )} - - - {(isAllowed) => ( - )} - onClick={(e) => { - e.stopPropagation(); - onDelete(); - }} - disabled={!isAllowed} - > - Delete Policy - - )} - - - - - +
+
+
+
Users
+
{el.userLabels || "-"}
+
+
+
Groups
+
{el.groupLabels || "-"}
+
+
+
Approvals Required
+
{el.approvals || "-"}
+
+
+
+ ))} + + + )} + ); };