import { ForbiddenError } from "@casl/ability"; import slugify from "@sindresorhus/slugify"; import { AccessScope, OrgMembershipRole, TRoles } from "@app/db/schemas"; import { TOidcConfigDALFactory } from "@app/ee/services/oidc/oidc-config-dal"; import { BadRequestError, NotFoundError, PermissionBoundaryError, UnauthorizedError } from "@app/lib/errors"; import { alphaNumericNanoId } from "@app/lib/nanoid"; import { TMembershipRoleDALFactory } from "@app/services/membership/membership-role-dal"; import { TMembershipGroupDALFactory } from "@app/services/membership-group/membership-group-dal"; import { TOrgDALFactory } from "@app/services/org/org-dal"; import { TProjectDALFactory } from "@app/services/project/project-dal"; import { TProjectBotDALFactory } from "@app/services/project-bot/project-bot-dal"; import { TProjectKeyDALFactory } from "@app/services/project-key/project-key-dal"; import { TUserDALFactory } from "@app/services/user/user-dal"; import { TLicenseServiceFactory } from "../license/license-service"; import { OrgPermissionGroupActions, OrgPermissionSubjects } from "../permission/org-permission"; import { constructPermissionErrorMessage, validatePrivilegeChangeOperation } from "../permission/permission-fns"; import { TPermissionServiceFactory } from "../permission/permission-service-types"; import { TGroupDALFactory } from "./group-dal"; import { addUsersToGroupByUserIds, removeUsersFromGroupByUserIds } from "./group-fns"; import { TAddUserToGroupDTO, TCreateGroupDTO, TDeleteGroupDTO, TGetGroupByIdDTO, TListGroupUsersDTO, TRemoveUserFromGroupDTO, TUpdateGroupDTO } from "./group-types"; import { TUserGroupMembershipDALFactory } from "./user-group-membership-dal"; type TGroupServiceFactoryDep = { userDAL: Pick; groupDAL: Pick< TGroupDALFactory, "create" | "findOne" | "update" | "delete" | "findAllGroupPossibleMembers" | "findById" | "transaction" >; membershipGroupDAL: Pick; membershipRoleDAL: Pick; orgDAL: Pick; userGroupMembershipDAL: Pick< TUserGroupMembershipDALFactory, "findOne" | "delete" | "filterProjectsByUserMembership" | "transaction" | "insertMany" | "find" >; projectDAL: Pick; projectBotDAL: Pick; projectKeyDAL: Pick; permissionService: Pick< TPermissionServiceFactory, "getOrgPermission" | "getOrgPermissionByRoles" | "invalidateProjectPermissionCache" >; licenseService: Pick; oidcConfigDAL: Pick; }; export type TGroupServiceFactory = ReturnType; export const groupServiceFactory = ({ userDAL, groupDAL, orgDAL, userGroupMembershipDAL, projectDAL, projectBotDAL, projectKeyDAL, permissionService, licenseService, oidcConfigDAL, membershipGroupDAL, membershipRoleDAL }: TGroupServiceFactoryDep) => { const createGroup = async ({ name, slug, role, actor, actorId, actorAuthMethod, actorOrgId }: TCreateGroupDTO) => { if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); const { permission } = await permissionService.getOrgPermission( actor, actorId, actorOrgId, actorAuthMethod, actorOrgId ); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Create, OrgPermissionSubjects.Groups); const plan = await licenseService.getPlan(actorOrgId); if (!plan.groups) throw new BadRequestError({ message: "Failed to create group due to plan restriction. Upgrade plan to create group." }); const [rolePermissionDetails] = await permissionService.getOrgPermissionByRoles([role], actorOrgId); const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(actorOrgId); const isCustomRole = Boolean(rolePermissionDetails?.role); if (role !== OrgMembershipRole.NoAccess) { const permissionBoundary = validatePrivilegeChangeOperation( shouldUseNewPrivilegeSystem, OrgPermissionGroupActions.GrantPrivileges, OrgPermissionSubjects.Groups, permission, rolePermissionDetails.permission ); if (!permissionBoundary.isValid) throw new PermissionBoundaryError({ message: constructPermissionErrorMessage( "Failed to create group", shouldUseNewPrivilegeSystem, OrgPermissionGroupActions.GrantPrivileges, OrgPermissionSubjects.Groups ), details: { missingPermissions: permissionBoundary.missingPermissions } }); } const group = await groupDAL.transaction(async (tx) => { const existingGroup = await groupDAL.findOne({ orgId: actorOrgId, name }, tx); if (existingGroup) { throw new BadRequestError({ message: `Failed to create group with name '${name}'. Group with the same name already exists` }); } const newGroup = await groupDAL.create( { name, slug: slug || slugify(`${name}-${alphaNumericNanoId(4)}`), orgId: actorOrgId, role: isCustomRole ? OrgMembershipRole.Custom : role, roleId: null }, tx ); const membership = await membershipGroupDAL.create( { actorGroupId: newGroup.id, scope: AccessScope.Organization, scopeOrgId: actorOrgId }, tx ); await membershipRoleDAL.create( { membershipId: membership.id, role: isCustomRole ? OrgMembershipRole.Custom : role, customRoleId: rolePermissionDetails?.role?.id }, tx ); return newGroup; }); return group; }; const updateGroup = async ({ id, name, slug, role, actor, actorId, actorAuthMethod, actorOrgId }: TUpdateGroupDTO) => { if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); const { permission } = await permissionService.getOrgPermission( actor, actorId, actorOrgId, actorAuthMethod, actorOrgId ); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Edit, OrgPermissionSubjects.Groups); const plan = await licenseService.getPlan(actorOrgId); if (!plan.groups) throw new BadRequestError({ message: "Failed to update group due to plan restriction Upgrade plan to update group." }); const group = await groupDAL.findOne({ orgId: actorOrgId, id }); if (!group) { throw new NotFoundError({ message: `Failed to find group with ID ${id}` }); } let customRole: TRoles | undefined; if (role) { const [rolePermissionDetails] = await permissionService.getOrgPermissionByRoles([role], group.orgId); const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(actorOrgId); const isCustomRole = Boolean(rolePermissionDetails?.role); const permissionBoundary = validatePrivilegeChangeOperation( shouldUseNewPrivilegeSystem, OrgPermissionGroupActions.GrantPrivileges, OrgPermissionSubjects.Groups, permission, rolePermissionDetails.permission ); if (!permissionBoundary.isValid) throw new PermissionBoundaryError({ message: constructPermissionErrorMessage( "Failed to update group", shouldUseNewPrivilegeSystem, OrgPermissionGroupActions.GrantPrivileges, OrgPermissionSubjects.Groups ), details: { missingPermissions: permissionBoundary.missingPermissions } }); if (isCustomRole) customRole = rolePermissionDetails?.role; } const updatedGroup = await groupDAL.transaction(async (tx) => { if (name) { const existingGroup = await groupDAL.findOne({ orgId: actorOrgId, name }, tx); if (existingGroup && existingGroup.id !== id) { throw new BadRequestError({ message: `Failed to update group with name '${name}'. Group with the same name already exists` }); } } let updated = group; if (name || slug) { [updated] = await groupDAL.update( { id: group.id }, { name, slug: slug ? slugify(slug) : undefined }, tx ); } if (role) { const membership = await membershipGroupDAL.findOne( { scope: AccessScope.Organization, actorGroupId: updated.id, scopeOrgId: updated.orgId }, tx ); await membershipRoleDAL.delete({ membershipId: membership.id }, tx); await membershipRoleDAL.create( { membershipId: membership.id, role: customRole ? OrgMembershipRole.Custom : role, customRoleId: customRole?.id ?? null }, tx ); } return updated; }); return updatedGroup; }; const deleteGroup = async ({ id, actor, actorId, actorAuthMethod, actorOrgId }: TDeleteGroupDTO) => { if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); const { permission } = await permissionService.getOrgPermission( actor, actorId, actorOrgId, actorAuthMethod, actorOrgId ); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Delete, OrgPermissionSubjects.Groups); const plan = await licenseService.getPlan(actorOrgId); if (!plan.groups) throw new BadRequestError({ message: "Failed to delete group due to plan restriction. Upgrade plan to delete group." }); const [group] = await groupDAL.delete({ id, orgId: actorOrgId }); return group; }; const getGroupById = async ({ id, actor, actorId, actorAuthMethod, actorOrgId }: TGetGroupByIdDTO) => { if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); const { permission } = await permissionService.getOrgPermission( actor, actorId, actorOrgId, actorAuthMethod, actorOrgId ); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Read, OrgPermissionSubjects.Groups); const group = await groupDAL.findById(id); if (!group) { throw new NotFoundError({ message: `Cannot find group with ID ${id}` }); } return group; }; const listGroupUsers = async ({ id, offset, limit, username, actor, actorId, actorAuthMethod, actorOrgId, search, filter }: TListGroupUsersDTO) => { if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); const { permission } = await permissionService.getOrgPermission( actor, actorId, actorOrgId, actorAuthMethod, actorOrgId ); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Read, OrgPermissionSubjects.Groups); const group = await groupDAL.findOne({ orgId: actorOrgId, id }); if (!group) throw new NotFoundError({ message: `Failed to find group with ID ${id}` }); const { members, totalCount } = await groupDAL.findAllGroupPossibleMembers({ orgId: group.orgId, groupId: group.id, offset, limit, username, search, filter }); return { users: members, totalCount }; }; const addUserToGroup = async ({ id, username, actor, actorId, actorAuthMethod, actorOrgId }: TAddUserToGroupDTO) => { if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); const { permission } = await permissionService.getOrgPermission( actor, actorId, actorOrgId, actorAuthMethod, actorOrgId ); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Edit, OrgPermissionSubjects.Groups); // check if group with slug exists const group = await groupDAL.findOne({ orgId: actorOrgId, id }); if (!group) throw new NotFoundError({ message: `Failed to find group with ID ${id}` }); const oidcConfig = await oidcConfigDAL.findOne({ orgId: group.orgId, isActive: true }); if (oidcConfig?.manageGroupMemberships) { throw new BadRequestError({ message: "Cannot add user to group: OIDC group membership mapping is enabled - user must be assigned to this group in your OIDC provider." }); } const [rolePermissionDetails] = await permissionService.getOrgPermissionByRoles([group.role], actorOrgId); const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(actorOrgId); // check if user has broader or equal to privileges than group const permissionBoundary = validatePrivilegeChangeOperation( shouldUseNewPrivilegeSystem, OrgPermissionGroupActions.AddMembers, OrgPermissionSubjects.Groups, permission, rolePermissionDetails.permission ); if (!permissionBoundary.isValid) throw new PermissionBoundaryError({ message: constructPermissionErrorMessage( "Failed to add user to more privileged group", shouldUseNewPrivilegeSystem, OrgPermissionGroupActions.AddMembers, OrgPermissionSubjects.Groups ), details: { missingPermissions: permissionBoundary.missingPermissions } }); const usersWithUsername = await userDAL.findUserByUsername(username); // akhilmhdh: case sensitive email resolution const user = usersWithUsername?.length > 1 ? usersWithUsername.find((el) => el.username === username) : usersWithUsername?.[0]; if (!user) throw new NotFoundError({ message: `Failed to find user with username ${username}` }); const users = await addUsersToGroupByUserIds({ group, userIds: [user.id], userDAL, userGroupMembershipDAL, orgDAL, membershipGroupDAL, projectKeyDAL, projectDAL, projectBotDAL }); return users[0]; }; const removeUserFromGroup = async ({ id, username, actor, actorId, actorAuthMethod, actorOrgId }: TRemoveUserFromGroupDTO) => { if (!actorOrgId) throw new UnauthorizedError({ message: "No organization ID provided in request" }); const { permission } = await permissionService.getOrgPermission( actor, actorId, actorOrgId, actorAuthMethod, actorOrgId ); ForbiddenError.from(permission).throwUnlessCan(OrgPermissionGroupActions.Edit, OrgPermissionSubjects.Groups); // check if group with slug exists const group = await groupDAL.findOne({ orgId: actorOrgId, id }); if (!group) throw new NotFoundError({ message: `Failed to find group with ID ${id}` }); const oidcConfig = await oidcConfigDAL.findOne({ orgId: group.orgId, isActive: true }); if (oidcConfig?.manageGroupMemberships) { throw new BadRequestError({ message: "Cannot remove user from group: OIDC group membership mapping is enabled - user must be removed from this group in your OIDC provider." }); } const [rolePermissionDetails] = await permissionService.getOrgPermissionByRoles([group.role], actorOrgId); const { shouldUseNewPrivilegeSystem } = await orgDAL.findById(actorOrgId); // check if user has broader or equal to privileges than group const permissionBoundary = validatePrivilegeChangeOperation( shouldUseNewPrivilegeSystem, OrgPermissionGroupActions.RemoveMembers, OrgPermissionSubjects.Groups, permission, rolePermissionDetails.permission ); if (!permissionBoundary.isValid) throw new PermissionBoundaryError({ message: constructPermissionErrorMessage( "Failed to delete user from more privileged group", shouldUseNewPrivilegeSystem, OrgPermissionGroupActions.RemoveMembers, OrgPermissionSubjects.Groups ), details: { missingPermissions: permissionBoundary.missingPermissions } }); const usersWithUsername = await userDAL.findUserByUsername(username); // akhilmhdh: case sensitive email resolution const user = usersWithUsername?.length > 1 ? usersWithUsername.find((el) => el.username === username) : usersWithUsername?.[0]; if (!user) throw new NotFoundError({ message: `Failed to find user with username ${username}` }); const users = await removeUsersFromGroupByUserIds({ group, userIds: [user.id], userDAL, userGroupMembershipDAL, membershipGroupDAL, projectKeyDAL }); return users[0]; }; return { createGroup, updateGroup, deleteGroup, listGroupUsers, addUserToGroup, removeUserFromGroup, getGroupById }; };