diff --git a/.changeset/funny-boats-guess.md b/.changeset/funny-boats-guess.md new file mode 100644 index 000000000000..076acff98329 --- /dev/null +++ b/.changeset/funny-boats-guess.md @@ -0,0 +1,6 @@ +--- +"@rocket.chat/meteor": minor +"@rocket.chat/i18n": minor +--- + +Added a new Audit endpoint `audit/rooms.members` that allows users with `view-members-list-all-rooms` to fetch a list of the members of any room even if the user is not part of it. diff --git a/apps/meteor/client/views/audit/components/AuditFiltersDisplay.tsx b/apps/meteor/client/views/audit/components/AuditFiltersDisplay.tsx index dadb59b04777..276d8c355c9b 100644 --- a/apps/meteor/client/views/audit/components/AuditFiltersDisplay.tsx +++ b/apps/meteor/client/views/audit/components/AuditFiltersDisplay.tsx @@ -9,20 +9,24 @@ import { useFormatDate } from '../../../hooks/useFormatDate'; type AuditFiltersDisplayProps = { users?: IUser['username'][]; room?: IRoom['name']; - startDate: Date; - endDate: Date; + startDate?: Date; + endDate?: Date; + filters?: string; }; -const AuditFiltersDisplay = ({ users, room, startDate, endDate }: AuditFiltersDisplayProps): ReactElement => { +const AuditFiltersDisplay = ({ users, room, startDate, endDate, filters }: AuditFiltersDisplayProps): ReactElement => { const formatDate = useFormatDate(); const t = useTranslation(); return ( {users?.length ? users.map((user) => `@${user}`).join(' : ') : `#${room}`} - - {formatDate(startDate)} {t('Date_to')} {formatDate(endDate)} {/* TODO: fix this translation */} - + {startDate && endDate ? ( + + {formatDate(startDate)} {t('Date_to')} {formatDate(endDate)} {/* TODO: fix this translation */} + + ) : null} + {filters ? {filters} : null} ); }; diff --git a/apps/meteor/client/views/audit/components/AuditLogEntry.tsx b/apps/meteor/client/views/audit/components/AuditLogEntry.tsx index 0ec56c1e5652..8e3f9788880b 100644 --- a/apps/meteor/client/views/audit/components/AuditLogEntry.tsx +++ b/apps/meteor/client/views/audit/components/AuditLogEntry.tsx @@ -2,6 +2,7 @@ import type { IAuditLog } from '@rocket.chat/core-typings'; import { Box } from '@rocket.chat/fuselage'; import { useMediaQuery } from '@rocket.chat/fuselage-hooks'; import { UserAvatar } from '@rocket.chat/ui-avatar'; +import { useTranslation } from '@rocket.chat/ui-contexts'; import type { ReactElement } from 'react'; import React, { memo, useMemo } from 'react'; @@ -13,10 +14,11 @@ type AuditLogEntryProps = { value: IAuditLog }; const AuditLogEntry = ({ value: { u, results, ts, _id, fields } }: AuditLogEntryProps): ReactElement => { const formatDateAndTime = useFormatDateAndTime(); + const t = useTranslation(); const { username, name, avatarETag } = u; - const { msg, users, room, startDate, endDate } = fields; + const { msg, users, room, startDate, endDate, type, filters } = fields; const when = useMemo(() => formatDateAndTime(ts), [formatDateAndTime, ts]); @@ -43,12 +45,12 @@ const AuditLogEntry = ({ value: { u, results, ts, _id, fields } }: AuditLogEntry - {msg} + {type === 'room_member_list' ? t('Room_members_list') : msg} {when} {results} - + ); diff --git a/apps/meteor/ee/server/api/audit.ts b/apps/meteor/ee/server/api/audit.ts new file mode 100644 index 000000000000..748368f0d569 --- /dev/null +++ b/apps/meteor/ee/server/api/audit.ts @@ -0,0 +1,95 @@ +import type { IUser, IRoom } from '@rocket.chat/core-typings'; +import { Rooms, AuditLog } from '@rocket.chat/models'; +import type { PaginatedRequest, PaginatedResult } from '@rocket.chat/rest-typings'; +import Ajv from 'ajv'; + +import { API } from '../../../app/api/server/api'; +import { getPaginationItems } from '../../../app/api/server/helpers/getPaginationItems'; +import { findUsersOfRoom } from '../../../server/lib/findUsersOfRoom'; + +const ajv = new Ajv({ + coerceTypes: true, +}); + +type AuditRoomMembersParams = PaginatedRequest<{ + roomId: string; + filter: string; +}>; + +const auditRoomMembersSchema = { + type: 'object', + properties: { + roomId: { type: 'string', minLength: 1 }, + filter: { type: 'string' }, + count: { type: 'number' }, + offset: { type: 'number' }, + sort: { type: 'string' }, + }, + required: ['roomId'], + additionalProperties: false, +}; + +export const isAuditRoomMembersProps = ajv.compile(auditRoomMembersSchema); + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention + interface Endpoints { + '/v1/audit/rooms.members': { + GET: ( + params: AuditRoomMembersParams, + ) => PaginatedResult<{ members: Pick[] }>; + }; + } +} + +API.v1.addRoute( + 'audit/rooms.members', + { authRequired: true, permissionsRequired: ['view-members-list-all-rooms'], validateParams: isAuditRoomMembersProps }, + { + async get() { + const { roomId, filter } = this.queryParams; + const { count: limit, offset: skip } = await getPaginationItems(this.queryParams); + const { sort } = await this.parseJsonQuery(); + + const room = await Rooms.findOneById>(roomId, { projection: { _id: 1, name: 1, fname: 1 } }); + if (!room) { + return API.v1.notFound(); + } + + const { cursor, totalCount } = findUsersOfRoom({ + rid: room._id, + filter, + skip, + limit, + ...(sort?.username && { sort: { username: sort.username } }), + }); + + const [members, total] = await Promise.all([cursor.toArray(), totalCount]); + + await AuditLog.insertOne({ + ts: new Date(), + results: total, + u: { + _id: this.user._id, + username: this.user.username, + name: this.user.name, + avatarETag: this.user.avatarETag, + }, + fields: { + msg: 'Room_members_list', + rids: [room._id], + type: 'room_member_list', + room: room.name || room.fname, + filters: filter, + }, + }); + + return API.v1.success({ + members, + count: members.length, + offset: skip, + total, + }); + }, + }, +); diff --git a/apps/meteor/ee/server/lib/audit/startup.ts b/apps/meteor/ee/server/lib/audit/startup.ts index ba50eb48e244..076336b50fe6 100644 --- a/apps/meteor/ee/server/lib/audit/startup.ts +++ b/apps/meteor/ee/server/lib/audit/startup.ts @@ -6,6 +6,7 @@ export const createPermissions = async () => { const permissions = [ { _id: 'can-audit', roles: ['admin', 'auditor'] }, { _id: 'can-audit-log', roles: ['admin', 'auditor-log'] }, + { _id: 'view-members-list-all-rooms', roles: ['admin', 'auditor'] }, ]; const defaultRoles = [ diff --git a/apps/meteor/ee/server/startup/audit.ts b/apps/meteor/ee/server/startup/audit.ts index c38794a7582e..9f8135a16a65 100644 --- a/apps/meteor/ee/server/startup/audit.ts +++ b/apps/meteor/ee/server/startup/audit.ts @@ -4,6 +4,7 @@ import { createPermissions } from '../lib/audit/startup'; await License.onLicense('auditing', async () => { await import('../lib/audit/methods'); + await import('../api/audit'); await createPermissions(); }); diff --git a/apps/meteor/tests/end-to-end/api/audit.ts b/apps/meteor/tests/end-to-end/api/audit.ts new file mode 100644 index 000000000000..ba62caf621b8 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/audit.ts @@ -0,0 +1,301 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import { Random } from '@rocket.chat/random'; +import { expect } from 'chai'; +import EJSON from 'ejson'; +import { before, describe, it, after } from 'mocha'; + +import { getCredentials, api, request, credentials, methodCall } from '../../data/api-data'; +import { updatePermission } from '../../data/permissions.helper'; +import { createRoom, deleteRoom } from '../../data/rooms.helper'; +import { password } from '../../data/user'; +import { createUser, deleteUser, login } from '../../data/users.helper'; +import { IS_EE } from '../../e2e/config/constants'; + +(IS_EE ? describe : describe.skip)('Audit Panel', () => { + let testChannel: IRoom; + let testPrivateChannel: IRoom; + let dummyUser: IUser; + let auditor: IUser; + let auditorCredentials: Credentials; + before((done) => getCredentials(done)); + before(async () => { + testChannel = (await createRoom({ type: 'c', name: `chat.api-test-${Date.now()}` })).body.channel; + testPrivateChannel = (await createRoom({ type: 'p', name: `chat.api-test-${Date.now()}` })).body.group; + dummyUser = await createUser(); + auditor = await createUser({ roles: ['user', 'auditor'] }); + + auditorCredentials = await login(auditor.username, password); + }); + after(async () => { + await deleteRoom({ type: 'c', roomId: testChannel._id }); + await deleteUser({ _id: dummyUser._id }); + await deleteUser({ _id: auditor._id }); + await deleteRoom({ type: 'p', roomId: testPrivateChannel._id }); + }); + + describe('audit/rooms.members [no permissions]', () => { + before(async () => { + await updatePermission('view-members-list-all-rooms', []); + }); + after(async () => { + await updatePermission('view-members-list-all-rooms', ['admin', 'auditor']); + }); + it('should fail if user does not have view-members-list-all-rooms permission', async () => { + await request + .get(api('audit/rooms.members')) + .set(credentials) + .query({ + roomId: 'GENERAL', + }) + .expect(403); + await request + .get(api('audit/rooms.members')) + .set(auditorCredentials) + .query({ + roomId: 'GENERAL', + }) + .expect(403); + }); + }); + + describe('audit/rooms.members', () => { + it('should fail if user is not logged in', async () => { + await request + .get(api('audit/rooms.members')) + .query({ + roomId: 'GENERAL', + }) + .expect(401); + }); + it('should fail if roomId is invalid', async () => { + await request + .get(api('audit/rooms.members')) + .set(credentials) + .query({ + roomId: Random.id(), + }) + .expect(404); + }); + it('should fail if roomId is not present', async () => { + await request.get(api('audit/rooms.members')).set(credentials).query({}).expect(400); + }); + it('should fail if roomId is an empty string', async () => { + await request + .get(api('audit/rooms.members')) + .set(credentials) + .query({ + roomId: '', + }) + .expect(400); + }); + it('should fetch the members of a room', async () => { + await request + .get(api('audit/rooms.members')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.members).to.be.an('array'); + expect(res.body.members).to.have.lengthOf(1); + }); + }); + it('should persist a log entry', async () => { + await request + .get(api('audit/rooms.members')) + .set(credentials) + .query({ + roomId: testChannel._id, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.members).to.be.an('array'); + expect(res.body.members).to.have.lengthOf(1); + }); + + await request + .post(methodCall('auditGetAuditions')) + .set(credentials) + .send({ + message: EJSON.stringify({ + method: 'auditGetAuditions', + params: [{ startDate: new Date(Date.now() - 86400000), endDate: new Date() }], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + const message = JSON.parse(res.body.message); + + expect(message.result).to.be.an('array').with.lengthOf.greaterThan(1); + const entry = message.result.find((audition: any) => { + return audition.fields.rids.includes(testChannel._id); + }); + expect(entry).to.have.property('u').that.is.an('object').deep.equal({ + _id: 'rocketchat.internal.admin.test', + username: 'rocketchat.internal.admin.test', + name: 'RocketChat Internal Admin Test', + }); + expect(entry).to.have.property('fields').that.is.an('object'); + const { fields } = entry; + + expect(fields).to.have.property('msg', 'Room_members_list'); + expect(fields).to.have.property('rids').that.is.an('array').with.lengthOf(1); + }); + }); + it('should fetch the members of a room with offset and count', async () => { + await request + .post(methodCall('addUsersToRoom')) + .set(credentials) + .send({ + message: JSON.stringify({ + method: 'addUsersToRoom', + params: [{ rid: testChannel._id, users: [dummyUser.username] }], + id: 'id', + msg: 'method', + }), + }) + .expect('Content-Type', 'application/json') + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(api('audit/rooms.members')) + .set(credentials) + .query({ + roomId: testChannel._id, + offset: 1, + count: 1, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.members).to.be.an('array'); + expect(res.body.members).to.have.lengthOf(1); + expect(res.body.members[0].username).to.be.equal(dummyUser.username); + expect(res.body.total).to.be.equal(2); + expect(res.body.offset).to.be.equal(1); + expect(res.body.count).to.be.equal(1); + }); + }); + + it('should filter by username', async () => { + await request + .get(api('audit/rooms.members')) + .set(credentials) + .query({ + roomId: testChannel._id, + filter: dummyUser.username, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.members).to.be.an('array'); + expect(res.body.members).to.have.lengthOf(1); + expect(res.body.members[0].username).to.be.equal(dummyUser.username); + }); + }); + + it('should filter by user name', async () => { + await request + .get(api('audit/rooms.members')) + .set(credentials) + .query({ + roomId: testChannel._id, + filter: dummyUser.name, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.members).to.be.an('array'); + expect(res.body.members).to.have.lengthOf(1); + expect(res.body.members[0].name).to.be.equal(dummyUser.name); + }); + }); + + it('should sort by username', async () => { + await request + .get(api('audit/rooms.members')) + .set(credentials) + .query({ + roomId: testChannel._id, + sort: '{ "username": -1 }', + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.members).to.be.an('array'); + expect(res.body.members).to.have.lengthOf(2); + expect(res.body.members[1].username).to.be.equal('rocketchat.internal.admin.test'); + expect(res.body.members[0].username).to.be.equal(dummyUser.username); + }); + }); + + it('should not allow nosqlinjection on filter param', async () => { + await request + .get(api('audit/rooms.members')) + .set(credentials) + .query({ + roomId: testChannel._id, + filter: '{ "$ne": "rocketchat.internal.admin.test" }', + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.members).to.be.an('array'); + expect(res.body.members).to.have.lengthOf(0); + }); + + await request + .get(api('audit/rooms.members')) + .set(credentials) + .query({ + roomId: testChannel._id, + filter: { username: 'rocketchat.internal.admin.test' }, + }) + .expect(400); + }); + + it('should allow to fetch info even if user is not in the room', async () => { + await request + .get(api('audit/rooms.members')) + .set(auditorCredentials) + .query({ + roomId: testChannel._id, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.members).to.be.an('array'); + expect(res.body.members[0].username).to.be.equal('rocketchat.internal.admin.test'); + expect(res.body.members[1].username).to.be.equal(dummyUser.username); + expect(res.body.total).to.be.equal(2); + }); + }); + + it('should allow to fetch info from private rooms', async () => { + await request + .get(api('audit/rooms.members')) + .set(auditorCredentials) + .query({ + roomId: testPrivateChannel._id, + }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body.members).to.be.an('array'); + expect(res.body.members[0].username).to.be.equal('rocketchat.internal.admin.test'); + expect(res.body.total).to.be.equal(1); + }); + }); + }); +}); diff --git a/packages/core-typings/src/ee/IAuditLog.ts b/packages/core-typings/src/ee/IAuditLog.ts index de1c6ff5213f..4618400d8017 100644 --- a/packages/core-typings/src/ee/IAuditLog.ts +++ b/packages/core-typings/src/ee/IAuditLog.ts @@ -12,12 +12,13 @@ export interface IAuditLog extends IRocketChatRecord { fields: { type: string; msg: IMessage['msg']; - startDate: Date; - endDate: Date; + startDate?: Date; + endDate?: Date; rids?: IRoom['_id'][]; room: IRoom['name']; users?: IUser['username'][]; visitor?: ILivechatVisitor['_id']; agent?: ILivechatAgent['_id']; + filters?: string; }; } diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 12f0dbf628c5..c270bb9bffb1 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -5791,6 +5791,9 @@ "view-all-teams_description": "Permission to view all teams", "view-all-team-channels": "View All Team Channels", "view-all-team-channels_description": "Permission to view all team's channels", + "view-members-list-all-rooms": "Can view members in all rooms", + "view-members-list-all-rooms_description": "Gives the ability to see the members list in all rooms, even those the user is not part of", + "Room_members_list": "Members list", "view-broadcast-member-list": "View Members List in Broadcast Room", "view-broadcast-member-list_description": "Permission to view list of users in broadcast channel", "view-c-room": "View Public Channel",