diff --git a/.changeset/funny-boats-guess.md b/.changeset/funny-boats-guess.md
new file mode 100644
index 0000000000000..076acff98329c
--- /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 dadb59b047779..276d8c355c9b0 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 0ec56c1e5652f..8e3f9788880bc 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 0000000000000..748368f0d5690
--- /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 ba50eb48e2441..076336b50fe6b 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 c38794a7582ec..9f8135a16a654 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 0000000000000..ba62caf621b89
--- /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 de1c6ff5213fc..4618400d80175 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 12f0dbf628c50..c270bb9bffb1a 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",