From dbb86171804d120a9d99b8c8c1922e06fda9a0e0 Mon Sep 17 00:00:00 2001 From: Sheen Capadngan Date: Mon, 16 Sep 2024 02:12:24 +0800 Subject: [PATCH] misc: setup prerequisites for terraform project group --- .../server/routes/v2/group-project-router.ts | 95 +++++++++++++-- .../group-project/group-project-dal.ts | 105 +++++++++++++++- .../group-project/group-project-service.ts | 114 +++++++++++++++--- .../group-project/group-project-types.ts | 16 ++- 4 files changed, 299 insertions(+), 31 deletions(-) diff --git a/backend/src/server/routes/v2/group-project-router.ts b/backend/src/server/routes/v2/group-project-router.ts index 6d438c1ff..2418cb0e9 100644 --- a/backend/src/server/routes/v2/group-project-router.ts +++ b/backend/src/server/routes/v2/group-project-router.ts @@ -28,14 +28,36 @@ export const registerGroupProjectRouter = async (server: FastifyZodProvider) => projectSlug: z.string().trim().describe(PROJECTS.ADD_GROUP_TO_PROJECT.projectSlug), groupSlug: z.string().trim().describe(PROJECTS.ADD_GROUP_TO_PROJECT.groupSlug) }), - body: z.object({ - role: z - .string() - .trim() - .min(1) - .default(ProjectMembershipRole.NoAccess) - .describe(PROJECTS.ADD_GROUP_TO_PROJECT.role) - }), + body: z + .object({ + role: z + .string() + .trim() + .min(1) + .default(ProjectMembershipRole.NoAccess) + .describe(PROJECTS.ADD_GROUP_TO_PROJECT.role), + roles: z + .array( + z.union([ + z.object({ + role: z.string(), + isTemporary: z.literal(false).default(false) + }), + z.object({ + role: z.string(), + isTemporary: z.literal(true), + temporaryMode: z.nativeEnum(ProjectUserMembershipTemporaryMode), + temporaryRange: z.string().refine((val) => ms(val) > 0, "Temporary range must be a positive number"), + temporaryAccessStartTime: z.string().datetime() + }) + ]) + ) + .optional() + }) + .refine((data) => data.role || data.roles, { + message: "Either role or roles must be present", + path: ["role", "roles"] + }), response: { 200: z.object({ groupMembership: GroupProjectMembershipsSchema @@ -50,8 +72,9 @@ export const registerGroupProjectRouter = async (server: FastifyZodProvider) => actorOrgId: req.permission.orgId, groupSlug: req.params.groupSlug, projectSlug: req.params.projectSlug, - role: req.body.role + roles: req.body.roles || [{ role: req.body.role }] }); + return { groupMembership }; } }); @@ -198,4 +221,58 @@ export const registerGroupProjectRouter = async (server: FastifyZodProvider) => return { groupMemberships }; } }); + + server.route({ + method: "GET", + url: "/:projectSlug/groups/:groupSlug", + onRequest: verifyAuth([AuthMode.JWT, AuthMode.IDENTITY_ACCESS_TOKEN]), + schema: { + description: "Return project group", + security: [ + { + bearerAuth: [] + } + ], + params: z.object({ + projectSlug: z.string().trim(), + groupSlug: z.string().trim() + }), + response: { + 200: z.object({ + groupMembership: z.object({ + id: z.string(), + groupId: z.string(), + createdAt: z.date(), + updatedAt: z.date(), + roles: z.array( + z.object({ + id: z.string(), + role: z.string(), + customRoleId: z.string().optional().nullable(), + customRoleName: z.string().optional().nullable(), + customRoleSlug: z.string().optional().nullable(), + isTemporary: z.boolean(), + temporaryMode: z.string().optional().nullable(), + temporaryRange: z.string().nullable().optional(), + temporaryAccessStartTime: z.date().nullable().optional(), + temporaryAccessEndTime: z.date().nullable().optional() + }) + ), + group: GroupsSchema.pick({ name: true, id: true, slug: true }) + }) + }) + } + }, + handler: async (req) => { + const groupMembership = await server.services.groupProject.getGroupInProject({ + actor: req.permission.type, + actorId: req.permission.id, + actorAuthMethod: req.permission.authMethod, + actorOrgId: req.permission.orgId, + ...req.params + }); + + return { groupMembership }; + } + }); }; diff --git a/backend/src/services/group-project/group-project-dal.ts b/backend/src/services/group-project/group-project-dal.ts index c74a6b1b2..8370ff881 100644 --- a/backend/src/services/group-project/group-project-dal.ts +++ b/backend/src/services/group-project/group-project-dal.ts @@ -10,6 +10,109 @@ export type TGroupProjectDALFactory = ReturnType; export const groupProjectDALFactory = (db: TDbClient) => { const groupProjectOrm = ormify(db, TableName.GroupProjectMembership); + const findByGroupSlugAndProject = async ( + { groupSlug, projectId }: { groupSlug: string; projectId: string }, + tx?: Knex + ) => { + try { + // this is a workaround so that the order of group roles is preserved as a necessary + // requirement for our terraform provider + + /* Approach: + 1. First we query the group project membership along with the attached roles + 2. Then we have a separate query to fetch the custom role details which we manually augment below + */ + const rawGroupResult = await (tx || db.replicaNode())(TableName.GroupProjectMembership) + .where(`${TableName.GroupProjectMembership}.projectId`, projectId) + .where(`${TableName.Groups}.slug`, "=", groupSlug) + .join(TableName.Groups, `${TableName.GroupProjectMembership}.groupId`, `${TableName.Groups}.id`) + .join( + TableName.GroupProjectMembershipRole, + `${TableName.GroupProjectMembershipRole}.projectMembershipId`, + `${TableName.GroupProjectMembership}.id` + ) + .select( + db.ref("id").withSchema(TableName.GroupProjectMembership), + db.ref("createdAt").withSchema(TableName.GroupProjectMembership), + db.ref("updatedAt").withSchema(TableName.GroupProjectMembership), + db.ref("id").as("groupId").withSchema(TableName.Groups), + db.ref("name").as("groupName").withSchema(TableName.Groups), + db.ref("slug").as("groupSlug").withSchema(TableName.Groups), + db.ref("id").withSchema(TableName.GroupProjectMembership), + db.ref("role").withSchema(TableName.GroupProjectMembershipRole), + db.ref("id").withSchema(TableName.GroupProjectMembershipRole).as("membershipRoleId"), + db.ref("customRoleId").withSchema(TableName.GroupProjectMembershipRole), + db.ref("temporaryMode").withSchema(TableName.GroupProjectMembershipRole), + db.ref("isTemporary").withSchema(TableName.GroupProjectMembershipRole), + db.ref("temporaryRange").withSchema(TableName.GroupProjectMembershipRole), + db.ref("temporaryAccessStartTime").withSchema(TableName.GroupProjectMembershipRole), + db.ref("temporaryAccessEndTime").withSchema(TableName.GroupProjectMembershipRole) + ); + + // IMPORTANT NOTE: we do this separately because this breaks the order of the group project roles + const customRoleDetails = await (tx || db.replicaNode())(TableName.ProjectRoles) + .whereIn("id", rawGroupResult.map((entry) => entry.customRoleId) as string[]) + .select("id", "slug", "name"); + + const groupProjectMembershipRoleToCustomRole: Record = {}; + customRoleDetails.forEach( + // eslint-disable-next-line no-return-assign + (entry) => + (groupProjectMembershipRoleToCustomRole[entry.id] = { + name: entry.name, + slug: entry.slug + }) + ); + + const members = sqlNestRelationships({ + data: rawGroupResult, + parentMapper: ({ groupId, groupName, id, createdAt, updatedAt }) => ({ + id, + groupId, + createdAt, + updatedAt, + group: { + id: groupId, + name: groupName, + slug: groupSlug + } + }), + key: "id", + childrenMapper: [ + { + label: "roles" as const, + key: "membershipRoleId", + mapper: ({ + role, + customRoleId, + membershipRoleId, + temporaryRange, + temporaryMode, + temporaryAccessEndTime, + temporaryAccessStartTime, + isTemporary + }) => ({ + id: membershipRoleId, + role, + customRoleId, + customRoleName: customRoleId && groupProjectMembershipRoleToCustomRole[customRoleId]?.name, + customRoleSlug: customRoleId && groupProjectMembershipRoleToCustomRole[customRoleId]?.slug, + temporaryRange, + temporaryMode, + temporaryAccessEndTime, + temporaryAccessStartTime, + isTemporary + }) + } + ] + }); + + return members[0]; + } catch (error) { + throw new DatabaseError({ error, name: "FindByGroupSlugAndProject" }); + } + }; + const findByProjectId = async (projectId: string, tx?: Knex) => { try { const docs = await (tx || db.replicaNode())(TableName.GroupProjectMembership) @@ -221,5 +324,5 @@ export const groupProjectDALFactory = (db: TDbClient) => { return members; }; - return { ...groupProjectOrm, findByProjectId, findByUserId, findAllProjectGroupMembers }; + return { ...groupProjectOrm, findByProjectId, findByUserId, findAllProjectGroupMembers, findByGroupSlugAndProject }; }; diff --git a/backend/src/services/group-project/group-project-service.ts b/backend/src/services/group-project/group-project-service.ts index 17862dd6f..ebf8025ef 100644 --- a/backend/src/services/group-project/group-project-service.ts +++ b/backend/src/services/group-project/group-project-service.ts @@ -7,7 +7,7 @@ import { ProjectPermissionActions, ProjectPermissionSub } from "@app/ee/services import { isAtLeastAsPrivileged } from "@app/lib/casl"; import { decryptAsymmetric, encryptAsymmetric } from "@app/lib/crypto"; import { infisicalSymmetricDecrypt } from "@app/lib/crypto/encryption"; -import { BadRequestError, ForbiddenRequestError } from "@app/lib/errors"; +import { BadRequestError, ForbiddenRequestError, NotFoundError } from "@app/lib/errors"; import { groupBy } from "@app/lib/fn"; import { TGroupDALFactory } from "../../ee/services/group/group-dal"; @@ -22,12 +22,16 @@ import { TGroupProjectMembershipRoleDALFactory } from "./group-project-membershi import { TCreateProjectGroupDTO, TDeleteProjectGroupDTO, + TGetGroupInProjectDTO, TListProjectGroupDTO, TUpdateProjectGroupDTO } from "./group-project-types"; type TGroupProjectServiceFactoryDep = { - groupProjectDAL: Pick; + groupProjectDAL: Pick< + TGroupProjectDALFactory, + "findOne" | "transaction" | "create" | "delete" | "findByProjectId" | "findByGroupSlugAndProject" + >; groupProjectMembershipRoleDAL: Pick< TGroupProjectMembershipRoleDALFactory, "create" | "transaction" | "insertMany" | "delete" @@ -61,7 +65,7 @@ export const groupProjectServiceFactory = ({ actorOrgId, actorAuthMethod, projectSlug, - role + roles }: TCreateProjectGroupDTO) => { const project = await projectDAL.findOne({ slug: projectSlug @@ -88,16 +92,35 @@ export const groupProjectServiceFactory = ({ message: `Group with slug ${groupSlug} already exists in project with id ${project.id}` }); - const { permission: rolePermission, role: customRole } = await permissionService.getProjectPermissionByRole( - role, - project.id + for await (const { role: requestedRoleChange } of roles) { + const { permission: rolePermission } = await permissionService.getProjectPermissionByRole( + requestedRoleChange, + project.id + ); + + const hasRequiredPriviledges = isAtLeastAsPrivileged(permission, rolePermission); + + if (!hasRequiredPriviledges) { + throw new ForbiddenRequestError({ message: "Failed to assign group to a more privileged role" }); + } + } + + // validate custom roles input + const customInputRoles = roles.filter( + ({ role }) => !Object.values(ProjectMembershipRole).includes(role as ProjectMembershipRole) ); - const hasPrivilege = isAtLeastAsPrivileged(permission, rolePermission); - if (!hasPrivilege) - throw new ForbiddenRequestError({ - message: "Failed to add group to project with more privileged role" - }); - const isCustomRole = Boolean(customRole); + const hasCustomRole = Boolean(customInputRoles.length); + const customRoles = hasCustomRole + ? await projectRoleDAL.find({ + projectId: project.id, + $in: { slug: customInputRoles.map(({ role }) => role) } + }) + : []; + if (customRoles.length !== customInputRoles.length) { + throw new NotFoundError({ message: "Custom role not found" }); + } + + const customRolesGroupBySlug = groupBy(customRoles, ({ slug }) => slug); const projectGroup = await groupProjectDAL.transaction(async (tx) => { const groupProjectMembership = await groupProjectDAL.create( @@ -108,14 +131,31 @@ export const groupProjectServiceFactory = ({ tx ); - await groupProjectMembershipRoleDAL.create( - { + const sanitizedProjectMembershipRoles = roles.map((inputRole) => { + const isCustomRole = Boolean(customRolesGroupBySlug?.[inputRole.role]?.[0]); + if (!inputRole.isTemporary) { + return { + projectMembershipId: groupProjectMembership.id, + role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role, + customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null + }; + } + + // check cron or relative here later for now its just relative + const relativeTimeInMs = ms(inputRole.temporaryRange); + return { projectMembershipId: groupProjectMembership.id, - role: isCustomRole ? ProjectMembershipRole.Custom : role, - customRoleId: customRole?.id - }, - tx - ); + role: isCustomRole ? ProjectMembershipRole.Custom : inputRole.role, + customRoleId: customRolesGroupBySlug[inputRole.role] ? customRolesGroupBySlug[inputRole.role][0].id : null, + isTemporary: true, + temporaryMode: ProjectUserMembershipTemporaryMode.Relative, + temporaryRange: inputRole.temporaryRange, + temporaryAccessStartTime: new Date(inputRole.temporaryAccessStartTime), + temporaryAccessEndTime: new Date(new Date(inputRole.temporaryAccessStartTime).getTime() + relativeTimeInMs) + }; + }); + + await groupProjectMembershipRoleDAL.insertMany(sanitizedProjectMembershipRoles, tx); // share project key with users in group that have not // individually been added to the project and that are not part of @@ -336,10 +376,44 @@ export const groupProjectServiceFactory = ({ return groupMemberships; }; + const getGroupInProject = async ({ + projectSlug, + actor, + actorId, + actorAuthMethod, + actorOrgId, + groupSlug + }: TGetGroupInProjectDTO) => { + const project = await projectDAL.findOne({ + slug: projectSlug + }); + + if (!project) { + throw new NotFoundError({ message: `Failed to find project with slug ${projectSlug}` }); + } + + const { permission } = await permissionService.getProjectPermission( + actor, + actorId, + project.id, + actorAuthMethod, + actorOrgId + ); + ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Groups); + + const groupMembership = await groupProjectDAL.findByGroupSlugAndProject({ + groupSlug, + projectId: project.id + }); + + return groupMembership; + }; + return { addGroupToProject, updateGroupInProject, removeGroupFromProject, - listGroupsInProject + listGroupsInProject, + getGroupInProject }; }; diff --git a/backend/src/services/group-project/group-project-types.ts b/backend/src/services/group-project/group-project-types.ts index c867b75c0..20827ce52 100644 --- a/backend/src/services/group-project/group-project-types.ts +++ b/backend/src/services/group-project/group-project-types.ts @@ -4,7 +4,19 @@ import { ProjectUserMembershipTemporaryMode } from "../project-membership/projec export type TCreateProjectGroupDTO = { groupSlug: string; - role: string; + roles: ( + | { + role: string; + isTemporary?: false; + } + | { + role: string; + isTemporary: true; + temporaryMode: ProjectUserMembershipTemporaryMode.Relative; + temporaryRange: string; + temporaryAccessStartTime: string; + } + )[]; } & TProjectSlugPermission; export type TUpdateProjectGroupDTO = { @@ -29,3 +41,5 @@ export type TDeleteProjectGroupDTO = { } & TProjectSlugPermission; export type TListProjectGroupDTO = TProjectSlugPermission; + +export type TGetGroupInProjectDTO = TProjectSlugPermission & { groupSlug: string };