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..22dfc3c30 --- /dev/null +++ b/backend/src/db/migrations/20250603094506_access-request-sequential.ts @@ -0,0 +1,31 @@ +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(0); + if (!hasApprovalRequiredColumn) t.integer("approvalsRequired").nullable(); + }); + } +} + +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..d3897ce3e 100644 --- a/backend/src/ee/routes/v1/access-approval-policy-router.ts +++ b/backend/src/ee/routes/v1/access-approval-policy-router.ts @@ -23,8 +23,17 @@ 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") @@ -37,6 +46,14 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi .array() .max(100, "Cannot have more than 100 bypassers") .optional(), + approvalsRequired: z + .record( + z.number().int(), + z.object({ + numberOfApprovals: z.number().int() + }) + ) + .optional(), approvals: z.number().min(1).default(1), enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard), allowedSelfApprovals: z.boolean().default(true) @@ -168,7 +185,15 @@ 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 + .record( + z.number().int(), + z.object({ + numberOfApprovals: z.number().int() + }) + ) + .optional() }), response: { 200: z.object({ 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..f194f8248 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 @@ -30,6 +30,7 @@ import { TListAccessApprovalPoliciesDTO, TUpdateAccessApprovalPolicy } from "./access-approval-policy-types"; +import { groupBy } from "@app/lib/fn"; type TAccessApprovalPolicyServiceFactoryDep = { projectDAL: TProjectDALFactory; @@ -76,24 +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[]; + const userApproverNames = approvers.filter( + (approver) => approver.type === ApproverType.User && approver.username + ) as { username: string; sequence?: number }[]; if (!groupApprovers && approvals > userApprovers.length + userApproverNames.length) throw new BadRequestError({ message: "Approvals cannot be greater than approvers" }); @@ -116,14 +116,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,31 +130,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].username, + 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[] = []; @@ -210,9 +191,11 @@ 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 ? approvalsRequired?.[el.sequence]?.numberOfApprovals : approvals })), tx ); @@ -220,9 +203,11 @@ 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 ? approvalsRequired?.[el.sequence]?.numberOfApprovals : approvals })), tx ); @@ -290,20 +275,19 @@ 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 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[]; + const userApproverNames = approvers.filter( + (approver) => approver.type === ApproverType.User && approver.username + ) as { username: string; sequence?: number }[]; const accessApprovalPolicy = await accessApprovalPolicyDAL.findById(policyId); const currentApprovals = approvals || accessApprovalPolicy.approvals; @@ -417,16 +401,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 +420,20 @@ export const accessApprovalPolicyServiceFactory = ({ }); } - userApproverIds = userApproverIds.concat(approverUsers.map((user) => user.id)); + approverUserIds = approverUserIds.concat( + userApproverNames.map((el) => ({ + id: approverUsersInDBGroupByUsername[el.username]?.[0].username, + 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 ? approvalsRequired?.[el.sequence]?.numberOfApprovals : approvals })), tx ); @@ -448,9 +441,11 @@ 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 ? approvalsRequired?.[el.sequence]?.numberOfApprovals : approvals })), tx ); 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..1fe2d4c34 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?: Record; } & 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?: Record; } & Omit; export type TDeleteAccessApprovalPolicy = {