diff --git a/backend/src/@types/knex.d.ts b/backend/src/@types/knex.d.ts index 7cf86032f..d7b484dfc 100644 --- a/backend/src/@types/knex.d.ts +++ b/backend/src/@types/knex.d.ts @@ -6,6 +6,7 @@ import { TAccessApprovalPoliciesApprovers, TAccessApprovalPoliciesApproversInsert, TAccessApprovalPoliciesApproversUpdate, + TAccessApprovalPoliciesGroupApprovers, TAccessApprovalPoliciesInsert, TAccessApprovalPoliciesUpdate, TAccessApprovalRequests, @@ -800,5 +801,10 @@ declare module "knex/types/tables" { TWorkflowIntegrationsInsert, TWorkflowIntegrationsUpdate >; + [TableName.AccessApprovalPolicyGroupApprover]: KnexOriginal.CompositeTableType< + TAccessApprovalPoliciesGroupApprovers, + TAccessApprovalPoliciesGroupApproversInsert, + TAccessApprovalPoliciesGroupApproversInsert + >; } } diff --git a/backend/src/db/migrations/20240918005344_add-group-approvals.ts b/backend/src/db/migrations/20240918005344_add-group-approvals.ts new file mode 100644 index 000000000..aeddbd9cb --- /dev/null +++ b/backend/src/db/migrations/20240918005344_add-group-approvals.ts @@ -0,0 +1,22 @@ +import { Knex } from "knex"; + +import { TableName } from "../schemas"; +import { createOnUpdateTrigger } from "../utils"; + +export async function up(knex: Knex): Promise { + if (!(await knex.schema.hasTable(TableName.AccessApprovalPolicyGroupApprover))) { + await knex.schema.createTable(TableName.AccessApprovalPolicyGroupApprover, (t) => { + t.uuid("id", { primaryKey: true }).defaultTo(knex.fn.uuid()); + t.uuid("approverGroupId").notNullable(); + t.foreign("approverGroupId").references("id").inTable(TableName.Groups).onDelete("CASCADE"); + t.uuid("policyId").notNullable(); + t.foreign("policyId").references("id").inTable(TableName.AccessApprovalPolicy).onDelete("CASCADE"); + t.timestamps(true, true, true); + }); + await createOnUpdateTrigger(knex, TableName.AccessApprovalPolicyGroupApprover); + } +} + +export async function down(knex: Knex): Promise { + await knex.schema.dropTableIfExists(TableName.AccessApprovalPolicyGroupApprover); +} diff --git a/backend/src/db/schemas/access-approval-policies-group-approvers.ts b/backend/src/db/schemas/access-approval-policies-group-approvers.ts new file mode 100644 index 000000000..7092629f6 --- /dev/null +++ b/backend/src/db/schemas/access-approval-policies-group-approvers.ts @@ -0,0 +1,25 @@ +// Code generated by automation script, DO NOT EDIT. +// Automated by pulling database and generating zod schema +// To update. Just run npm run generate:schema +// Written by akhilmhdh. + +import { z } from "zod"; + +import { TImmutableDBKeys } from "./models"; + +export const AccessApprovalPoliciesGroupApproversSchema = z.object({ + id: z.string().uuid(), + approverGroupId: z.string().uuid(), + policyId: z.string().uuid(), + createdAt: z.date(), + updatedAt: z.date() +}); + +export type TAccessApprovalPoliciesGroupApprovers = z.infer; +export type TAccessApprovalPoliciesGroupApproversInsert = Omit< + z.input, + TImmutableDBKeys +>; +export type TAccessApprovalPoliciesGroupApproversUpdate = Partial< + Omit, TImmutableDBKeys> +>; diff --git a/backend/src/db/schemas/index.ts b/backend/src/db/schemas/index.ts index d856cab49..b145684b4 100644 --- a/backend/src/db/schemas/index.ts +++ b/backend/src/db/schemas/index.ts @@ -1,5 +1,6 @@ export * from "./access-approval-policies"; export * from "./access-approval-policies-approvers"; +export * from "./access-approval-policies-group-approvers"; export * from "./access-approval-requests"; export * from "./access-approval-requests-reviewers"; export * from "./api-keys"; diff --git a/backend/src/db/schemas/models.ts b/backend/src/db/schemas/models.ts index 068ef74ad..2ed5c0326 100644 --- a/backend/src/db/schemas/models.ts +++ b/backend/src/db/schemas/models.ts @@ -73,6 +73,7 @@ export enum TableName { ScimToken = "scim_tokens", AccessApprovalPolicy = "access_approval_policies", AccessApprovalPolicyApprover = "access_approval_policies_approvers", + AccessApprovalPolicyGroupApprover = "access_approval_policies_group_approvers", AccessApprovalRequest = "access_approval_requests", AccessApprovalRequestReviewer = "access_approval_requests_reviewers", SecretApprovalPolicy = "secret_approval_policies", 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 a57a26d81..4a0bc8983 100644 --- a/backend/src/ee/routes/v1/access-approval-policy-router.ts +++ b/backend/src/ee/routes/v1/access-approval-policy-router.ts @@ -17,13 +17,14 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi name: z.string().optional(), secretPath: z.string().trim().default("/"), environment: z.string(), - approvers: z.string().array().min(1), + approvers: z.string().array().default([]), + groupApprovers: z.string().array().default([]), approvals: z.number().min(1).default(1), enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard) }) - .refine((data) => data.approvals <= data.approvers.length, { - path: ["approvals"], - message: "The number of approvals should be lower than the number of approvers." + .refine((data) => data.approvers.length > 0 || data.groupApprovers.length > 0, { + path: ["approvers", "groupApprovers"], + message: "At least one approver should be provided." }), response: { 200: z.object({ @@ -63,6 +64,11 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi userId: z.string() }) .array(), + groupApprovers: z + .object({ + groupId: z.string() + }) + .array(), secretPath: z.string().optional().nullable() }) .array() @@ -127,13 +133,14 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi .trim() .optional() .transform((val) => (val === "" ? "/" : val)), - approvers: z.string().array().min(1), - approvals: z.number().min(1).default(1), + approvers: z.string().array().optional().default([]), + approvals: z.number().min(1).optional(), + groupApprovers: z.string().array().optional().default([]), enforcementLevel: z.nativeEnum(EnforcementLevel).default(EnforcementLevel.Hard) }) - .refine((data) => data.approvals <= data.approvers.length, { - path: ["approvals"], - message: "The number of approvals should be lower than the number of approvers." + .refine((data) => data.approvers || data.groupApprovers, { + path: ["approvers", "groupApprovers"], + message: "At least one approver should be provided." }), response: { 200: 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 c224bd3ca..b5c854e41 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 @@ -20,7 +20,13 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => { `${TableName.AccessApprovalPolicy}.id`, `${TableName.AccessApprovalPolicyApprover}.policyId` ) + .leftJoin( + TableName.AccessApprovalPolicyGroupApprover, + `${TableName.AccessApprovalPolicy}.id`, + `${TableName.AccessApprovalPolicyGroupApprover}.policyId` + ) .select(tx.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover)) + .select(tx.ref("approverGroupId").withSchema(TableName.AccessApprovalPolicyGroupApprover)) .select(tx.ref("name").withSchema(TableName.Environment).as("envName")) .select(tx.ref("slug").withSchema(TableName.Environment).as("envSlug")) .select(tx.ref("id").withSchema(TableName.Environment).as("envId")) @@ -54,6 +60,13 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => { mapper: ({ approverUserId }) => ({ userId: approverUserId }) + }, + { + key: "approverGroupId", + label: "groupApprovers" as const, + mapper: ({ approverGroupId }) => ({ + groupId: approverGroupId + }) } ] }); @@ -88,6 +101,13 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => { mapper: ({ approverUserId }) => ({ userId: approverUserId }) + }, + { + key: "approverGroupId", + label: "groupApprovers" as const, + mapper: ({ approverGroupId }) => ({ + groupId: approverGroupId + }) } ] }); diff --git a/backend/src/ee/services/access-approval-policy/access-approval-policy-group-approver-dal.ts b/backend/src/ee/services/access-approval-policy/access-approval-policy-group-approver-dal.ts new file mode 100644 index 000000000..baa330543 --- /dev/null +++ b/backend/src/ee/services/access-approval-policy/access-approval-policy-group-approver-dal.ts @@ -0,0 +1,12 @@ +import { TDbClient } from "@app/db"; +import { TableName } from "@app/db/schemas"; +import { ormify } from "@app/lib/knex"; + +export type TAccessApprovalPolicyGroupApproverDALFactory = ReturnType< + typeof accessApprovalPolicyGroupApproverDALFactory +>; + +export const accessApprovalPolicyGroupApproverDALFactory = (db: TDbClient) => { + const accessApprovalPolicyGroupApproverOrm = ormify(db, TableName.AccessApprovalPolicyGroupApprover); + return { ...accessApprovalPolicyGroupApproverOrm }; +}; 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 153941771..0e75416dd 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 @@ -3,13 +3,16 @@ import { ForbiddenError } from "@casl/ability"; import { TPermissionServiceFactory } from "@app/ee/services/permission/permission-service"; import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services/permission/project-permission"; import { BadRequestError } from "@app/lib/errors"; +import { logger } from "@app/lib/logger"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectEnvDALFactory } from "@app/services/project-env/project-env-dal"; import { TProjectMembershipDALFactory } from "@app/services/project-membership/project-membership-dal"; +import { TGroupDALFactory } from "../group/group-dal"; import { TAccessApprovalPolicyApproverDALFactory } from "./access-approval-policy-approver-dal"; import { TAccessApprovalPolicyDALFactory } from "./access-approval-policy-dal"; import { verifyApprovers } from "./access-approval-policy-fns"; +import { TAccessApprovalPolicyGroupApproverDALFactory } from "./access-approval-policy-group-approver-dal"; import { TCreateAccessApprovalPolicy, TDeleteAccessApprovalPolicy, @@ -25,13 +28,17 @@ type TSecretApprovalPolicyServiceFactoryDep = { projectEnvDAL: Pick; accessApprovalPolicyApproverDAL: TAccessApprovalPolicyApproverDALFactory; projectMembershipDAL: Pick; + groupDAL: TGroupDALFactory; + accessApprovalPolicyGroupApproverDAL: TAccessApprovalPolicyGroupApproverDALFactory; }; export type TAccessApprovalPolicyServiceFactory = ReturnType; export const accessApprovalPolicyServiceFactory = ({ accessApprovalPolicyDAL, + accessApprovalPolicyGroupApproverDAL, accessApprovalPolicyApproverDAL, + groupDAL, permissionService, projectEnvDAL, projectDAL @@ -45,6 +52,7 @@ export const accessApprovalPolicyServiceFactory = ({ actorAuthMethod, approvals, approvers, + groupApprovers, projectSlug, environment, enforcementLevel @@ -52,7 +60,11 @@ export const accessApprovalPolicyServiceFactory = ({ const project = await projectDAL.findProjectBySlug(projectSlug, actorOrgId); if (!project) throw new BadRequestError({ message: "Project not found" }); - if (approvals > approvers.length) + if (!groupApprovers && !approvers) + throw new BadRequestError({ message: "Either of approvers or group approvers must be provided" }); + + // If there is a group approver people might be added to the group later to meet the approvers quota + if (!groupApprovers && approvals > approvers.length) throw new BadRequestError({ message: "Approvals cannot be greater than approvers" }); const { permission } = await permissionService.getProjectPermission( @@ -69,6 +81,35 @@ export const accessApprovalPolicyServiceFactory = ({ const env = await projectEnvDAL.findOne({ slug: environment, projectId: project.id }); if (!env) throw new BadRequestError({ message: "Environment not found" }); + const verifyAllApprovers = approvers; + const usersPromises: Promise< + { + id: string; + email: string | null | undefined; + username: string; + firstName: string | null | undefined; + lastName: string | null | undefined; + isPartOfGroup: boolean; + }[] + >[] = []; + + for (const groupId of groupApprovers) { + usersPromises.push(groupDAL.findAllGroupMembers({ orgId: actorOrgId, groupId, offset: 0 })); + } + const verifyGroupApprovers = (await Promise.all(usersPromises)).flat().map((user) => user.id); + verifyAllApprovers.push(...verifyGroupApprovers); + + logger.info("verifyApproversCreate"); + logger.info({ + projectId: project.id, + orgId: actorOrgId, + envSlug: environment, + secretPath, + actorAuthMethod, + permissionService, + userIds: verifyAllApprovers + }); + await verifyApprovers({ projectId: project.id, orgId: actorOrgId, @@ -76,7 +117,7 @@ export const accessApprovalPolicyServiceFactory = ({ secretPath, actorAuthMethod, permissionService, - userIds: approvers + userIds: verifyAllApprovers }); const accessApproval = await accessApprovalPolicyDAL.transaction(async (tx) => { @@ -97,6 +138,15 @@ export const accessApprovalPolicyServiceFactory = ({ })), tx ); + + await accessApprovalPolicyGroupApproverDAL.insertMany( + groupApprovers.map((groupId) => ({ + approverGroupId: groupId, + policyId: doc.id + })), + tx + ); + return doc; }); return { ...accessApproval, environment: env, projectId: project.id }; @@ -129,6 +179,7 @@ export const accessApprovalPolicyServiceFactory = ({ const updateAccessApprovalPolicy = async ({ policyId, approvers, + groupApprovers, secretPath, name, actorId, @@ -162,6 +213,16 @@ export const accessApprovalPolicyServiceFactory = ({ tx ); if (approvers) { + logger.info("verifyApproversPatch"); + logger.info({ + projectId: accessApprovalPolicy.projectId, + orgId: actorOrgId, + envSlug: accessApprovalPolicy.environment.slug, + secretPath, + actorAuthMethod, + permissionService, + userIds: approvers + }); await verifyApprovers({ projectId: accessApprovalPolicy.projectId, orgId: actorOrgId, @@ -171,7 +232,6 @@ export const accessApprovalPolicyServiceFactory = ({ permissionService, userIds: approvers }); - await accessApprovalPolicyApproverDAL.delete({ policyId: doc.id }, tx); await accessApprovalPolicyApproverDAL.insertMany( approvers.map((userId) => ({ @@ -181,6 +241,43 @@ export const accessApprovalPolicyServiceFactory = ({ tx ); } + + if (groupApprovers) { + const usersPromises: Promise< + { + id: string; + email: string | null | undefined; + username: string; + firstName: string | null | undefined; + lastName: string | null | undefined; + isPartOfGroup: boolean; + }[] + >[] = []; + + for (const groupId of groupApprovers) { + usersPromises.push(groupDAL.findAllGroupMembers({ orgId: actorOrgId, groupId, offset: 0 })); + } + const verifyGroupApprovers = (await Promise.all(usersPromises)).flat().map((user) => user.id); + + await verifyApprovers({ + projectId: accessApprovalPolicy.projectId, + orgId: actorOrgId, + envSlug: accessApprovalPolicy.environment.slug, + secretPath: doc.secretPath!, + actorAuthMethod, + permissionService, + userIds: verifyGroupApprovers + }); + await accessApprovalPolicyGroupApproverDAL.delete({ policyId: doc.id }, tx); + await accessApprovalPolicyGroupApproverDAL.insertMany( + groupApprovers.map((groupId) => ({ + approverGroupId: groupId, + policyId: 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 fdb6fc8bb..47d7ead9e 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 @@ -18,6 +18,7 @@ export type TCreateAccessApprovalPolicy = { secretPath: string; environment: string; approvers: string[]; + groupApprovers: string[]; projectSlug: string; name: string; enforcementLevel: EnforcementLevel; @@ -27,6 +28,7 @@ export type TUpdateAccessApprovalPolicy = { policyId: string; approvals?: number; approvers?: string[]; + groupApprovers?: string[]; secretPath?: string; name?: string; enforcementLevel?: EnforcementLevel; 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 48e2d88bf..5746ecb59 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,6 +39,17 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { `${TableName.AccessApprovalPolicy}.id`, `${TableName.AccessApprovalPolicyApprover}.policyId` ) + .leftJoin( + TableName.AccessApprovalPolicyGroupApprover, + `${TableName.AccessApprovalPolicy}.id`, + `${TableName.AccessApprovalPolicyGroupApprover}.policyId` + ) + .leftJoin( + TableName.UserGroupMembership, + `${TableName.AccessApprovalPolicyGroupApprover}.approverGroupId`, + `${TableName.UserGroupMembership}.groupId` + ) + .leftJoin(TableName.Users, `${TableName.UserGroupMembership}.userId`, `${TableName.Users}.id`) .join( db(TableName.Users).as("requestedByUser"), @@ -59,6 +70,7 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { ) .select(db.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover)) + .select(db.ref("userId").withSchema(TableName.UserGroupMembership).as("approverGroupUserId")) .select( db.ref("projectId").withSchema(TableName.Environment), @@ -142,7 +154,12 @@ 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 }) => approverUserId }, + { + key: "approverGroupUserId", + label: "approvers" as const, + mapper: ({ approverGroupUserId }) => approverGroupUserId + } ] }); @@ -172,18 +189,36 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { `requestedByUser.id` ) - .join( + .leftJoin( TableName.AccessApprovalPolicyApprover, `${TableName.AccessApprovalPolicy}.id`, `${TableName.AccessApprovalPolicyApprover}.policyId` ) - .join( + .leftJoin( db(TableName.Users).as("accessApprovalPolicyApproverUser"), `${TableName.AccessApprovalPolicyApprover}.approverUserId`, "accessApprovalPolicyApproverUser.id" ) + .leftJoin( + TableName.AccessApprovalPolicyGroupApprover, + `${TableName.AccessApprovalPolicy}.id`, + `${TableName.AccessApprovalPolicyGroupApprover}.policyId` + ) + + .leftJoin( + TableName.UserGroupMembership, + `${TableName.AccessApprovalPolicyGroupApprover}.approverGroupId`, + `${TableName.UserGroupMembership}.groupId` + ) + + .leftJoin( + db(TableName.Users).as("accessApprovalPolicyGroupApproverUser"), + `${TableName.UserGroupMembership}.userId`, + "accessApprovalPolicyGroupApproverUser.id" + ) + .leftJoin( TableName.AccessApprovalRequestReviewer, `${TableName.AccessApprovalRequest}.id`, @@ -200,10 +235,15 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { .select(selectAllTableCols(TableName.AccessApprovalRequest)) .select( tx.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover), + tx.ref("userId").withSchema(TableName.UserGroupMembership), tx.ref("email").withSchema("accessApprovalPolicyApproverUser").as("approverEmail"), + tx.ref("email").withSchema("accessApprovalPolicyGroupApproverUser").as("approverGroupEmail"), tx.ref("username").withSchema("accessApprovalPolicyApproverUser").as("approverUsername"), + tx.ref("username").withSchema("accessApprovalPolicyGroupApproverUser").as("approverGroupUsername"), tx.ref("firstName").withSchema("accessApprovalPolicyApproverUser").as("approverFirstName"), + tx.ref("firstName").withSchema("accessApprovalPolicyGroupApproverUser").as("approverGroupFirstName"), tx.ref("lastName").withSchema("accessApprovalPolicyApproverUser").as("approverLastName"), + tx.ref("lastName").withSchema("accessApprovalPolicyGroupApproverUser").as("approverGroupLastName"), tx.ref("email").withSchema("requestedByUser").as("requestedByUserEmail"), tx.ref("username").withSchema("requestedByUser").as("requestedByUserUsername"), tx.ref("firstName").withSchema("requestedByUser").as("requestedByUserFirstName"), @@ -282,6 +322,23 @@ export const accessApprovalRequestDALFactory = (db: TDbClient) => { lastName, username }) + }, + { + key: "userId", + label: "approvers" as const, + mapper: ({ + userId, + approverGroupEmail: email, + approverGroupUsername: username, + approverGroupLastName: lastName, + approverFirstName: firstName + }) => ({ + userId, + email, + firstName, + lastName, + username + }) } ] }); 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 2a7953ead..108e4607e 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 @@ -18,6 +18,8 @@ import { TUserDALFactory } from "@app/services/user/user-dal"; import { TAccessApprovalPolicyApproverDALFactory } from "../access-approval-policy/access-approval-policy-approver-dal"; import { TAccessApprovalPolicyDALFactory } from "../access-approval-policy/access-approval-policy-dal"; import { verifyApprovers } from "../access-approval-policy/access-approval-policy-fns"; +import { TAccessApprovalPolicyGroupApproverDALFactory } from "../access-approval-policy/access-approval-policy-group-approver-dal"; +import { TGroupDALFactory } from "../group/group-dal"; import { TPermissionServiceFactory } from "../permission/permission-service"; import { TProjectUserAdditionalPrivilegeDALFactory } from "../project-user-additional-privilege/project-user-additional-privilege-dal"; import { ProjectUserAdditionalPrivilegeTemporaryMode } from "../project-user-additional-privilege/project-user-additional-privilege-types"; @@ -36,6 +38,7 @@ type TSecretApprovalRequestServiceFactoryDep = { additionalPrivilegeDAL: Pick; permissionService: Pick; accessApprovalPolicyApproverDAL: Pick; + accessApprovalPolicyGroupApproverDAL: Pick; projectEnvDAL: Pick; projectDAL: Pick< TProjectDALFactory, @@ -57,6 +60,7 @@ type TSecretApprovalRequestServiceFactoryDep = { TAccessApprovalRequestReviewerDALFactory, "create" | "find" | "findOne" | "transaction" >; + groupDAL: Pick; projectMembershipDAL: Pick; smtpService: Pick; userDAL: Pick< @@ -70,6 +74,7 @@ type TSecretApprovalRequestServiceFactoryDep = { export type TAccessApprovalRequestServiceFactory = ReturnType; export const accessApprovalRequestServiceFactory = ({ + groupDAL, projectDAL, projectEnvDAL, permissionService, @@ -78,6 +83,7 @@ export const accessApprovalRequestServiceFactory = ({ projectMembershipDAL, accessApprovalPolicyDAL, accessApprovalPolicyApproverDAL, + accessApprovalPolicyGroupApproverDAL, additionalPrivilegeDAL, smtpService, userDAL, @@ -124,13 +130,35 @@ export const accessApprovalRequestServiceFactory = ({ }); if (!policy) throw new UnauthorizedError({ message: "No policy matching criteria was found." }); + const approverIds = []; + const approvers = await accessApprovalPolicyApproverDAL.find({ policyId: policy.id }); + approvers.forEach((approver) => { + approverIds.push(approver.approverUserId); + }); + + const groupApprovers = await accessApprovalPolicyGroupApproverDAL.find({ + policyId: policy.id + }); + + const groupUsers = ( + await Promise.all( + groupApprovers.map((groupApprover) => + groupDAL.findAllGroupMembers({ + orgId: actorOrgId, + groupId: groupApprover.id + }) + ) + ) + ).flat(); + approverIds.push(...groupUsers.map((user) => user.id)); + const approverUsers = await userDAL.find({ $in: { - id: approvers.map((approver) => approver.approverUserId) + id: [...new Set(approverIds)] } }); diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index 3eb6b0031..c1fd71494 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -7,6 +7,7 @@ import { registerCertificateEstRouter } from "@app/ee/routes/est/certificate-est import { registerV1EERoutes } from "@app/ee/routes/v1"; import { accessApprovalPolicyApproverDALFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-approver-dal"; import { accessApprovalPolicyDALFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-dal"; +import { accessApprovalPolicyGroupApproverDALFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-group-approver-dal"; import { accessApprovalPolicyServiceFactory } from "@app/ee/services/access-approval-policy/access-approval-policy-service"; import { accessApprovalRequestDALFactory } from "@app/ee/services/access-approval-request/access-approval-request-dal"; import { accessApprovalRequestReviewerDALFactory } from "@app/ee/services/access-approval-request/access-approval-request-reviewer-dal"; @@ -626,6 +627,7 @@ export const registerRoutes = async ( const pkiAlertDAL = pkiAlertDALFactory(db); const pkiCollectionDAL = pkiCollectionDALFactory(db); const pkiCollectionItemDAL = pkiCollectionItemDALFactory(db); + const accessApprovalPolicyGroupApproverDAL = accessApprovalPolicyGroupApproverDALFactory(db); const certificateService = certificateServiceFactory({ certificateDAL, @@ -923,6 +925,8 @@ export const registerRoutes = async ( const accessApprovalPolicyService = accessApprovalPolicyServiceFactory({ accessApprovalPolicyDAL, accessApprovalPolicyApproverDAL, + accessApprovalPolicyGroupApproverDAL, + groupDAL, permissionService, projectEnvDAL, projectMembershipDAL, @@ -942,7 +946,9 @@ export const registerRoutes = async ( smtpService, accessApprovalPolicyApproverDAL, projectSlackConfigDAL, - kmsService + kmsService, + accessApprovalPolicyGroupApproverDAL, + groupDAL }); const secretReplicationService = secretReplicationServiceFactory({ diff --git a/frontend/src/hooks/api/accessApproval/mutation.tsx b/frontend/src/hooks/api/accessApproval/mutation.tsx index 9c3199a99..c41b633b0 100644 --- a/frontend/src/hooks/api/accessApproval/mutation.tsx +++ b/frontend/src/hooks/api/accessApproval/mutation.tsx @@ -21,6 +21,7 @@ export const useCreateAccessApprovalPolicy = () => { projectSlug, approvals, approvers, + groupApprovers, name, secretPath, enforcementLevel @@ -30,6 +31,7 @@ export const useCreateAccessApprovalPolicy = () => { projectSlug, approvals, approvers, + groupApprovers, secretPath, name, enforcementLevel @@ -46,10 +48,11 @@ export const useUpdateAccessApprovalPolicy = () => { const queryClient = useQueryClient(); return useMutation<{}, {}, TUpdateAccessPolicyDTO>({ - mutationFn: async ({ id, approvers, approvals, name, secretPath, enforcementLevel }) => { + mutationFn: async ({ id, approvers, groupApprovers, approvals, name, secretPath, enforcementLevel }) => { const { data } = await apiRequest.patch(`/api/v1/access-approvals/policies/${id}`, { approvals, approvers, + groupApprovers, secretPath, name, enforcementLevel diff --git a/frontend/src/hooks/api/accessApproval/types.ts b/frontend/src/hooks/api/accessApproval/types.ts index 47917716d..37772878e 100644 --- a/frontend/src/hooks/api/accessApproval/types.ts +++ b/frontend/src/hooks/api/accessApproval/types.ts @@ -17,6 +17,7 @@ export type TAccessApprovalPolicy = { enforcementLevel: EnforcementLevel; updatedAt: Date; userApprovers?: { userId: string }[]; + groupApprovers?: { groupId: string }[]; }; export type TAccessApprovalRequest = { @@ -131,6 +132,7 @@ export type TCreateAccessPolicyDTO = { name?: string; environment: string; approvers?: string[]; + groupApprovers?: string[]; approvals?: number; secretPath?: string; enforcementLevel?: EnforcementLevel; @@ -140,6 +142,7 @@ export type TUpdateAccessPolicyDTO = { id: string; name?: string; approvers?: string[]; + groupApprovers?: string[]; secretPath?: string; environment?: string; approvals?: number; diff --git a/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx index 7234c18ce..0653a92e8 100644 --- a/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx +++ b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/ApprovalPolicyList.tsx @@ -41,7 +41,8 @@ import { useDeleteAccessApprovalPolicy, useDeleteSecretApprovalPolicy, useGetSecretApprovalPolicies, - useGetWorkspaceUsers + useGetWorkspaceUsers, + useListWorkspaceGroups } from "@app/hooks/api"; import { useGetAccessApprovalPolicies } from "@app/hooks/api/accessApproval/queries"; import { PolicyType } from "@app/hooks/api/policies/enums"; @@ -102,6 +103,8 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => { const { currentWorkspace } = useWorkspace(); const { data: members } = useGetWorkspaceUsers(workspaceId, true); + const { data: groups } = useListWorkspaceGroups(currentWorkspace?.slug || ""); + const { policies, isLoading: isPoliciesLoading } = useApprovalPolicies( permission, currentWorkspace @@ -186,6 +189,7 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => { Environment Secret Path Eligible Approvers + Eligible Group Approvers Approval Required @@ -257,6 +261,7 @@ export const ApprovalPolicyList = ({ workspaceId }: IProps) => { workspaceId={workspaceId} key={policy.id} members={members} + groups={groups} onEdit={() => handlePopUpOpen("policyForm", policy)} onDelete={() => handlePopUpOpen("deletePolicy", policy)} /> diff --git a/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx index 24a470bcc..a4218e582 100644 --- a/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx +++ b/frontend/src/views/SecretApprovalPage/components/ApprovalPolicyList/components/AccessPolicyModal.tsx @@ -22,7 +22,7 @@ import { } from "@app/components/v2"; import { useWorkspace } from "@app/context"; import { policyDetails } from "@app/helpers/policies"; -import { useCreateSecretApprovalPolicy, useUpdateSecretApprovalPolicy } from "@app/hooks/api"; +import { useCreateSecretApprovalPolicy, useListWorkspaceGroups, useUpdateSecretApprovalPolicy } from "@app/hooks/api"; import { useCreateAccessApprovalPolicy, useUpdateAccessApprovalPolicy @@ -45,13 +45,14 @@ const formSchema = z name: z.string().optional(), secretPath: z.string().optional(), approvals: z.number().min(1), - approvers: z.string().array().min(1), + approvers: z.string().array().optional(), + groupApprovers: z.string().array().optional(), policyType: z.nativeEnum(PolicyType), enforcementLevel: z.nativeEnum(EnforcementLevel) }) - .refine((data) => data.approvals <= data.approvers.length, { - path: ["approvals"], - message: "The number of approvals should be lower than the number of approvers." + .refine((data) => data.approvers || data.groupApprovers, { + path: ["approvers", "groupApprovers"], + message: "At least one approver should be provided." }); type TFormSchema = z.infer; @@ -75,11 +76,14 @@ export const AccessPolicyForm = ({ ? { ...editValues, environment: editValues.environment.slug, - approvers: editValues?.userApprovers?.map((user) => user.userId) || editValues?.approvers + approvers: editValues?.userApprovers?.map((user) => user.userId) || editValues?.approvers, + groupApprovers: editValues?.groupApprovers?.map((group) => group.groupId) || editValues?.groupApprovers, + approvals: editValues?.approvals } : undefined }); const { currentWorkspace } = useWorkspace(); + const { data: groups } = useListWorkspaceGroups(projectSlug); const environments = currentWorkspace?.environments || []; const isEditMode = Boolean(editValues); @@ -266,8 +270,7 @@ export const AccessPolicyForm = ({ name="approvers" render={({ field: { value, onChange }, fieldState: { error } }) => ( @@ -312,6 +315,56 @@ export const AccessPolicyForm = ({ )} /> + ( + + + + + + + + Select groups that are allowed to approve requests + + {groups && groups.map(({ group }) => { + const { id } = group; + const isChecked = value?.includes(id); + return ( + { + evt.preventDefault(); + onChange( + isChecked + ? value?.filter((el: string) => el !== id) + : [...(value || []), id] + ); + }} + key={`create-policy-members-${id}`} + iconPos="right" + icon={isChecked && } + > + {group.name} + + ); + })} + + + + )} + /> void; @@ -48,12 +51,14 @@ type Props = { export const ApprovalPolicyRow = ({ policy, members = [], + groups = [], projectSlug, workspaceId, onEdit, onDelete }: Props) => { const [selectedApprovers, setSelectedApprovers] = useState(policy.userApprovers?.map(({ userId }) => userId) || policy.approvers || []); + const [selectedGroupApprovers, setSelectedGroupApprovers] = useState(policy.groupApprovers?.map(({ groupId }) => groupId) || []); const { mutate: updateAccessApprovalPolicy, isLoading: isAccessApprovalPolicyLoading } = useUpdateAccessApprovalPolicy(); const { mutate: updateSecretApprovalPolicy, isLoading: isSecretApprovalPolicyLoading } = useUpdateSecretApprovalPolicy(); const isLoading = isAccessApprovalPolicyLoading || isSecretApprovalPolicyLoading; @@ -74,9 +79,10 @@ export const ApprovalPolicyRow = ({ { projectSlug, id: policy.id, - approvers: selectedApprovers + approvers: selectedApprovers, + groupApprovers: selectedGroupApprovers }, - { onSettled: () => {} } + { onSettled: () => { } } ); } else { updateSecretApprovalPolicy( @@ -85,7 +91,7 @@ export const ApprovalPolicyRow = ({ id: policy.id, approvers: selectedApprovers }, - { onSettled: () => {} } + { onSettled: () => { } } ); } } else { @@ -95,7 +101,7 @@ export const ApprovalPolicyRow = ({ ); } }} - > + > Select members that are allowed to approve changes - {members?.map(({ id, user }) => { - const userId = policy.policyType === PolicyType.ChangePolicy ? user.id : id; + {members?.map(({ user }) => { + const userId = user.id; const isChecked = selectedApprovers.includes(userId); return ( + + { + if (!isOpen) { + if (policy.policyType === PolicyType.AccessPolicy) { + updateAccessApprovalPolicy( + { + projectSlug, + id: policy.id, + approvers: selectedApprovers, + groupApprovers: selectedGroupApprovers + }, + { onSettled: () => { } } + ); + } else { + updateSecretApprovalPolicy( + { + workspaceId, + id: policy.id, + approvers: selectedApprovers, + }, + { onSettled: () => { } } + ); + } + } else { + setSelectedGroupApprovers(policy.policyType === PolicyType.ChangePolicy + ? policy?.groupApprovers?.map(({ groupId }) => groupId) || [] + : policy?.groupApprovers?.map(({groupId}) => groupId) || [] + ); + } + }} + > + + + + + + Select groups that are allowed to approve requests + + {groups && groups.map(({ group }) => { + const { id } = group; + const isChecked = selectedGroupApprovers?.includes(id); + return ( + { + evt.preventDefault(); + setSelectedGroupApprovers( + isChecked + ? selectedGroupApprovers?.filter((el: string) => el !== id) + : [...(selectedGroupApprovers || []), id] + ); + }} + key={`create-policy-groups-${id}`} + iconPos="right" + icon={isChecked && } + > + {group.name} + + ); + })} + + + {policy.approvals}