feat(audit): New endpoint for listing members of any room (#32916)

flaky-livechat^2
Kevin Aleman 1 year ago committed by GitHub
parent 08ac2f39fa
commit 127866ce97
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      .changeset/funny-boats-guess.md
  2. 16
      apps/meteor/client/views/audit/components/AuditFiltersDisplay.tsx
  3. 8
      apps/meteor/client/views/audit/components/AuditLogEntry.tsx
  4. 95
      apps/meteor/ee/server/api/audit.ts
  5. 1
      apps/meteor/ee/server/lib/audit/startup.ts
  6. 1
      apps/meteor/ee/server/startup/audit.ts
  7. 301
      apps/meteor/tests/end-to-end/api/audit.ts
  8. 5
      packages/core-typings/src/ee/IAuditLog.ts
  9. 3
      packages/i18n/src/locales/en.i18n.json

@ -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.

@ -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 (
<Box display='flex' flexDirection='column' alignItems='stretch' withTruncatedText>
<Box withTruncatedText>{users?.length ? users.map((user) => `@${user}`).join(' : ') : `#${room}`}</Box>
<Box withTruncatedText>
{formatDate(startDate)} {t('Date_to')} {formatDate(endDate)} {/* TODO: fix this translation */}
</Box>
{startDate && endDate ? (
<Box withTruncatedText>
{formatDate(startDate)} {t('Date_to')} {formatDate(endDate)} {/* TODO: fix this translation */}
</Box>
) : null}
{filters ? <Box withTruncatedText>{filters}</Box> : null}
</Box>
);
};

@ -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
</Box>
</GenericTableCell>
<GenericTableCell fontScale='p2m' color='hint' withTruncatedText>
{msg}
{type === 'room_member_list' ? t('Room_members_list') : msg}
</GenericTableCell>
<GenericTableCell withTruncatedText>{when}</GenericTableCell>
<GenericTableCell withTruncatedText>{results}</GenericTableCell>
<GenericTableCell fontScale='p2' color='hint' withTruncatedText>
<AuditFiltersDisplay users={users} room={room} startDate={startDate} endDate={endDate} />
<AuditFiltersDisplay users={users} room={room} startDate={startDate} endDate={endDate} filters={filters} />
</GenericTableCell>
</GenericTableRow>
);

@ -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<AuditRoomMembersParams>(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<IUser, '_id' | 'name' | 'username' | 'status' | '_updatedAt'>[] }>;
};
}
}
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<Pick<IRoom, '_id' | 'name' | 'fname'>>(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,
});
},
},
);

@ -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 = [

@ -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();
});

@ -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);
});
});
});
});

@ -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;
};
}

@ -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",

Loading…
Cancel
Save