diff --git a/apps/meteor/app/authorization/server/functions/hasRole.ts b/apps/meteor/app/authorization/server/functions/hasRole.ts index 2e53229e3e7..00f781725c5 100644 --- a/apps/meteor/app/authorization/server/functions/hasRole.ts +++ b/apps/meteor/app/authorization/server/functions/hasRole.ts @@ -1,6 +1,9 @@ import type { IRole, IUser, IRoom, ISubscription } from '@rocket.chat/core-typings'; import { Roles } from '@rocket.chat/models'; +/** + * @deprecated use `Authorization.hasAnyRole` instead + */ export const hasAnyRoleAsync = async ( userId: IUser['_id'], roleIds: IRole['_id'][], diff --git a/apps/meteor/app/livechat/server/api/lib/departments.ts b/apps/meteor/app/livechat/server/api/lib/departments.ts index 049dbebaf7a..cf430e3ad85 100644 --- a/apps/meteor/app/livechat/server/api/lib/departments.ts +++ b/apps/meteor/app/livechat/server/api/lib/departments.ts @@ -1,10 +1,10 @@ import type { ILivechatDepartment, ILivechatDepartmentAgents } from '@rocket.chat/core-typings'; import { LivechatDepartment, LivechatDepartmentAgents } from '@rocket.chat/models'; +import { applyDepartmentRestrictions } from '@rocket.chat/omni-core'; import type { PaginatedResult } from '@rocket.chat/rest-typings'; import { escapeRegExp } from '@rocket.chat/string-helpers'; -import type { Document, Filter, FindOptions } from 'mongodb'; +import type { Document, Filter, FilterOperators, FindOptions } from 'mongodb'; -import { callbacks } from '../../../../../lib/callbacks'; import { hasPermissionAsync } from '../../../../authorization/server/functions/hasPermission'; type Pagination = { pagination: { offset: number; count: number; sort: FindOptions['sort'] } }; @@ -46,7 +46,7 @@ export async function findDepartments({ showArchived = false, pagination: { offset, count, sort }, }: FindDepartmentParams): Promise> { - let query = { + let query: FilterOperators = { $or: [{ type: { $eq: 'd' } }, { type: { $exists: false } }], ...(!showArchived && { archived: { $ne: !showArchived } }), ...(enabled && { enabled: Boolean(enabled) }), @@ -55,7 +55,7 @@ export async function findDepartments({ }; if (onlyMyDepartments) { - query = await callbacks.run('livechat.applyDepartmentRestrictions', query, { userId }); + query = await applyDepartmentRestrictions(query, userId); } const { cursor, totalCount } = LivechatDepartment.findPaginated(query, { @@ -81,7 +81,7 @@ export async function findArchivedDepartments({ excludeDepartmentId, pagination: { offset, count, sort }, }: FindDepartmentParams): Promise> { - let query = { + let query: FilterOperators = { $or: [{ type: { $eq: 'd' } }, { type: { $exists: false } }], archived: { $eq: true }, ...(text && { name: new RegExp(escapeRegExp(text), 'i') }), @@ -89,7 +89,7 @@ export async function findArchivedDepartments({ }; if (onlyMyDepartments) { - query = await callbacks.run('livechat.applyDepartmentRestrictions', query, { userId }); + query = await applyDepartmentRestrictions(query, userId); } const { cursor, totalCount } = LivechatDepartment.findPaginated(query, { @@ -119,10 +119,10 @@ export async function findDepartmentById({ }> { const canViewLivechatDepartments = includeAgents && (await hasPermissionAsync(userId, 'view-livechat-departments')); - let query = { _id: departmentId }; + let query: FilterOperators = { _id: departmentId }; if (onlyMyDepartments) { - query = await callbacks.run('livechat.applyDepartmentRestrictions', query, { userId }); + query = await applyDepartmentRestrictions(query, userId); } const result = { @@ -146,7 +146,7 @@ export async function findDepartmentsToAutocomplete({ let { conditions = {} } = selector; if (onlyMyDepartments) { - conditions = await callbacks.run('livechat.applyDepartmentRestrictions', conditions, { userId: uid }); + conditions = await applyDepartmentRestrictions(conditions, uid); } const conditionsWithArchived = { archived: { $ne: !showArchived }, ...conditions }; diff --git a/apps/meteor/app/livechat/server/lib/closeRoom.ts b/apps/meteor/app/livechat/server/lib/closeRoom.ts index 717679035ff..66cbe70c948 100644 --- a/apps/meteor/app/livechat/server/lib/closeRoom.ts +++ b/apps/meteor/app/livechat/server/lib/closeRoom.ts @@ -3,6 +3,7 @@ import { Message } from '@rocket.chat/core-services'; import type { ILivechatDepartment, ILivechatInquiryRecord, IOmnichannelRoom, IOmnichannelRoomClosingInfo } from '@rocket.chat/core-typings'; import { isOmnichannelRoom } from '@rocket.chat/core-typings'; import { LivechatDepartment, LivechatInquiry, LivechatRooms, Subscriptions, Users } from '@rocket.chat/models'; +import { applyDepartmentRestrictions } from '@rocket.chat/omni-core'; import type { ClientSession } from 'mongodb'; import type { CloseRoomParams, CloseRoomParamsByUser, CloseRoomParamsByVisitor } from './localTypes'; @@ -283,7 +284,7 @@ export async function closeOpenChats(userId: string, comment?: string) { logger.debug(`Closing open chats for user ${userId}`); const user = await Users.findOneById(userId); - const extraQuery = await callbacks.run('livechat.applyDepartmentRestrictions', {}, { userId }); + const extraQuery = await applyDepartmentRestrictions({}, userId); const openChats = LivechatRooms.findOpenByAgent(userId, extraQuery); const promises: Promise[] = []; await openChats.forEach((room) => { diff --git a/apps/meteor/ee/app/livechat-enterprise/server/api/lib/units.ts b/apps/meteor/ee/app/livechat-enterprise/server/api/lib/units.ts index e95c323766a..5ad7242276d 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/api/lib/units.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/api/lib/units.ts @@ -1,10 +1,9 @@ import type { IOmnichannelBusinessUnit, ILivechatUnitMonitor } from '@rocket.chat/core-typings'; import { LivechatUnitMonitors, LivechatUnit } from '@rocket.chat/models'; +import { getUnitsFromUser } from '@rocket.chat/omni-core-ee'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import type { FindOptions } from 'mongodb'; -import { getUnitsFromUser } from '../../methods/getUnitsFromUserRoles'; - export async function findUnitsOfUser({ text, userId, diff --git a/apps/meteor/ee/app/livechat-enterprise/server/hooks/applyDepartmentRestrictions.ts b/apps/meteor/ee/app/livechat-enterprise/server/hooks/applyDepartmentRestrictions.ts deleted file mode 100644 index 08c8efac94e..00000000000 --- a/apps/meteor/ee/app/livechat-enterprise/server/hooks/applyDepartmentRestrictions.ts +++ /dev/null @@ -1,32 +0,0 @@ -import type { ILivechatDepartment } from '@rocket.chat/core-typings'; -import type { FilterOperators } from 'mongodb'; - -import { callbacks } from '../../../../../lib/callbacks'; -import { cbLogger } from '../lib/logger'; -import { getUnitsFromUser } from '../methods/getUnitsFromUserRoles'; - -export const addQueryRestrictionsToDepartmentsModel = async (originalQuery: FilterOperators = {}, userId: string) => { - const query: FilterOperators = { $and: [originalQuery, { type: { $ne: 'u' } }] }; - - const units = await getUnitsFromUser(userId); - if (Array.isArray(units)) { - query.$and.push({ $or: [{ ancestors: { $in: units } }, { _id: { $in: units } }] }); - } - - cbLogger.debug({ msg: 'Applying department query restrictions', userId, units }); - return query; -}; - -callbacks.add( - 'livechat.applyDepartmentRestrictions', - async (originalQuery: FilterOperators = {}, { userId }: { userId?: string | null } = { userId: null }) => { - if (!userId) { - return originalQuery; - } - - cbLogger.debug('Applying department query restrictions'); - return addQueryRestrictionsToDepartmentsModel(originalQuery, userId); - }, - callbacks.priority.HIGH, - 'livechat-apply-department-restrictions', -); diff --git a/apps/meteor/ee/app/livechat-enterprise/server/hooks/index.ts b/apps/meteor/ee/app/livechat-enterprise/server/hooks/index.ts index 598f682f8cc..f275a6db901 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/hooks/index.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/hooks/index.ts @@ -17,7 +17,6 @@ import './onBusinessHourStart'; import './onAgentAssignmentFailed'; import './afterOnHoldChatResumed'; import './afterReturnRoomAsInquiry'; -import './applyDepartmentRestrictions'; import './afterForwardChatToAgent'; import './applySimultaneousChatsRestrictions'; import './afterInquiryQueued'; diff --git a/apps/meteor/ee/app/livechat-enterprise/server/hooks/manageDepartmentUnit.ts b/apps/meteor/ee/app/livechat-enterprise/server/hooks/manageDepartmentUnit.ts index 8b25ea35db2..86c214e60a5 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/hooks/manageDepartmentUnit.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/hooks/manageDepartmentUnit.ts @@ -1,9 +1,9 @@ import type { ILivechatDepartment } from '@rocket.chat/core-typings'; import { LivechatDepartment, LivechatUnit } from '@rocket.chat/models'; +import { getUnitsFromUser } from '@rocket.chat/omni-core-ee'; import { hasAnyRoleAsync } from '../../../../../app/authorization/server/functions/hasRole'; import { callbacks } from '../../../../../lib/callbacks'; -import { getUnitsFromUser } from '../methods/getUnitsFromUserRoles'; export const manageDepartmentUnit = async ({ userId, departmentId, unitId }: { userId: string; departmentId: string; unitId: string }) => { const accessibleUnits = await getUnitsFromUser(userId); diff --git a/apps/meteor/ee/app/livechat-enterprise/server/lib/Department.ts b/apps/meteor/ee/app/livechat-enterprise/server/lib/Department.ts index f1ab72fa1e3..764a795dd0c 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/lib/Department.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/lib/Department.ts @@ -1,10 +1,9 @@ import type { ILivechatDepartment } from '@rocket.chat/core-typings'; import { LivechatDepartment } from '@rocket.chat/models'; +import { applyDepartmentRestrictions } from '@rocket.chat/omni-core'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import type { Filter } from 'mongodb'; -import { callbacks } from '../../../../../lib/callbacks'; - export const findAllDepartmentsAvailable = async ( uid: string, unitId: string, @@ -22,7 +21,7 @@ export const findAllDepartmentsAvailable = async ( }; if (onlyMyDepartments) { - query = await callbacks.run('livechat.applyDepartmentRestrictions', query, { userId: uid }); + query = await applyDepartmentRestrictions(query, uid); } const { cursor, totalCount } = LivechatDepartment.findPaginated(query, { limit: count, offset, sort: { name: 1 } }); diff --git a/apps/meteor/ee/app/livechat-enterprise/server/lib/LivechatEnterprise.ts b/apps/meteor/ee/app/livechat-enterprise/server/lib/LivechatEnterprise.ts index 8f8189da814..c3a81036763 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/lib/LivechatEnterprise.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/lib/LivechatEnterprise.ts @@ -1,5 +1,6 @@ import type { IOmnichannelBusinessUnit, IOmnichannelServiceLevelAgreements, IUser, ILivechatTag } from '@rocket.chat/core-typings'; import { Users, OmnichannelServiceLevelAgreements, LivechatTag, LivechatUnitMonitors, LivechatUnit } from '@rocket.chat/models'; +import { getUnitsFromUser } from '@rocket.chat/omni-core-ee'; import { Match, check } from 'meteor/check'; import { Meteor } from 'meteor/meteor'; @@ -8,7 +9,6 @@ import { removeSLAFromRooms } from './SlaHelper'; import { callbacks } from '../../../../../lib/callbacks'; import { addUserRolesAsync } from '../../../../../server/lib/roles/addUserRoles'; import { removeUserFromRolesAsync } from '../../../../../server/lib/roles/removeUserFromRoles'; -import { getUnitsFromUser } from '../methods/getUnitsFromUserRoles'; export const LivechatEnterprise = { async addMonitor(username: string) { diff --git a/apps/meteor/ee/app/livechat-enterprise/server/lib/restrictQuery.ts b/apps/meteor/ee/app/livechat-enterprise/server/lib/restrictQuery.ts index 2fb08bd2571..77ff6cb53f5 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/lib/restrictQuery.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/lib/restrictQuery.ts @@ -1,9 +1,9 @@ import type { IOmnichannelRoom } from '@rocket.chat/core-typings'; import { LivechatDepartment } from '@rocket.chat/models'; +import { getUnitsFromUser } from '@rocket.chat/omni-core-ee'; import type { FilterOperators } from 'mongodb'; import { cbLogger } from './logger'; -import { getUnitsFromUser } from '../methods/getUnitsFromUserRoles'; export const restrictQuery = async ({ originalQuery = {}, diff --git a/apps/meteor/ee/app/livechat-enterprise/server/lib/unit.ts b/apps/meteor/ee/app/livechat-enterprise/server/lib/unit.ts index 252aefaa2cb..8ac75cbc0d0 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/lib/unit.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/lib/unit.ts @@ -1,8 +1,8 @@ import { LivechatUnit } from '@rocket.chat/models'; +import { getUnitsFromUser } from '@rocket.chat/omni-core-ee'; import type { CheckUnitsFromUser } from '../../../../../app/livechat/server/api/lib/livechat'; import { checkUnitsFromUser } from '../../../../../app/livechat/server/api/lib/livechat'; -import { getUnitsFromUser } from '../methods/getUnitsFromUserRoles'; checkUnitsFromUser.patch(async (_next, { businessUnit, userId }: CheckUnitsFromUser) => { if (!businessUnit) { diff --git a/apps/meteor/ee/app/livechat-enterprise/server/methods/getUnitsFromUserRoles.ts b/apps/meteor/ee/app/livechat-enterprise/server/methods/getUnitsFromUserRoles.ts index f9ac4a27cc8..7161d14cb79 100644 --- a/apps/meteor/ee/app/livechat-enterprise/server/methods/getUnitsFromUserRoles.ts +++ b/apps/meteor/ee/app/livechat-enterprise/server/methods/getUnitsFromUserRoles.ts @@ -1,54 +1,8 @@ import type { ServerMethods } from '@rocket.chat/ddp-client'; -import { LivechatUnit, LivechatDepartmentAgents } from '@rocket.chat/models'; -import mem from 'mem'; +import { getUnitsFromUser } from '@rocket.chat/omni-core-ee'; import { Meteor } from 'meteor/meteor'; -import { hasAnyRoleAsync } from '../../../../../app/authorization/server/functions/hasRole'; import { methodDeprecationLogger } from '../../../../../app/lib/server/lib/deprecationWarningLogger'; -import { logger } from '../lib/logger'; - -async function getUnitsFromUserRoles(user: string): Promise { - return LivechatUnit.findByMonitorId(user); -} - -async function getDepartmentsFromUserRoles(user: string): Promise { - return (await LivechatDepartmentAgents.findByAgentId(user).toArray()).map((department) => department.departmentId); -} - -const memoizedGetUnitFromUserRoles = mem(getUnitsFromUserRoles, { maxAge: process.env.TEST_MODE ? 1 : 10000 }); -const memoizedGetDepartmentsFromUserRoles = mem(getDepartmentsFromUserRoles, { maxAge: process.env.TEST_MODE ? 1 : 10000 }); - -async function hasUnits(): Promise { - // @ts-expect-error - this prop is injected dynamically on ee license - return (await LivechatUnit.countUnits({ type: 'u' })) > 0; -} - -// Units should't change really often, so we can cache the result -const memoizedHasUnits = mem(hasUnits, { maxAge: process.env.TEST_MODE ? 1 : 10000 }); - -export const getUnitsFromUser = async (userId?: string): Promise => { - if (!userId) { - return; - } - - if (!(await memoizedHasUnits())) { - return; - } - - // TODO: we can combine these 2 calls into one single query - if (await hasAnyRoleAsync(userId, ['admin', 'livechat-manager'])) { - return; - } - - if (!(await hasAnyRoleAsync(userId, ['livechat-monitor', 'livechat-agent']))) { - return; - } - - const unitsAndDepartments = [...(await memoizedGetUnitFromUserRoles(userId)), ...(await memoizedGetDepartmentsFromUserRoles(userId))]; - logger.debug({ msg: 'Calculating units for monitor', user: userId, unitsAndDepartments }); - - return unitsAndDepartments; -}; declare module '@rocket.chat/ddp-client' { // eslint-disable-next-line @typescript-eslint/naming-convention diff --git a/apps/meteor/ee/tests/unit/apps/livechat-enterprise/server/hooks/manageDepartmentUnit.spec.ts b/apps/meteor/ee/tests/unit/apps/livechat-enterprise/server/hooks/manageDepartmentUnit.spec.ts index 8fbf0dcf97a..38ed2e92c12 100644 --- a/apps/meteor/ee/tests/unit/apps/livechat-enterprise/server/hooks/manageDepartmentUnit.spec.ts +++ b/apps/meteor/ee/tests/unit/apps/livechat-enterprise/server/hooks/manageDepartmentUnit.spec.ts @@ -21,7 +21,7 @@ const getUnitsFromUserStub = sinon.stub(); const { manageDepartmentUnit } = proxyquire .noCallThru() .load('../../../../../../app/livechat-enterprise/server/hooks/manageDepartmentUnit.ts', { - '../methods/getUnitsFromUserRoles': { + '@rocket.chat/omni-core-ee': { getUnitsFromUser: getUnitsFromUserStub, }, '../../../../../app/authorization/server/functions/hasRole': { diff --git a/apps/meteor/lib/callbacks.ts b/apps/meteor/lib/callbacks.ts index 1ec2e8a3722..a5a0a626220 100644 --- a/apps/meteor/lib/callbacks.ts +++ b/apps/meteor/lib/callbacks.ts @@ -151,10 +151,6 @@ type ChainedCallbackSignatures = { agentsId: ILivechatAgent['_id'][]; }; 'livechat.applySimultaneousChatRestrictions': (_: undefined, params: { departmentId?: ILivechatDepartmentRecord['_id'] }) => undefined; - 'livechat.applyDepartmentRestrictions': ( - query: FilterOperators, - params: { userId: IUser['_id'] }, - ) => FilterOperators; 'livechat.applyRoomRestrictions': ( query: FilterOperators, params?: { diff --git a/apps/meteor/server/services/authorization/service.ts b/apps/meteor/server/services/authorization/service.ts index 958dce13e1f..50ce3d320f3 100644 --- a/apps/meteor/server/services/authorization/service.ts +++ b/apps/meteor/server/services/authorization/service.ts @@ -189,4 +189,16 @@ export class Authorization extends ServiceClass implements IAuthorization { return true; } + + async hasAnyRole(userId: IUser['_id'], roleIds: IRole['_id'][], scope?: IRoom['_id']): Promise { + if (!Array.isArray(roleIds)) { + throw new Error('error-invalid-arguments'); + } + + if (!userId) { + return false; + } + + return Roles.isUserInRoles(userId, roleIds, scope); + } } diff --git a/ee/packages/omni-core-ee/package.json b/ee/packages/omni-core-ee/package.json index e93eb3e7eff..66418628e83 100644 --- a/ee/packages/omni-core-ee/package.json +++ b/ee/packages/omni-core-ee/package.json @@ -25,7 +25,14 @@ "/dist" ], "dependencies": { + "@rocket.chat/core-services": "workspace:^", + "@rocket.chat/logger": "workspace:^", "@rocket.chat/models": "workspace:^", - "@rocket.chat/omni-core": "workspace:^" + "@rocket.chat/omni-core": "workspace:^", + "mem": "^8.1.1", + "mongodb": "6.10.0" + }, + "volta": { + "extends": "../../../package.json" } } diff --git a/ee/packages/omni-core-ee/src/index.ts b/ee/packages/omni-core-ee/src/index.ts index e7fea8ef3b0..34ebbaefcc6 100644 --- a/ee/packages/omni-core-ee/src/index.ts +++ b/ee/packages/omni-core-ee/src/index.ts @@ -1,5 +1,9 @@ import { isDepartmentCreationAvailablePatch } from './isDepartmentCreationAvailable'; +import { applyDepartmentRestrictionsPatch } from './patches/applyDepartmentRestrictions'; export function patchOmniCore(): void { isDepartmentCreationAvailablePatch(); + applyDepartmentRestrictionsPatch(); } + +export * from './units/getUnitsFromUser'; diff --git a/ee/packages/omni-core-ee/src/patches/applyDepartmentRestrictions.ts b/ee/packages/omni-core-ee/src/patches/applyDepartmentRestrictions.ts new file mode 100644 index 00000000000..36a28d5df50 --- /dev/null +++ b/ee/packages/omni-core-ee/src/patches/applyDepartmentRestrictions.ts @@ -0,0 +1,24 @@ +import type { ILivechatDepartment } from '@rocket.chat/core-typings'; +import { License } from '@rocket.chat/license'; +import { applyDepartmentRestrictions } from '@rocket.chat/omni-core'; +import type { FilterOperators } from 'mongodb'; + +import { addQueryRestrictionsToDepartmentsModel } from '../units/addRoleBasedRestrictionsToDepartment'; +import { hooksLogger } from '../utils/logger'; + +export const applyDepartmentRestrictionsPatch = () => { + applyDepartmentRestrictions.patch( + async ( + prev: (query: FilterOperators, userId: string) => FilterOperators, + query: FilterOperators = {}, + userId: string, + ) => { + if (!License.hasModule('livechat-enterprise')) { + return prev(query, userId); + } + + hooksLogger.debug('Applying department query restrictions'); + return addQueryRestrictionsToDepartmentsModel(query, userId); + }, + ); +}; diff --git a/ee/packages/omni-core-ee/src/units/addRoleBasedRestrictionsToDepartment.spec.ts b/ee/packages/omni-core-ee/src/units/addRoleBasedRestrictionsToDepartment.spec.ts new file mode 100644 index 00000000000..6c1042e947c --- /dev/null +++ b/ee/packages/omni-core-ee/src/units/addRoleBasedRestrictionsToDepartment.spec.ts @@ -0,0 +1,297 @@ +import type { ILivechatDepartment } from '@rocket.chat/core-typings'; +import type { FilterOperators } from 'mongodb'; + +import { addQueryRestrictionsToDepartmentsModel } from './addRoleBasedRestrictionsToDepartment'; +import { getUnitsFromUser } from './getUnitsFromUser'; +import { defaultLogger } from '../utils/logger'; + +// Mock dependencies +jest.mock('./getUnitsFromUser'); +jest.mock('../utils/logger'); + +const mockedGetUnitsFromUser = jest.mocked(getUnitsFromUser); +const mockedLogger = jest.mocked(defaultLogger); + +describe('addQueryRestrictionsToDepartmentsModel', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + describe('when getUnitsFromUser returns an array of units', () => { + it('should add unit restrictions to the query', async () => { + // Arrange + const userId = 'user123'; + const units = ['unit1', 'unit2', 'unit3']; + const originalQuery: FilterOperators = { name: 'test-department' }; + + mockedGetUnitsFromUser.mockResolvedValue(units); + + // Act + const result = await addQueryRestrictionsToDepartmentsModel(originalQuery, userId); + + // Assert + expect(result).toEqual({ + $and: [ + { name: 'test-department' }, + { type: { $ne: 'u' } }, + { + $or: [{ ancestors: { $in: ['unit1', 'unit2', 'unit3'] } }, { _id: { $in: ['unit1', 'unit2', 'unit3'] } }], + }, + ], + }); + + expect(mockedGetUnitsFromUser).toHaveBeenCalledWith(userId); + expect(mockedLogger.debug).toHaveBeenCalledWith({ + msg: 'Applying department query restrictions', + userId, + units, + }); + }); + + it('should work with empty original query', async () => { + // Arrange + const userId = 'user456'; + const units = ['unit4', 'unit5']; + + mockedGetUnitsFromUser.mockResolvedValue(units); + + // Act + const result = await addQueryRestrictionsToDepartmentsModel({}, userId); + + // Assert + expect(result).toEqual({ + $and: [ + {}, + { type: { $ne: 'u' } }, + { + $or: [{ ancestors: { $in: ['unit4', 'unit5'] } }, { _id: { $in: ['unit4', 'unit5'] } }], + }, + ], + }); + }); + + it('should work with undefined original query', async () => { + // Arrange + const userId = 'user789'; + const units = ['unit6']; + + mockedGetUnitsFromUser.mockResolvedValue(units); + + // Act + const result = await addQueryRestrictionsToDepartmentsModel(undefined, userId); + + // Assert + expect(result).toEqual({ + $and: [ + {}, + { type: { $ne: 'u' } }, + { + $or: [{ ancestors: { $in: ['unit6'] } }, { _id: { $in: ['unit6'] } }], + }, + ], + }); + }); + + it('should handle single unit in array', async () => { + // Arrange + const userId = 'user101'; + const units = ['single-unit']; + const originalQuery: FilterOperators = { enabled: true }; + + mockedGetUnitsFromUser.mockResolvedValue(units); + + // Act + const result = await addQueryRestrictionsToDepartmentsModel(originalQuery, userId); + + // Assert + expect(result).toEqual({ + $and: [ + { enabled: true }, + { type: { $ne: 'u' } }, + { + $or: [{ ancestors: { $in: ['single-unit'] } }, { _id: { $in: ['single-unit'] } }], + }, + ], + }); + }); + }); + + describe('when getUnitsFromUser returns non-array values', () => { + it('should not add unit restrictions when getUnitsFromUser returns null', async () => { + // Arrange + const userId = 'user202'; + const originalQuery: FilterOperators = { name: 'test' }; + + mockedGetUnitsFromUser.mockResolvedValue(undefined); + + // Act + const result = await addQueryRestrictionsToDepartmentsModel(originalQuery, userId); + + // Assert + expect(result).toEqual({ + $and: [{ name: 'test' }, { type: { $ne: 'u' } }], + }); + + expect(mockedLogger.debug).toHaveBeenCalledWith({ + msg: 'Applying department query restrictions', + userId, + units: undefined, + }); + }); + + it('should not add unit restrictions when getUnitsFromUser returns undefined', async () => { + // Arrange + const userId = 'user303'; + const originalQuery: FilterOperators = { active: true }; + + mockedGetUnitsFromUser.mockResolvedValue(undefined); + + // Act + const result = await addQueryRestrictionsToDepartmentsModel(originalQuery, userId); + + // Assert + expect(result).toEqual({ + $and: [{ active: true }, { type: { $ne: 'u' } }], + }); + + expect(mockedLogger.debug).toHaveBeenCalledWith({ + msg: 'Applying department query restrictions', + userId, + units: undefined, + }); + }); + + it('should not add unit restrictions when getUnitsFromUser returns a string', async () => { + // Arrange + const userId = 'user404'; + const originalQuery: FilterOperators = {}; + + mockedGetUnitsFromUser.mockResolvedValue('not-an-array' as any); + + // Act + const result = await addQueryRestrictionsToDepartmentsModel(originalQuery, userId); + + // Assert + expect(result).toEqual({ + $and: [{}, { type: { $ne: 'u' } }], + }); + + expect(mockedLogger.debug).toHaveBeenCalledWith({ + msg: 'Applying department query restrictions', + userId, + units: 'not-an-array', + }); + }); + + it('should not add unit restrictions when getUnitsFromUser returns empty array', async () => { + // Arrange + const userId = 'user505'; + const originalQuery: FilterOperators = { department: 'support' }; + + mockedGetUnitsFromUser.mockResolvedValue([]); + + // Act + const result = await addQueryRestrictionsToDepartmentsModel(originalQuery, userId); + + // Assert + expect(result).toEqual({ + $and: [ + { department: 'support' }, + { type: { $ne: 'u' } }, + { + $or: [{ ancestors: { $in: [] } }, { _id: { $in: [] } }], + }, + ], + }); + + expect(mockedLogger.debug).toHaveBeenCalledWith({ + msg: 'Applying department query restrictions', + userId, + units: [], + }); + }); + }); + + describe('error handling', () => { + it('should propagate errors from getUnitsFromUser', async () => { + // Arrange + const userId = 'user606'; + const error = new Error('Database connection failed'); + + mockedGetUnitsFromUser.mockRejectedValue(error); + + // Act & Assert + await expect(addQueryRestrictionsToDepartmentsModel({}, userId)).rejects.toThrow('Database connection failed'); + + expect(mockedGetUnitsFromUser).toHaveBeenCalledWith(userId); + expect(mockedLogger.debug).not.toHaveBeenCalled(); + }); + }); + + describe('complex query scenarios', () => { + it('should handle complex original query with nested conditions', async () => { + // Arrange + const userId = 'user707'; + const units = ['unit-a', 'unit-b']; + const originalQuery: FilterOperators = { + $or: [{ name: { $regex: 'support' } }, { enabled: true }], + createdAt: { $gte: new Date('2023-01-01') }, + }; + + mockedGetUnitsFromUser.mockResolvedValue(units); + + // Act + const result = await addQueryRestrictionsToDepartmentsModel(originalQuery, userId); + + // Assert + expect(result).toEqual({ + $and: [ + { + $or: [{ name: { $regex: 'support' } }, { enabled: true }], + createdAt: { $gte: new Date('2023-01-01') }, + }, + { type: { $ne: 'u' } }, + { + $or: [{ ancestors: { $in: ['unit-a', 'unit-b'] } }, { _id: { $in: ['unit-a', 'unit-b'] } }], + }, + ], + }); + }); + + it('should always exclude departments with type "u"', async () => { + // Arrange + const userId = 'user808'; + const units = ['unit-x']; + const originalQuery: FilterOperators = { type: 'normal' }; + + mockedGetUnitsFromUser.mockResolvedValue(units); + + // Act + const result = await addQueryRestrictionsToDepartmentsModel(originalQuery, userId); + + // Assert + expect(result.$and).toContainEqual({ type: { $ne: 'u' } }); + }); + }); + + describe('logging', () => { + it('should always call debug logger with correct parameters', async () => { + // Arrange + const userId = 'user909'; + const units = ['unit-test']; + + mockedGetUnitsFromUser.mockResolvedValue(units); + + // Act + await addQueryRestrictionsToDepartmentsModel({}, userId); + + // Assert + expect(mockedLogger.debug).toHaveBeenCalledTimes(1); + expect(mockedLogger.debug).toHaveBeenCalledWith({ + msg: 'Applying department query restrictions', + userId: 'user909', + units: ['unit-test'], + }); + }); + }); +}); diff --git a/ee/packages/omni-core-ee/src/units/addRoleBasedRestrictionsToDepartment.ts b/ee/packages/omni-core-ee/src/units/addRoleBasedRestrictionsToDepartment.ts new file mode 100644 index 00000000000..691f0807284 --- /dev/null +++ b/ee/packages/omni-core-ee/src/units/addRoleBasedRestrictionsToDepartment.ts @@ -0,0 +1,17 @@ +import type { ILivechatDepartment } from '@rocket.chat/core-typings'; +import type { FilterOperators } from 'mongodb'; + +import { getUnitsFromUser } from './getUnitsFromUser'; +import { defaultLogger } from '../utils/logger'; + +export const addQueryRestrictionsToDepartmentsModel = async (originalQuery: FilterOperators = {}, userId: string) => { + const query: FilterOperators = { $and: [originalQuery, { type: { $ne: 'u' } }] }; + + const units = await getUnitsFromUser(userId); + if (Array.isArray(units)) { + query.$and.push({ $or: [{ ancestors: { $in: units } }, { _id: { $in: units } }] }); + } + + defaultLogger.debug({ msg: 'Applying department query restrictions', userId, units }); + return query; +}; diff --git a/ee/packages/omni-core-ee/src/units/getUnitsFromUser.spec.ts b/ee/packages/omni-core-ee/src/units/getUnitsFromUser.spec.ts new file mode 100644 index 00000000000..f2543b22671 --- /dev/null +++ b/ee/packages/omni-core-ee/src/units/getUnitsFromUser.spec.ts @@ -0,0 +1,230 @@ +import { Authorization } from '@rocket.chat/core-services'; +import { LivechatUnit, LivechatDepartmentAgents } from '@rocket.chat/models'; + +import { getUnitsFromUser } from './getUnitsFromUser'; +import { defaultLogger } from '../utils/logger'; + +// Mock the dependencies +jest.mock('@rocket.chat/core-services', () => ({ + Authorization: { + hasAnyRole: jest.fn(), + }, +})); +jest.mock('@rocket.chat/models', () => ({ + LivechatUnit: { + findByMonitorId: jest.fn(), + countUnits: jest.fn(), + }, + LivechatDepartmentAgents: { + findByAgentId: jest.fn(), + }, +})); + +jest.mock('mem', () => (fn: any) => fn); +jest.mock('../utils/logger'); + +const mockAuthorization = Authorization as jest.Mocked; +const mockLivechatUnit = LivechatUnit as jest.Mocked; +const mockLivechatDepartmentAgents = LivechatDepartmentAgents as jest.Mocked; +const mockLogger = defaultLogger as jest.Mocked; +describe('getUnitsFromUser', () => { + beforeEach(() => { + jest.resetAllMocks(); + + // Setup default mock implementations + mockLivechatUnit.findByMonitorId.mockResolvedValue(['unit1', 'unit2']); + mockLivechatDepartmentAgents.findByAgentId.mockReturnValue({ + toArray: jest.fn().mockResolvedValue([{ departmentId: 'dept1' }, { departmentId: 'dept2' }]), + } as any); + mockLivechatUnit.countUnits.mockResolvedValue(5); + + mockAuthorization.hasAnyRole.mockResolvedValue(false); + mockLogger.debug.mockImplementation(() => { + // + }); + }); + + describe('when userId is not provided', () => { + it('should return undefined for null userId', async () => { + const result = await getUnitsFromUser(null as any); + expect(result).toBeUndefined(); + }); + + it('should return undefined for undefined userId', async () => { + const result = await getUnitsFromUser(undefined); + expect(result).toBeUndefined(); + }); + + it('should return undefined for empty string userId', async () => { + const result = await getUnitsFromUser(''); + expect(result).toBeUndefined(); + }); + }); + + describe('when there are no units in the system', () => { + it('should return undefined', async () => { + mockLivechatUnit.countUnits.mockResolvedValue(0); + + const result = await getUnitsFromUser('user123'); + + expect(result).toBeUndefined(); + expect(mockLivechatUnit.countUnits).toHaveBeenCalled(); + }); + }); + + describe('when user has admin role', () => { + it('should return undefined for admin users', async () => { + mockAuthorization.hasAnyRole + .mockResolvedValueOnce(true) // admin/livechat-manager check + .mockResolvedValueOnce(false); // livechat-monitor/agent check + + const result = await getUnitsFromUser('admin-user'); + + expect(mockLivechatUnit.countUnits).toHaveBeenCalled(); + expect(result).toBeUndefined(); + expect(mockAuthorization.hasAnyRole).toHaveBeenCalledWith('admin-user', ['admin', 'livechat-manager']); + }); + }); + + describe('when user does not have required roles', () => { + it('should return undefined for users without livechat-monitor or livechat-agent roles', async () => { + mockAuthorization.hasAnyRole + .mockResolvedValueOnce(false) // admin/livechat-manager check + .mockResolvedValueOnce(false); // livechat-monitor/agent check + + const result = await getUnitsFromUser('regular-user'); + + expect(result).toBeUndefined(); + expect(mockAuthorization.hasAnyRole).toHaveBeenCalledWith('regular-user', ['admin', 'livechat-manager']); + expect(mockAuthorization.hasAnyRole).toHaveBeenCalledWith('regular-user', ['livechat-monitor', 'livechat-agent']); + }); + }); + + describe('when user has livechat-manager role', () => { + it('should return undefined for livechat-manager users', async () => { + mockAuthorization.hasAnyRole + .mockResolvedValueOnce(true) // admin/livechat-manager check + .mockResolvedValueOnce(false); // livechat-monitor/agent check + + const result = await getUnitsFromUser('manager-user'); + + expect(result).toBeUndefined(); + expect(mockAuthorization.hasAnyRole).toHaveBeenCalledWith('manager-user', ['admin', 'livechat-manager']); + }); + }); + + describe('when user has livechat-monitor role', () => { + it('should return combined units and departments', async () => { + const userId = 'monitor-user'; + mockAuthorization.hasAnyRole + .mockResolvedValueOnce(false) // admin/livechat-manager check + .mockResolvedValueOnce(true); // livechat-monitor/agent check + + mockLivechatUnit.findByMonitorId.mockResolvedValue(['unit1', 'unit2']); + mockLivechatDepartmentAgents.findByAgentId.mockReturnValue({ + toArray: jest.fn().mockResolvedValue([{ departmentId: 'dept1' }, { departmentId: 'dept2' }]), + } as any); + + const result = await getUnitsFromUser(userId); + + expect(result).toEqual(['unit1', 'unit2', 'dept1', 'dept2']); + expect(mockLivechatUnit.findByMonitorId).toHaveBeenCalledWith(userId); + expect(mockLivechatDepartmentAgents.findByAgentId).toHaveBeenCalledWith(userId); + expect(mockLogger.debug).toHaveBeenCalledWith({ + msg: 'Calculating units for monitor', + user: userId, + unitsAndDepartments: ['unit1', 'unit2', 'dept1', 'dept2'], + }); + }); + }); + + describe('when user has livechat-agent role', () => { + it('should return combined units and departments', async () => { + const userId = 'agent-user'; + mockAuthorization.hasAnyRole + .mockResolvedValueOnce(false) // admin/livechat-manager check + .mockResolvedValueOnce(true); // livechat-monitor/agent check + + mockLivechatUnit.findByMonitorId.mockResolvedValue(['unit3']); + mockLivechatDepartmentAgents.findByAgentId.mockReturnValue({ + toArray: jest.fn().mockResolvedValue([{ departmentId: 'dept3' }, { departmentId: 'dept4' }]), + } as any); + + const result = await getUnitsFromUser(userId); + + expect(result).toEqual(['unit3', 'dept3', 'dept4']); + expect(mockLivechatUnit.findByMonitorId).toHaveBeenCalledWith(userId); + expect(mockLivechatDepartmentAgents.findByAgentId).toHaveBeenCalledWith(userId); + }); + }); + + describe('edge cases', () => { + it('should handle empty units and departments arrays', async () => { + const userId = 'empty-user'; + mockAuthorization.hasAnyRole + .mockResolvedValueOnce(false) // admin/livechat-manager check + .mockResolvedValueOnce(true); // livechat-monitor/agent check + + mockLivechatUnit.findByMonitorId.mockResolvedValue([]); + mockLivechatDepartmentAgents.findByAgentId.mockReturnValue({ + toArray: jest.fn().mockResolvedValue([]), + } as any); + + const result = await getUnitsFromUser(userId); + + expect(result).toEqual([]); + }); + + it('should handle when only units are returned', async () => { + const userId = 'units-only-user'; + mockAuthorization.hasAnyRole + .mockResolvedValueOnce(false) // admin/livechat-manager check + .mockResolvedValueOnce(true); // livechat-monitor/agent check + + mockLivechatUnit.findByMonitorId.mockResolvedValue(['unit1', 'unit2']); + mockLivechatDepartmentAgents.findByAgentId.mockReturnValue({ + toArray: jest.fn().mockResolvedValue([]), + } as any); + + const result = await getUnitsFromUser(userId); + + expect(result).toEqual(['unit1', 'unit2']); + }); + + it('should handle when only departments are returned', async () => { + const userId = 'departments-only-user'; + mockAuthorization.hasAnyRole + .mockResolvedValueOnce(false) // admin/livechat-manager check + .mockResolvedValueOnce(true); // livechat-monitor/agent check + + mockLivechatUnit.findByMonitorId.mockResolvedValue([]); + mockLivechatDepartmentAgents.findByAgentId.mockReturnValue({ + toArray: jest.fn().mockResolvedValue([{ departmentId: 'dept1' }, { departmentId: 'dept2' }]), + } as any); + + const result = await getUnitsFromUser(userId); + + expect(result).toEqual(['dept1', 'dept2']); + }); + }); + + describe('memoization', () => { + it('should work correctly with memoization disabled in tests', async () => { + const userId = 'test-user'; + mockAuthorization.hasAnyRole + .mockResolvedValueOnce(false) // admin/livechat-manager check + .mockResolvedValueOnce(true); // livechat-monitor/agent check + + mockLivechatUnit.findByMonitorId.mockResolvedValue(['unit1']); + mockLivechatDepartmentAgents.findByAgentId.mockReturnValue({ + toArray: jest.fn().mockResolvedValue([{ departmentId: 'dept1' }]), + } as any); + + const result = await getUnitsFromUser(userId); + + expect(result).toEqual(['unit1', 'dept1']); + expect(mockLivechatUnit.findByMonitorId).toHaveBeenCalledWith(userId); + expect(mockLivechatDepartmentAgents.findByAgentId).toHaveBeenCalledWith(userId); + }); + }); +}); diff --git a/ee/packages/omni-core-ee/src/units/getUnitsFromUser.ts b/ee/packages/omni-core-ee/src/units/getUnitsFromUser.ts new file mode 100644 index 00000000000..4055483614f --- /dev/null +++ b/ee/packages/omni-core-ee/src/units/getUnitsFromUser.ts @@ -0,0 +1,48 @@ +import { Authorization } from '@rocket.chat/core-services'; +import { LivechatUnit, LivechatDepartmentAgents } from '@rocket.chat/models'; +import mem from 'mem'; + +import { defaultLogger } from '../utils/logger'; + +async function getUnitsFromUserRoles(user: string): Promise { + return LivechatUnit.findByMonitorId(user); +} + +async function getDepartmentsFromUserRoles(user: string): Promise { + return (await LivechatDepartmentAgents.findByAgentId(user).toArray()).map((department) => department.departmentId); +} + +const memoizedGetUnitFromUserRoles = mem(getUnitsFromUserRoles, { maxAge: process.env.TEST_MODE ? 1 : 10000 }); +const memoizedGetDepartmentsFromUserRoles = mem(getDepartmentsFromUserRoles, { maxAge: process.env.TEST_MODE ? 1 : 10000 }); + +async function hasUnits(): Promise { + // @ts-expect-error - this prop is injected dynamically on ee license + return (await LivechatUnit.countUnits({ type: 'u' })) > 0; +} + +// Units should't change really often, so we can cache the result +const memoizedHasUnits = mem(hasUnits, { maxAge: process.env.TEST_MODE ? 1 : 10000 }); + +export const getUnitsFromUser = async (userId?: string): Promise => { + if (!userId) { + return; + } + + if (!(await memoizedHasUnits())) { + return; + } + + // TODO: we can combine these 2 calls into one single query + if (await Authorization.hasAnyRole(userId, ['admin', 'livechat-manager'])) { + return; + } + + if (!(await Authorization.hasAnyRole(userId, ['livechat-monitor', 'livechat-agent']))) { + return; + } + + const unitsAndDepartments = [...(await memoizedGetUnitFromUserRoles(userId)), ...(await memoizedGetDepartmentsFromUserRoles(userId))]; + defaultLogger.debug({ msg: 'Calculating units for monitor', user: userId, unitsAndDepartments }); + + return unitsAndDepartments; +}; diff --git a/ee/packages/omni-core-ee/src/utils/logger.ts b/ee/packages/omni-core-ee/src/utils/logger.ts new file mode 100644 index 00000000000..71b7414567e --- /dev/null +++ b/ee/packages/omni-core-ee/src/utils/logger.ts @@ -0,0 +1,4 @@ +import { Logger } from '@rocket.chat/logger'; + +export const defaultLogger = new Logger('OmniCore-ee'); +export const hooksLogger = defaultLogger.section('hooks'); diff --git a/packages/core-services/src/types/IAuthorization.ts b/packages/core-services/src/types/IAuthorization.ts index abefce601dd..0176db5c9a9 100644 --- a/packages/core-services/src/types/IAuthorization.ts +++ b/packages/core-services/src/types/IAuthorization.ts @@ -1,4 +1,4 @@ -import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import type { IRoom, IUser, IRole } from '@rocket.chat/core-typings'; export type RoomAccessValidator = ( room?: Pick, @@ -14,4 +14,5 @@ export interface IAuthorization { canReadRoom: RoomAccessValidator; canAccessRoomId(rid: IRoom['_id'], uid?: IUser['_id']): Promise; getUsersFromPublicRoles(): Promise, '_id' | 'username' | 'roles'>[]>; + hasAnyRole(userId: IUser['_id'], roleIds: IRole['_id'][], scope?: IRoom['_id']): Promise; } diff --git a/packages/omni-core/package.json b/packages/omni-core/package.json index 9dbd412107b..a66939f6fbe 100644 --- a/packages/omni-core/package.json +++ b/packages/omni-core/package.json @@ -30,6 +30,10 @@ }, "dependencies": { "@rocket.chat/models": "workspace:^", - "@rocket.chat/patch-injection": "workspace:^" + "@rocket.chat/patch-injection": "workspace:^", + "mongodb": "6.10.0" + }, + "volta": { + "extends": "../../package.json" } } diff --git a/packages/omni-core/src/hooks/applyDepartmentRestrictions.ts b/packages/omni-core/src/hooks/applyDepartmentRestrictions.ts new file mode 100644 index 00000000000..db61370da44 --- /dev/null +++ b/packages/omni-core/src/hooks/applyDepartmentRestrictions.ts @@ -0,0 +1,7 @@ +import type { ILivechatDepartment } from '@rocket.chat/core-typings'; +import { makeFunction } from '@rocket.chat/patch-injection'; +import type { FilterOperators } from 'mongodb'; + +export const applyDepartmentRestrictions = makeFunction(async (query: FilterOperators = {}, _userId: string) => { + return query; +}); diff --git a/packages/omni-core/src/index.ts b/packages/omni-core/src/index.ts index c0a01acf69d..d617f440544 100644 --- a/packages/omni-core/src/index.ts +++ b/packages/omni-core/src/index.ts @@ -1,2 +1,3 @@ export * from './isDepartmentCreationAvailable'; +export * from './hooks/applyDepartmentRestrictions'; export * from './visitor/create';