diff --git a/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-service.ts b/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-service.ts index c9ff2c7e0..c5b06cad5 100644 --- a/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-service.ts +++ b/backend/src/ee/services/project-user-additional-privilege/project-user-additional-privilege-service.ts @@ -18,7 +18,7 @@ import { type TProjectUserAdditionalPrivilegeServiceFactoryDep = { projectUserAdditionalPrivilegeDAL: TProjectUserAdditionalPrivilegeDALFactory; - projectMembershipDAL: Pick; + projectMembershipDAL: Pick; permissionService: Pick; }; @@ -53,12 +53,17 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Edit, ProjectPermissionSub.Member); - const existingSlug = await projectUserAdditionalPrivilegeDAL.findOne({ slug, projectMembershipId }); + const existingSlug = await projectUserAdditionalPrivilegeDAL.findOne({ + slug, + projectId: projectMembership.projectId, + userId: projectMembership.userId + }); if (existingSlug) throw new BadRequestError({ message: "Additional privilege of provided slug exist" }); if (!dto.isTemporary) { const additionalPrivilege = await projectUserAdditionalPrivilegeDAL.create({ - projectMembershipId, + userId: projectMembership.userId, + projectId: projectMembership.projectId, slug, permissions: customPermission }); @@ -67,7 +72,8 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({ const relativeTempAllocatedTimeInMs = ms(dto.temporaryRange); const additionalPrivilege = await projectUserAdditionalPrivilegeDAL.create({ - projectMembershipId, + projectId: projectMembership.projectId, + userId: projectMembership.userId, slug, permissions: customPermission, isTemporary: true, @@ -90,7 +96,11 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({ const userPrivilege = await projectUserAdditionalPrivilegeDAL.findById(privilegeId); if (!userPrivilege) throw new BadRequestError({ message: "User additional privilege not found" }); - const projectMembership = await projectMembershipDAL.findById(userPrivilege.projectMembershipId); + const projectMembership = await projectMembershipDAL.findOne({ + userId: userPrivilege.userId, + projectId: userPrivilege.projectId + }); + if (!projectMembership) throw new BadRequestError({ message: "Project membership not found" }); const { permission } = await permissionService.getProjectPermission( @@ -105,7 +115,8 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({ if (dto?.slug) { const existingSlug = await projectUserAdditionalPrivilegeDAL.findOne({ slug: dto.slug, - projectMembershipId: projectMembership.id + userId: projectMembership.id, + projectId: projectMembership.projectId }); if (existingSlug && existingSlug.id !== userPrivilege.id) throw new BadRequestError({ message: "Additional privilege of provided slug exist" }); @@ -138,7 +149,10 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({ const userPrivilege = await projectUserAdditionalPrivilegeDAL.findById(privilegeId); if (!userPrivilege) throw new BadRequestError({ message: "User additional privilege not found" }); - const projectMembership = await projectMembershipDAL.findById(userPrivilege.projectMembershipId); + const projectMembership = await projectMembershipDAL.findOne({ + userId: userPrivilege.userId, + projectId: userPrivilege.projectId + }); if (!projectMembership) throw new BadRequestError({ message: "Project membership not found" }); const { permission } = await permissionService.getProjectPermission( @@ -164,7 +178,10 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({ const userPrivilege = await projectUserAdditionalPrivilegeDAL.findById(privilegeId); if (!userPrivilege) throw new BadRequestError({ message: "User additional privilege not found" }); - const projectMembership = await projectMembershipDAL.findById(userPrivilege.projectMembershipId); + const projectMembership = await projectMembershipDAL.findOne({ + userId: userPrivilege.userId, + projectId: userPrivilege.projectId + }); if (!projectMembership) throw new BadRequestError({ message: "Project membership not found" }); const { permission } = await permissionService.getProjectPermission( @@ -198,7 +215,10 @@ export const projectUserAdditionalPrivilegeServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Member); - const userPrivileges = await projectUserAdditionalPrivilegeDAL.find({ projectMembershipId }); + const userPrivileges = await projectUserAdditionalPrivilegeDAL.find({ + userId: projectMembership.userId, + projectId: projectMembership.projectId + }); return userPrivileges; }; diff --git a/backend/src/server/routes/index.ts b/backend/src/server/routes/index.ts index d49c7aace..10fdcc74c 100644 --- a/backend/src/server/routes/index.ts +++ b/backend/src/server/routes/index.ts @@ -553,6 +553,7 @@ export const registerRoutes = async ( smtpService, projectKeyDAL, projectRoleDAL, + groupProjectDAL, licenseService }); const projectUserAdditionalPrivilegeService = projectUserAdditionalPrivilegeServiceFactory({ diff --git a/backend/src/server/routes/v1/project-router.ts b/backend/src/server/routes/v1/project-router.ts index 7619171da..ac9703f07 100644 --- a/backend/src/server/routes/v1/project-router.ts +++ b/backend/src/server/routes/v1/project-router.ts @@ -59,12 +59,19 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { rateLimit: readLimit }, schema: { + querystring: z.object({ + includeGroupMembers: z + .enum(["true", "false"]) + .default("false") + .transform((value) => value === "true") + }), params: z.object({ workspaceId: z.string().trim() }), response: { 200: z.object({ users: ProjectMembershipsSchema.extend({ + isGroupMember: z.boolean(), user: UsersSchema.pick({ email: true, username: true, @@ -99,9 +106,11 @@ export const registerProjectRouter = async (server: FastifyZodProvider) => { actorId: req.permission.id, actor: req.permission.type, actorAuthMethod: req.permission.authMethod, + includeGroupMembers: req.query.includeGroupMembers, projectId: req.params.workspaceId, actorOrgId: req.permission.orgId }); + return { users }; } }); diff --git a/backend/src/services/group-project/group-project-dal.ts b/backend/src/services/group-project/group-project-dal.ts index a1d276376..fedfcfbc3 100644 --- a/backend/src/services/group-project/group-project-dal.ts +++ b/backend/src/services/group-project/group-project-dal.ts @@ -1,7 +1,7 @@ import { Knex } from "knex"; import { TDbClient } from "@app/db"; -import { TableName } from "@app/db/schemas"; +import { TableName, TUserEncryptionKeys } from "@app/db/schemas"; import { DatabaseError } from "@app/lib/errors"; import { ormify, sqlNestRelationships } from "@app/lib/knex"; @@ -95,5 +95,107 @@ export const groupProjectDALFactory = (db: TDbClient) => { } }; - return { ...groupProjectOrm, findByProjectId }; + // The GroupProjectMembership table has a reference to the project (projectId) AND the group (groupId). + // We need to join the GroupProjectMembership table with the Groups table to get the group name and slug. + // We also need to join the GroupProjectMembershipRole table to get the role of the group in the project. + const findAllProjectGroupMembers = async (projectId: string) => { + const docs = await db(TableName.UserGroupMembership) + // Join the GroupProjectMembership table with the Groups table to get the group name and slug. + .join( + TableName.GroupProjectMembership, + `${TableName.UserGroupMembership}.groupId`, + `${TableName.GroupProjectMembership}.groupId` // this gives us access to the project id in the group membership + ) + + .join(TableName.Project, `${TableName.GroupProjectMembership}.projectId`, `${TableName.Project}.id`) + + .where(`${TableName.GroupProjectMembership}.projectId`, projectId) + + .join(TableName.Users, `${TableName.UserGroupMembership}.userId`, `${TableName.Users}.id`) + .join( + TableName.UserEncryptionKey, + `${TableName.UserEncryptionKey}.userId`, + `${TableName.Users}.id` + ) + .join( + TableName.GroupProjectMembershipRole, + `${TableName.GroupProjectMembershipRole}.projectMembershipId`, + `${TableName.GroupProjectMembership}.id` + ) + .leftJoin( + TableName.ProjectRoles, + `${TableName.GroupProjectMembershipRole}.customRoleId`, + `${TableName.ProjectRoles}.id` + ) + .select( + db.ref("id").withSchema(TableName.GroupProjectMembership), + db.ref("isGhost").withSchema(TableName.Users), + db.ref("username").withSchema(TableName.Users), + db.ref("email").withSchema(TableName.Users), + db.ref("publicKey").withSchema(TableName.UserEncryptionKey), + db.ref("firstName").withSchema(TableName.Users), + db.ref("lastName").withSchema(TableName.Users), + db.ref("id").withSchema(TableName.Users).as("userId"), + db.ref("role").withSchema(TableName.GroupProjectMembershipRole), + db.ref("id").withSchema(TableName.GroupProjectMembershipRole).as("membershipRoleId"), + db.ref("customRoleId").withSchema(TableName.GroupProjectMembershipRole), + db.ref("name").withSchema(TableName.ProjectRoles).as("customRoleName"), + db.ref("slug").withSchema(TableName.ProjectRoles).as("customRoleSlug"), + 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), + db.ref("name").as("projectName").withSchema(TableName.Project) + ) + .where({ isGhost: false }); + + const members = sqlNestRelationships({ + data: docs, + parentMapper: ({ email, firstName, username, lastName, publicKey, isGhost, id, userId, projectName }) => ({ + isGroupMember: true, + id, + userId, + projectId, + project: { + id: projectId, + name: projectName + }, + user: { email, username, firstName, lastName, id: userId, publicKey, isGhost } + }), + key: "id", + childrenMapper: [ + { + label: "roles" as const, + key: "membershipRoleId", + mapper: ({ + role, + customRoleId, + customRoleName, + customRoleSlug, + membershipRoleId, + temporaryRange, + temporaryMode, + temporaryAccessEndTime, + temporaryAccessStartTime, + isTemporary + }) => ({ + id: membershipRoleId, + role, + customRoleId, + customRoleName, + customRoleSlug, + temporaryRange, + temporaryMode, + temporaryAccessEndTime, + temporaryAccessStartTime, + isTemporary + }) + } + ] + }); + return members; + }; + + return { ...groupProjectOrm, findByProjectId, findAllProjectGroupMembers }; }; diff --git a/backend/src/services/project-membership/project-membership-service.ts b/backend/src/services/project-membership/project-membership-service.ts index 8f87e8d55..b5e97e864 100644 --- a/backend/src/services/project-membership/project-membership-service.ts +++ b/backend/src/services/project-membership/project-membership-service.ts @@ -19,6 +19,7 @@ import { groupBy } from "@app/lib/fn"; import { TUserGroupMembershipDALFactory } from "../../ee/services/group/user-group-membership-dal"; import { ActorType } from "../auth/auth-type"; +import { TGroupProjectDALFactory } from "../group-project/group-project-dal"; import { TOrgDALFactory } from "../org/org-dal"; import { TProjectDALFactory } from "../project/project-dal"; import { assignWorkspaceKeysToMembers } from "../project/project-fns"; @@ -54,6 +55,7 @@ type TProjectMembershipServiceFactoryDep = { projectDAL: Pick; projectKeyDAL: Pick; licenseService: Pick; + groupProjectDAL: TGroupProjectDALFactory; }; export type TProjectMembershipServiceFactory = ReturnType; @@ -68,6 +70,7 @@ export const projectMembershipServiceFactory = ({ orgDAL, userDAL, userGroupMembershipDAL, + groupProjectDAL, projectDAL, projectKeyDAL, licenseService @@ -77,6 +80,7 @@ export const projectMembershipServiceFactory = ({ actor, actorOrgId, actorAuthMethod, + includeGroupMembers, projectId }: TGetProjectMembershipDTO) => { const { permission } = await permissionService.getProjectPermission( @@ -88,7 +92,25 @@ export const projectMembershipServiceFactory = ({ ); ForbiddenError.from(permission).throwUnlessCan(ProjectPermissionActions.Read, ProjectPermissionSub.Member); - return projectMembershipDAL.findAllProjectMembers(projectId); + const projectMembers = await projectMembershipDAL.findAllProjectMembers(projectId); + + // projectMembers[0].project + if (includeGroupMembers) { + const groupMembers = await groupProjectDAL.findAllProjectGroupMembers(projectId); + + const allMembers = [ + ...projectMembers.map((m) => ({ ...m, isGroupMember: false })), + ...groupMembers.map((m) => ({ ...m, isGroupMember: true })) + ]; + + // Ensure the userId is unique + const membersIds = new Set(allMembers.map((entity) => entity.user.id)); + const uniqueMembers = allMembers.filter((entity) => membersIds.has(entity.user.id)); + + return uniqueMembers; + } + + return projectMembers.map((m) => ({ ...m, isGroupMember: false })); }; const getProjectMembershipByUsername = async ({ diff --git a/backend/src/services/project-membership/project-membership-types.ts b/backend/src/services/project-membership/project-membership-types.ts index dc3a62016..21efaf691 100644 --- a/backend/src/services/project-membership/project-membership-types.ts +++ b/backend/src/services/project-membership/project-membership-types.ts @@ -1,6 +1,6 @@ import { TProjectPermission } from "@app/lib/types"; -export type TGetProjectMembershipDTO = TProjectPermission; +export type TGetProjectMembershipDTO = { includeGroupMembers?: boolean } & TProjectPermission; export type TLeaveProjectDTO = Omit; export enum ProjectUserMembershipTemporaryMode { Relative = "relative"