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 f8abcee89..49d1c7765 100644 --- a/backend/src/ee/routes/v1/access-approval-policy-router.ts +++ b/backend/src/ee/routes/v1/access-approval-policy-router.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { ApproverType } from "@app/ee/services/access-approval-policy/access-approval-policy-types"; import { EnforcementLevel } from "@app/lib/types"; +import { readLimit } from "@app/server/config/rateLimiter"; import { verifyAuth } from "@app/server/plugins/auth/verify-auth"; import { sapPubSchema } from "@app/server/routes/sanitizedSchemas"; import { AuthMode } from "@app/services/auth/auth-type"; @@ -18,7 +19,10 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi secretPath: z.string().trim().default("/"), environment: z.string(), approvers: z - .object({ type: z.nativeEnum(ApproverType), id: z.string() }) + .discriminatedUnion("type", [ + z.object({ type: z.literal(ApproverType.Group), id: z.string() }), + z.object({ type: z.literal(ApproverType.User), id: z.string().optional(), name: z.string().optional() }) + ]) .array() .min(1, { message: "At least one approver should be provided" }), approvals: z.number().min(1).default(1), @@ -30,7 +34,7 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const approval = await server.services.accessApprovalPolicy.createAccessApprovalPolicy({ actor: req.permission.type, @@ -127,7 +131,10 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi .optional() .transform((val) => (val === "" ? "/" : val)), approvers: z - .object({ type: z.nativeEnum(ApproverType), id: z.string() }) + .discriminatedUnion("type", [ + z.object({ type: z.literal(ApproverType.Group), id: z.string() }), + z.object({ type: z.literal(ApproverType.User), id: z.string().optional(), name: z.string().optional() }) + ]) .array() .min(1, { message: "At least one approver should be provided" }), approvals: z.number().min(1).optional(), @@ -139,7 +146,7 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { await server.services.accessApprovalPolicy.updateAccessApprovalPolicy({ policyId: req.params.policyId, @@ -165,7 +172,7 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi }) } }, - onRequest: verifyAuth([AuthMode.JWT]), + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), handler: async (req) => { const approval = await server.services.accessApprovalPolicy.deleteAccessApprovalPolicy({ actor: req.permission.type, @@ -177,4 +184,44 @@ export const registerAccessApprovalPolicyRouter = async (server: FastifyZodProvi return { approval }; } }); + + server.route({ + url: "/:policyId", + method: "GET", + config: { + rateLimit: readLimit + }, + schema: { + params: z.object({ + policyId: z.string() + }), + response: { + 200: z.object({ + approval: sapPubSchema.extend({ + approvers: z + .object({ + type: z.nativeEnum(ApproverType), + id: z.string().nullable().optional(), + name: z.string().nullable().optional() + }) + .array() + .nullable() + .optional() + }) + }) + } + }, + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + handler: async (req) => { + const approval = await server.services.accessApprovalPolicy.getAccessApprovalPolicyById({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.params + }); + + return { approval }; + } + }); }; 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 51303dcfd..569580507 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 @@ -12,16 +12,29 @@ export type TAccessApprovalPolicyDALFactory = ReturnType { const accessApprovalPolicyOrm = ormify(db, TableName.AccessApprovalPolicy); - const accessApprovalPolicyFindQuery = async (tx: Knex, filter: TFindFilter) => { + const accessApprovalPolicyFindQuery = async ( + tx: Knex, + filter: TFindFilter, + customFilter?: { + policyId?: string; + } + ) => { const result = await tx(TableName.AccessApprovalPolicy) // eslint-disable-next-line .where(buildFindFilter(filter)) + .where((qb) => { + if (customFilter?.policyId) { + void qb.where(`${TableName.AccessApprovalPolicy}.id`, "=", customFilter.policyId); + } + }) .join(TableName.Environment, `${TableName.AccessApprovalPolicy}.envId`, `${TableName.Environment}.id`) .leftJoin( TableName.AccessApprovalPolicyApprover, `${TableName.AccessApprovalPolicy}.id`, `${TableName.AccessApprovalPolicyApprover}.policyId` ) + .leftJoin(TableName.Users, `${TableName.AccessApprovalPolicyApprover}.approverUserId`, `${TableName.Users}.id`) + .select(tx.ref("username").withSchema(TableName.Users).as("approverUsername")) .select(tx.ref("approverUserId").withSchema(TableName.AccessApprovalPolicyApprover)) .select(tx.ref("approverGroupId").withSchema(TableName.AccessApprovalPolicyApprover)) .select(tx.ref("name").withSchema(TableName.Environment).as("envName")) @@ -76,9 +89,15 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => { } }; - const find = async (filter: TFindFilter, tx?: Knex) => { + const find = async ( + filter: TFindFilter, + customFilter?: { + policyId?: string; + }, + tx?: Knex + ) => { try { - const docs = await accessApprovalPolicyFindQuery(tx || db.replicaNode(), filter); + const docs = await accessApprovalPolicyFindQuery(tx || db.replicaNode(), filter, customFilter); const formattedDocs = sqlNestRelationships({ data: docs, @@ -97,9 +116,10 @@ export const accessApprovalPolicyDALFactory = (db: TDbClient) => { { key: "approverUserId", label: "approvers" as const, - mapper: ({ approverUserId: id }) => ({ + mapper: ({ approverUserId: id, approverUsername }) => ({ id, - type: ApproverType.User + type: ApproverType.User, + name: approverUsername }) }, { 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 fdb242314..f947f267c 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 @@ -2,10 +2,11 @@ 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 { BadRequestError, NotFoundError } from "@app/lib/errors"; 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 { TUserDALFactory } from "@app/services/user/user-dal"; import { TGroupDALFactory } from "../group/group-dal"; import { TAccessApprovalPolicyApproverDALFactory } from "./access-approval-policy-approver-dal"; @@ -15,6 +16,7 @@ import { ApproverType, TCreateAccessApprovalPolicy, TDeleteAccessApprovalPolicy, + TGetAccessApprovalPolicyByIdDTO, TGetAccessPolicyCountByEnvironmentDTO, TListAccessApprovalPoliciesDTO, TUpdateAccessApprovalPolicy @@ -28,6 +30,7 @@ type TSecretApprovalPolicyServiceFactoryDep = { accessApprovalPolicyApproverDAL: TAccessApprovalPolicyApproverDALFactory; projectMembershipDAL: Pick; groupDAL: TGroupDALFactory; + userDAL: Pick; }; export type TAccessApprovalPolicyServiceFactory = ReturnType; @@ -38,7 +41,8 @@ export const accessApprovalPolicyServiceFactory = ({ groupDAL, permissionService, projectEnvDAL, - projectDAL + projectDAL, + userDAL }: TSecretApprovalPolicyServiceFactoryDep) => { const createAccessApprovalPolicy = async ({ name, @@ -59,12 +63,18 @@ export const accessApprovalPolicyServiceFactory = ({ // 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); + .map((approver) => approver.id) as string[]; + const userApprovers = approvers .filter((approver) => approver.type === ApproverType.User) - .map((approver) => approver.id); + .map((approver) => approver.id) + .filter(Boolean) as string[]; - if (!groupApprovers && approvals > userApprovers.length) + const userApproverNames = approvers + .map((approver) => (approver.type === ApproverType.User ? approver.name : undefined)) + .filter(Boolean) as string[]; + + if (!groupApprovers && approvals > userApprovers.length + userApproverNames.length) throw new BadRequestError({ message: "Approvals cannot be greater than approvers" }); const { permission } = await permissionService.getProjectPermission( @@ -81,7 +91,26 @@ export const accessApprovalPolicyServiceFactory = ({ const env = await projectEnvDAL.findOne({ slug: environment, projectId: project.id }); if (!env) throw new BadRequestError({ message: "Environment not found" }); - const verifyAllApprovers = userApprovers; + let approverUserIds = userApprovers; + if (userApproverNames.length) { + const approverUsers = await userDAL.find({ + $in: { + username: userApproverNames + } + }); + + const approverNamesFromDb = approverUsers.map((user) => user.username); + const invalidUsernames = userApproverNames.filter((username) => !approverNamesFromDb.includes(username)); + + if (invalidUsernames.length) { + throw new BadRequestError({ + message: `Invalid approver user: ${invalidUsernames.join(", ")}` + }); + } + + approverUserIds = approverUserIds.concat(approverUsers.map((user) => user.id)); + } + const usersPromises: Promise< { id: string; @@ -92,6 +121,7 @@ export const accessApprovalPolicyServiceFactory = ({ isPartOfGroup: boolean; }[] >[] = []; + const verifyAllApprovers = [...approverUserIds]; for (const groupId of groupApprovers) { usersPromises.push(groupDAL.findAllGroupMembers({ orgId: actorOrgId, groupId, offset: 0 })); @@ -120,9 +150,9 @@ export const accessApprovalPolicyServiceFactory = ({ }, tx ); - if (userApprovers) { + if (approverUserIds.length) { await accessApprovalPolicyApproverDAL.insertMany( - userApprovers.map((userId) => ({ + approverUserIds.map((userId) => ({ approverUserId: userId, policyId: doc.id })), @@ -182,15 +212,25 @@ export const accessApprovalPolicyServiceFactory = ({ enforcementLevel }: TUpdateAccessApprovalPolicy) => { const groupApprovers = approvers - ?.filter((approver) => approver.type === ApproverType.Group) - .map((approver) => approver.id); + .filter((approver) => approver.type === ApproverType.Group) + .map((approver) => approver.id) as string[]; + const userApprovers = approvers - ?.filter((approver) => approver.type === ApproverType.User) - .map((approver) => approver.id); + .filter((approver) => approver.type === ApproverType.User) + .map((approver) => approver.id) + .filter(Boolean) as string[]; + + const userApproverNames = approvers + .map((approver) => (approver.type === ApproverType.User ? approver.name : undefined)) + .filter(Boolean) as string[]; const accessApprovalPolicy = await accessApprovalPolicyDAL.findById(policyId); const currentAppovals = approvals || accessApprovalPolicy.approvals; - if (groupApprovers?.length === 0 && userApprovers && currentAppovals > userApprovers.length) { + if ( + groupApprovers?.length === 0 && + userApprovers && + currentAppovals > userApprovers.length + userApproverNames.length + ) { throw new BadRequestError({ message: "Approvals cannot be greater than approvers" }); } @@ -219,7 +259,27 @@ export const accessApprovalPolicyServiceFactory = ({ await accessApprovalPolicyApproverDAL.delete({ policyId: doc.id }, tx); - if (userApprovers) { + if (userApprovers.length || userApproverNames.length) { + let userApproverIds = userApprovers; + if (userApproverNames.length) { + const approverUsers = await userDAL.find({ + $in: { + username: userApproverNames + } + }); + + const approverNamesFromDb = approverUsers.map((user) => user.username); + const invalidUsernames = userApproverNames.filter((username) => !approverNamesFromDb.includes(username)); + + if (invalidUsernames.length) { + throw new BadRequestError({ + message: `Invalid approver user: ${invalidUsernames.join(", ")}` + }); + } + + userApproverIds = userApproverIds.concat(approverUsers.map((user) => user.id)); + } + await verifyApprovers({ projectId: accessApprovalPolicy.projectId, orgId: actorOrgId, @@ -227,10 +287,11 @@ export const accessApprovalPolicyServiceFactory = ({ secretPath: doc.secretPath!, actorAuthMethod, permissionService, - userIds: userApprovers + userIds: userApproverIds }); + await accessApprovalPolicyApproverDAL.insertMany( - userApprovers.map((userId) => ({ + userApproverIds.map((userId) => ({ approverUserId: userId, policyId: doc.id })), @@ -338,11 +399,40 @@ export const accessApprovalPolicyServiceFactory = ({ return { count: policies.length }; }; + const getAccessApprovalPolicyById = async ({ + actorId, + actor, + actorOrgId, + actorAuthMethod, + policyId + }: TGetAccessApprovalPolicyByIdDTO) => { + const [policy] = await accessApprovalPolicyDAL.find({}, { policyId }); + + if (!policy) { + throw new NotFoundError({ + message: "Cannot find access approval policy" + }); + } + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + policy.projectId, + actorAuthMethod, + actorOrgId + ); + + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.SecretApproval); + + return policy; + }; + return { getAccessPolicyCountByEnvSlug, createAccessApprovalPolicy, deleteAccessApprovalPolicy, updateAccessApprovalPolicy, - getAccessApprovalPolicyByProjectSlug + getAccessApprovalPolicyByProjectSlug, + getAccessApprovalPolicyById }; }; 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 c20aabfad..bc8f23572 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 @@ -22,7 +22,7 @@ export type TCreateAccessApprovalPolicy = { approvals: number; secretPath: string; environment: string; - approvers: { type: ApproverType; id: string }[]; + approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; name?: string })[]; projectSlug: string; name: string; enforcementLevel: EnforcementLevel; @@ -31,7 +31,7 @@ export type TCreateAccessApprovalPolicy = { export type TUpdateAccessApprovalPolicy = { policyId: string; approvals?: number; - approvers?: { type: ApproverType; id: string }[]; + approvers: ({ type: ApproverType.Group; id: string } | { type: ApproverType.User; id?: string; name?: string })[]; secretPath?: string; name?: string; enforcementLevel?: EnforcementLevel; @@ -46,6 +46,10 @@ export type TGetAccessPolicyCountByEnvironmentDTO = { projectSlug: string; } & Omit; +export type TGetAccessApprovalPolicyByIdDTO = { + policyId: string; +} & Omit; + export type TListAccessApprovalPoliciesDTO = { projectSlug: string; } & Omit; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index f1c51f8b5..45a08b36b 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -928,7 +928,8 @@ export const registerRoutes = async ( permissionService, projectEnvDAL, projectMembershipDAL, - projectDAL + projectDAL, + userDAL }); const accessApprovalRequestService = accessApprovalRequestServiceFactory({