Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion apps/meteor/app/lib/server/functions/addUserToRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { getSubscriptionAutotranslateDefaultConfig } from '../../../../server/li
import { roomCoordinator } from '../../../../server/lib/rooms/roomCoordinator';
import { settings } from '../../../settings/server';
import { getDefaultSubscriptionPref } from '../../../utils/lib/getDefaultSubscriptionPref';
import { beforeAddUserToRoom as beforeAddUserToRoomPatch } from '../lib/beforeAddUserToRoom';
import { notifyOnRoomChangedById, notifyOnSubscriptionChangedById } from '../lib/notifyListener';

/**
Expand Down Expand Up @@ -57,7 +58,10 @@ export const addUserToRoom = async (
}

try {
await beforeAddUserToRoom.run({ user: userToBeAdded, inviter: (inviter && (await Users.findOneById(inviter._id))) || undefined }, room);
const inviterUser = inviter && ((await Users.findOneById(inviter._id)) || undefined);
// Not "duplicated": we're moving away from callbacks so this is a patch function. We should migrate the next one to be a patch or use this same patch, instead of calling both
await beforeAddUserToRoomPatch([userToBeAdded.username!], room, inviterUser);
await beforeAddUserToRoom.run({ user: userToBeAdded, inviter: inviterUser }, room);
} catch (error) {
throw new Meteor.Error((error as any)?.message);
}
Expand Down
6 changes: 6 additions & 0 deletions apps/meteor/app/lib/server/lib/beforeAddUserToRoom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import type { IRoom, IUser } from '@rocket.chat/core-typings';
import { makeFunction } from '@rocket.chat/patch-injection';

export const beforeAddUserToRoom = makeFunction(async (_users: IUser['username'][], _room: IRoom, _actor?: IUser) => {
// no op on CE
});
43 changes: 28 additions & 15 deletions apps/meteor/app/slashcommands-invite/server/server.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { api } from '@rocket.chat/core-services';
import { api, isMeteorError } from '@rocket.chat/core-services';
import type { IUser, SlashCommandCallbackParams } from '@rocket.chat/core-typings';
import { Subscriptions, Users } from '@rocket.chat/models';
import { Meteor } from 'meteor/meteor';
Expand All @@ -8,6 +8,11 @@ import { addUsersToRoomMethod, sanitizeUsername } from '../../lib/server/methods
import { settings } from '../../settings/server';
import { slashCommands } from '../../utils/server/slashCommand';

// Type guards for the error
function isStringError(error: unknown): error is { error: string } {
return typeof (error as any)?.error === 'string';
}

/*
* Invite is a named function that will replace /invite commands
* @param {Object} message - The message object
Expand Down Expand Up @@ -73,23 +78,31 @@ slashCommands.add({
},
inviter,
);
} catch ({ error }: any) {
if (typeof error !== 'string') {
return;
}
} catch (e: unknown) {
if (isMeteorError(e)) {
const details = Array.isArray(e.details) ? e.details.join(', ') : '';

if (error === 'error-federated-users-in-non-federated-rooms') {
void api.broadcast('notify.ephemeralMessage', userId, message.rid, {
msg: i18n.t('You_cannot_add_external_users_to_non_federated_room', { lng: settings.get('Language') || 'en' }),
});
} else if (error === 'cant-invite-for-direct-room') {
void api.broadcast('notify.ephemeralMessage', userId, message.rid, {
msg: i18n.t('Cannot_invite_users_to_direct_rooms', { lng: settings.get('Language') || 'en' }),
});
} else {
void api.broadcast('notify.ephemeralMessage', userId, message.rid, {
msg: i18n.t(error, { lng: settings.get('Language') || 'en' }),
msg: i18n.t(e.message, { lng: settings.get('Language') || 'en', details: `\`${details}\`` }),
});
return;
}

if (isStringError(e)) {
const { error } = e;
if (error === 'error-federated-users-in-non-federated-rooms') {
void api.broadcast('notify.ephemeralMessage', userId, message.rid, {
msg: i18n.t('You_cannot_add_external_users_to_non_federated_room', { lng: settings.get('Language') || 'en' }),
});
} else if (error === 'cant-invite-for-direct-room') {
void api.broadcast('notify.ephemeralMessage', userId, message.rid, {
msg: i18n.t('Cannot_invite_users_to_direct_rooms', { lng: settings.get('Language') || 'en' }),
});
} else {
void api.broadcast('notify.ephemeralMessage', userId, message.rid, {
msg: i18n.t(error, { lng: settings.get('Language') || 'en' }),
});
}
}
}
}),
Expand Down
2 changes: 2 additions & 0 deletions apps/meteor/ee/server/configuration/abac.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,7 @@ Meteor.startup(async () => {

await addSettings();
await createPermissions();

await import('../hooks/abac');
});
});
22 changes: 22 additions & 0 deletions apps/meteor/ee/server/hooks/abac/beforeAddUserToRoom.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { Abac } from '@rocket.chat/core-services';
import { License } from '@rocket.chat/license';

import { beforeAddUserToRoom } from '../../../../app/lib/server/lib/beforeAddUserToRoom';
import { settings } from '../../../../app/settings/server';

beforeAddUserToRoom.patch(async (prev, users, room, actor) => {
await prev(users, room, actor);

const validUsers = users.filter(Boolean);
if (
!room?.abacAttributes?.length ||
!validUsers.length ||
!License.hasModule('abac') ||
room.t !== 'p' ||
!settings.get('ABAC_Enabled')
) {
return;
}

await Abac.checkUsernamesMatchAttributes(validUsers as string[], room.abacAttributes);
});
1 change: 1 addition & 0 deletions apps/meteor/ee/server/hooks/abac/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import './beforeAddUserToRoom';
6 changes: 6 additions & 0 deletions apps/meteor/server/methods/addAllUserToRoom.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { check } from 'meteor/check';
import { Meteor } from 'meteor/meteor';

import { hasPermissionAsync } from '../../app/authorization/server/functions/hasPermission';
import { beforeAddUserToRoom } from '../../app/lib/server/lib/beforeAddUserToRoom';
import { notifyOnSubscriptionChangedById } from '../../app/lib/server/lib/notifyListener';
import { settings } from '../../app/settings/server';
import { getDefaultSubscriptionPref } from '../../app/utils/lib/getDefaultSubscriptionPref';
Expand Down Expand Up @@ -50,6 +51,11 @@ export const addAllUserToRoomFn = async (userId: string, rid: IRoom['_id'], acti
});
}

await beforeAddUserToRoom(
users.map((u) => u.username!),
room,
);

const now = new Date();
for await (const user of users) {
const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, user._id);
Expand Down
72 changes: 72 additions & 0 deletions apps/meteor/tests/end-to-end/api/abac.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1411,4 +1411,76 @@ const addAbacAttributesToUserDirectly = async (userId: string, abacAttributes: I
});
});
});

describe('Room access (invite, addition)', () => {
let roomWithoutAttr: IRoom;
let roomWithAttr: IRoom;
const accessAttrKey = `access_attr_${Date.now()}`;

before(async () => {
await updateSetting('ABAC_Enabled', true);

await request
.post(`${v1}/abac/attributes`)
.set(credentials)
.send({ key: accessAttrKey, values: ['v1'] })
.expect(200);

// We have to add them directly cause otherwise the abac engine would kick the user from the room after the attribute is added
await addAbacAttributesToUserDirectly(credentials['X-User-Id'], [{ key: accessAttrKey, values: ['v1'] }]);

// Create two private rooms: one will stay without attributes, the other will get the attribute
roomWithoutAttr = (await createRoom({ type: 'p', name: `abac-access-noattr-${Date.now()}` })).body.group;
roomWithAttr = (await createRoom({ type: 'p', name: `abac-access-withattr-${Date.now()}` })).body.group;

// Assign the attribute to the second room
await request
.post(`${v1}/abac/rooms/${roomWithAttr._id}/attributes/${accessAttrKey}`)
.set(credentials)
.send({ values: ['v1'] })
.expect(200);
});

after(async () => {
await deleteRoom({ type: 'p', roomId: roomWithoutAttr._id });
await deleteRoom({ type: 'p', roomId: roomWithAttr._id });
});

it('INVITE: user without attributes invited to room without attributes succeeds', async () => {
await request
.post(`${v1}/groups.invite`)
.set(credentials)
.send({ roomId: roomWithoutAttr._id, usernames: [unauthorizedUser.username] })
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
});
});

it('INVITE: user without attributes invited to room with attributes should fail', async () => {
await request
.post(`${v1}/groups.invite`)
.set(credentials)
.send({ roomId: roomWithAttr._id, usernames: [unauthorizedUser.username] })
.expect(400)
.expect((res) => {
expect(res.body).to.have.property('success', false);
expect(res.body).to.have.property('error').that.includes('error-usernames-not-matching-abac-attributes');
});
});

it('INVITE: after room loses attributes user without attributes can be invited', async () => {
await request.delete(`${v1}/abac/rooms/${roomWithAttr._id}/attributes/${accessAttrKey}`).set(credentials).expect(200);

// Try inviting again - should now succeed
await request
.post(`${v1}/groups.invite`)
.set(credentials)
.send({ roomId: roomWithAttr._id, usernames: [unauthorizedUser.username] })
.expect(200)
.expect((res) => {
expect(res.body).to.have.property('success', true);
});
});
});
});
136 changes: 132 additions & 4 deletions ee/packages/abac/src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ const mockUpdateSingleAbacAttributeValuesById = jest.fn();
const mockUpdateAbacAttributeValuesArrayFilteredById = jest.fn();
const mockRemoveAbacAttributeByRoomIdAndKey = jest.fn();
const mockInsertAbacAttributeIfNotExistsById = jest.fn();
const mockUsersFind = jest.fn();

jest.mock('@rocket.chat/models', () => ({
Rooms: {
Expand All @@ -37,12 +38,22 @@ jest.mock('@rocket.chat/models', () => ({
removeById: (...args: any[]) => mockAbacDeleteOne(...args),
find: (...args: any[]) => mockAbacFind(...args),
},
Users: {
find: (...args: any[]) => mockUsersFind(...args),
},
}));

// Minimal mock for ServiceClass (we don't need its real behavior in unit scope)
jest.mock('@rocket.chat/core-services', () => ({
ServiceClass: class {},
}));
// Partial mock for @rocket.chat/core-services: keep real MeteorError, override ServiceClass and Room
jest.mock('@rocket.chat/core-services', () => {
const actual = jest.requireActual('@rocket.chat/core-services');
return {
...actual,
ServiceClass: class {},
Room: {
removeUserFromRoom: jest.fn(),
},
};
});

describe('AbacService (unit)', () => {
let service: AbacService;
Expand Down Expand Up @@ -815,4 +826,121 @@ describe('AbacService (unit)', () => {
});
});
});

describe('checkUsernamesMatchAttributes', () => {
beforeEach(() => {
mockUsersFind.mockReset();
});

const attributes = [{ key: 'dept', values: ['eng'] }];

it('returns early (no query) when usernames array is empty', async () => {
await expect(service.checkUsernamesMatchAttributes([], attributes as any)).resolves.toBeUndefined();
expect(mockUsersFind).not.toHaveBeenCalled();
});

it('returns early (no query) when attributes array is empty', async () => {
await expect(service.checkUsernamesMatchAttributes(['alice'], [])).resolves.toBeUndefined();
expect(mockUsersFind).not.toHaveBeenCalled();
});

it('resolves when all provided usernames are compliant (query returns empty)', async () => {
const usernames = ['alice', 'bob'];
mockUsersFind.mockImplementationOnce(() => ({
map: () => ({
toArray: async () => [],
}),
}));

await expect(service.checkUsernamesMatchAttributes(usernames, attributes as any)).resolves.toBeUndefined();

expect(mockUsersFind).toHaveBeenCalledWith(
{
username: { $in: usernames },
$or: [
{
abacAttributes: {
$not: {
$elemMatch: {
key: 'dept',
values: { $all: ['eng'] },
},
},
},
},
],
},
{ projection: { username: 1 } },
);
});

it('rejects with error-usernames-not-matching-abac-attributes and details for non-compliant users', async () => {
const usernames = ['alice', 'bob', 'charlie'];
const nonCompliantDocs = [{ username: 'bob' }, { username: 'charlie' }];
mockUsersFind.mockImplementationOnce(() => ({
map: (fn: (u: any) => string) => ({
toArray: async () => nonCompliantDocs.map(fn),
}),
}));

await expect(service.checkUsernamesMatchAttributes(usernames, attributes as any)).rejects.toMatchObject({
error: 'error-usernames-not-matching-abac-attributes',
message: expect.stringContaining('[error-usernames-not-matching-abac-attributes]'),
details: expect.arrayContaining(['bob', 'charlie']),
});
});
});
describe('buildNonCompliantConditions (private)', () => {
it('returns empty array for empty attributes list', () => {
const result = (service as any).buildNonCompliantConditions([]);
expect(result).toEqual([]);
});

it('maps single attribute to $not $elemMatch query', () => {
const attrs = [{ key: 'dept', values: ['eng', 'sales'] }];
const result = (service as any).buildNonCompliantConditions(attrs);
expect(result).toEqual([
{
abacAttributes: {
$not: {
$elemMatch: {
key: 'dept',
values: { $all: ['eng', 'sales'] },
},
},
},
},
]);
});

it('maps multiple attributes preserving order', () => {
const attrs = [
{ key: 'dept', values: ['eng'] },
{ key: 'region', values: ['emea', 'apac'] },
];
const result = (service as any).buildNonCompliantConditions(attrs);
expect(result).toEqual([
{
abacAttributes: {
$not: {
$elemMatch: {
key: 'dept',
values: { $all: ['eng'] },
},
},
},
},
{
abacAttributes: {
$not: {
$elemMatch: {
key: 'region',
values: { $all: ['emea', 'apac'] },
},
},
},
},
]);
});
});
});
Loading
Loading