diff --git a/apps/meteor/client/lib/utils/mapSubscriptionFromApi.ts b/apps/meteor/client/lib/utils/mapSubscriptionFromApi.ts index d0f75a389ab8f..358efaef0e9c6 100644 --- a/apps/meteor/client/lib/utils/mapSubscriptionFromApi.ts +++ b/apps/meteor/client/lib/utils/mapSubscriptionFromApi.ts @@ -7,6 +7,7 @@ export const mapSubscriptionFromApi = ({ _updatedAt, oldRoomKeys, suggestedOldRoomKeys, + abacLastTimeChecked, ...subscription }: Serialized): ISubscription => ({ ...subscription, @@ -14,6 +15,7 @@ export const mapSubscriptionFromApi = ({ ls: new Date(ls), lr: new Date(lr), _updatedAt: new Date(_updatedAt), + ...(abacLastTimeChecked && { abacLastTimeChecked: new Date(abacLastTimeChecked) }), ...(oldRoomKeys && { oldRoomKeys: oldRoomKeys.map(({ ts, ...key }) => ({ ...key, ts: new Date(ts) })) }), ...(suggestedOldRoomKeys && { suggestedOldRoomKeys: suggestedOldRoomKeys.map(({ ts, ...key }) => ({ ...key, ts: new Date(ts) })) }), }); diff --git a/apps/meteor/ee/server/settings/abac.ts b/apps/meteor/ee/server/settings/abac.ts index f187e17703cdd..1040a98fb437a 100644 --- a/apps/meteor/ee/server/settings/abac.ts +++ b/apps/meteor/ee/server/settings/abac.ts @@ -15,6 +15,12 @@ export function addSettings(): void { section: 'ABAC', i18nDescription: 'ABAC_Enabled_Description', }); + await this.add('Abac_Cache_Decision_Time_Seconds', 300, { + type: 'int', + public: true, + section: 'ABAC', + invalidValue: 0, + }); }, ); }); diff --git a/apps/meteor/server/services/authorization/canAccessRoom.ts b/apps/meteor/server/services/authorization/canAccessRoom.ts index 2182bef8e8873..73263437b569c 100644 --- a/apps/meteor/server/services/authorization/canAccessRoom.ts +++ b/apps/meteor/server/services/authorization/canAccessRoom.ts @@ -1,6 +1,6 @@ -import { Authorization } from '@rocket.chat/core-services'; +import { Authorization, License, Abac } from '@rocket.chat/core-services'; import type { RoomAccessValidator } from '@rocket.chat/core-services'; -import { TEAM_TYPE } from '@rocket.chat/core-typings'; +import { TEAM_TYPE, AbacAccessOperation, AbacObjectType } from '@rocket.chat/core-typings'; import type { IUser, ITeam } from '@rocket.chat/core-typings'; import { Subscriptions, Rooms, Settings, TeamMember, Team } from '@rocket.chat/models'; @@ -57,15 +57,25 @@ const roomAccessValidators: RoomAccessValidator[] = [ return false; } - if (!(await Subscriptions.countByRoomIdAndUserId(room._id, user._id))) { - return false; - } - - if (await Authorization.hasPermission(user._id, 'view-joined-room')) { - return true; + const [canViewJoined, canViewT] = await Promise.all([ + Authorization.hasPermission(user._id, 'view-joined-room'), + Authorization.hasPermission(user._id, `view-${room.t}-room`), + ]); + + // When there's no ABAC setting, license or values on the room, fallback to previous behavior + if ( + !room?.abacAttributes?.length || + !(await License.hasModule('abac')) || + (!(await Settings.getValueById('ABAC_Enabled')) as boolean) + ) { + if (!(await Subscriptions.countByRoomIdAndUserId(room._id, user._id))) { + return false; + } + + return canViewJoined || canViewT; } - return Authorization.hasPermission(user._id, `view-${room.t}-room`); + return (canViewJoined || canViewT) && Abac.canAccessObject(room, user, AbacAccessOperation.READ, AbacObjectType.ROOM); }, async function _validateAccessToDiscussionsParentRoom(room, user): Promise { diff --git a/apps/meteor/server/services/authorization/service.ts b/apps/meteor/server/services/authorization/service.ts index 50ce3d320f310..a5f3851d565b7 100644 --- a/apps/meteor/server/services/authorization/service.ts +++ b/apps/meteor/server/services/authorization/service.ts @@ -86,12 +86,13 @@ export class Authorization extends ServiceClass implements IAuthorization { } async canAccessRoomId(rid: IRoom['_id'], uid: IUser['_id']): Promise { - const room = await Rooms.findOneById>(rid, { + const room = await Rooms.findOneById>(rid, { projection: { _id: 1, t: 1, teamId: 1, prid: 1, + abacAttributes: 1, }, }); diff --git a/apps/meteor/tests/end-to-end/api/abac.ts b/apps/meteor/tests/end-to-end/api/abac.ts index 5a8b9b25371c4..02187a13d3601 100644 --- a/apps/meteor/tests/end-to-end/api/abac.ts +++ b/apps/meteor/tests/end-to-end/api/abac.ts @@ -1483,4 +1483,135 @@ const addAbacAttributesToUserDirectly = async (userId: string, abacAttributes: I }); }); }); + + describe('Room access (after subscribed)', () => { + let cacheRoom: IRoom; + const cacheAttrKey = `access_cache_attr_${Date.now()}`; + let cacheUser: IUser; + let cacheUserCreds: Credentials; + const ttlSeconds = 5; + + before(async function () { + this.timeout(10000); + + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: cacheAttrKey, values: ['on'] }) + .expect(200); + + cacheRoom = (await createRoom({ type: 'p', name: `abac-cache-room-${Date.now()}` })).body.group; + + cacheUser = await createUser(); + cacheUserCreds = await login(cacheUser.username, password); + await addAbacAttributesToUserDirectly(cacheUser._id, [{ key: cacheAttrKey, values: ['on'] }]); + await addAbacAttributesToUserDirectly(credentials['X-User-Id'], [{ key: cacheAttrKey, values: ['on'] }]); + + await request + .post(`${v1}/abac/rooms/${cacheRoom._id}/attributes/${cacheAttrKey}`) + .set(credentials) + .send({ values: ['on'] }) + .expect(200); + + await request + .post(`${v1}/groups.invite`) + .set(credentials) + .send({ roomId: cacheRoom._id, usernames: [cacheUser.username] }) + .expect(200); + + await updateSetting('Abac_Cache_Decision_Time_Seconds', ttlSeconds); + + await request + .post(`${v1}/chat.sendMessage`) + .set(credentials) + .send({ message: { rid: cacheRoom._id, msg: 'Seed message for cache access test' } }) + .expect(200); + }); + + after(async () => { + await deleteRoom({ type: 'p', roomId: cacheRoom._id }); + await deleteUser(cacheUser); + await updateSetting('Abac_Cache_Decision_Time_Seconds', 300); + }); + + it('ACCESS: user can retrieve messages after subscription (initial compliant)', async () => { + await request + .get(`/api/v1/groups.history`) + .set(cacheUserCreds) + .query({ roomId: cacheRoom._id }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').that.is.an('array'); + }); + }); + + it('ACCESS: user retains access within cache after losing attributes', async () => { + await addAbacAttributesToUserDirectly(cacheUser._id, []); + + await request + .get(`/api/v1/groups.history`) + .set(cacheUserCreds) + .query({ roomId: cacheRoom._id }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('ACCESS: user loses access after cache expiry', async () => { + await updateSetting('Abac_Cache_Decision_Time_Seconds', 0); + + await request + .get(`${v1}/groups.history`) + .set(cacheUserCreds) + .query({ roomId: cacheRoom._id }) + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('ACCESS: user is removed from the room when the access check fails', async () => { + const roomInfoRes = await request + .get(`${v1}/rooms.membersOrderedByRole`) + .set(credentials) + .query({ roomId: cacheRoom._id }) + .expect(200); + const { members } = roomInfoRes.body; + + expect(members.find((m: IUser) => m.username === cacheUser.username)).to.be.undefined; + }); + + it('ACCESS: user can be re invited to the room and access history', async () => { + await addAbacAttributesToUserDirectly(cacheUser._id, [{ key: cacheAttrKey, values: ['on'] }]); + await request + .post(`${v1}/groups.invite`) + .set(credentials) + .send({ roomId: cacheRoom._id, usernames: [cacheUser.username] }) + .expect(200); + + await request + .get(`/api/v1/groups.history`) + .set(cacheUserCreds) + .query({ roomId: cacheRoom._id }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('ACCESS: keeps access once room attributes are removed', async () => { + await request.delete(`${v1}/abac/rooms/${cacheRoom._id}/attributes/${cacheAttrKey}`).set(credentials).expect(200); + + await request + .get(`${v1}/groups.history`) + .set(cacheUserCreds) + .query({ roomId: cacheRoom._id }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + }); }); diff --git a/ee/packages/abac/src/can-access-object.spec.ts b/ee/packages/abac/src/can-access-object.spec.ts new file mode 100644 index 0000000000000..2cffb79953d66 --- /dev/null +++ b/ee/packages/abac/src/can-access-object.spec.ts @@ -0,0 +1,261 @@ +import { AbacAccessOperation, AbacObjectType } from '@rocket.chat/core-typings'; + +import { AbacService } from './index'; + +const mockSettingsGetValueById = jest.fn(); +const mockSubscriptionsFindOneByRoomIdAndUserId = jest.fn(); +const mockSubscriptionsSetAbacLastTimeCheckedByUserIdAndRoomId = jest.fn(); +const mockUsersFindOne = jest.fn(); +const mockUsersFindOneById = jest.fn(); +const mockRoomRemoveUserFromRoom = jest.fn(); + +jest.mock('@rocket.chat/models', () => ({ + Settings: { + getValueById: (...args: any[]) => mockSettingsGetValueById(...args), + }, + Subscriptions: { + findOneByRoomIdAndUserId: (...args: any[]) => mockSubscriptionsFindOneByRoomIdAndUserId(...args), + setAbacLastTimeCheckedByUserIdAndRoomId: (...args: any[]) => mockSubscriptionsSetAbacLastTimeCheckedByUserIdAndRoomId(...args), + }, + Users: { + findOne: (...args: any[]) => mockUsersFindOne(...args), + findOneById: (...args: any[]) => mockUsersFindOneById(...args), + }, +})); + +jest.mock('@rocket.chat/core-services', () => ({ + ServiceClass: class {}, + Room: { + removeUserFromRoom: (...args: any[]) => mockRoomRemoveUserFromRoom(...args), + }, +})); + +describe('AbacService.canAccessObject (unit)', () => { + let service: AbacService; + + const baseRoom = { + _id: 'RID', + t: 'p', + abacAttributes: [ + { key: 'dept', values: ['eng', 'sales'] }, + { key: 'region', values: ['emea'] }, + ], + }; + + const baseUser = { + _id: 'UID', + username: 'user1', + }; + + beforeEach(() => { + service = new AbacService(); + jest.clearAllMocks(); + // Default behaviors + mockSettingsGetValueById.mockResolvedValue(300); // 5 minute cache + }); + + describe('parameter validation & early returns', () => { + it('throws error-abac-unsupported-object-type when objectType is not ROOM', async () => { + await expect(service.canAccessObject(baseRoom as any, baseUser as any, AbacAccessOperation.READ, 'not-room' as any)).rejects.toThrow( + 'error-abac-unsupported-object-type', + ); + + expect(mockSubscriptionsFindOneByRoomIdAndUserId).not.toHaveBeenCalled(); + expect(mockUsersFindOne).not.toHaveBeenCalled(); + }); + + it('throws error-abac-unsupported-operation when action is not READ', async () => { + await expect( + service.canAccessObject(baseRoom as any, baseUser as any, AbacAccessOperation.WRITE, AbacObjectType.ROOM), + ).rejects.toThrow('error-abac-unsupported-operation'); + + expect(mockSubscriptionsFindOneByRoomIdAndUserId).not.toHaveBeenCalled(); + expect(mockUsersFindOne).not.toHaveBeenCalled(); + }); + + it('returns false when user is missing _id', async () => { + const result = await service.canAccessObject(baseRoom as any, {} as any, AbacAccessOperation.READ, AbacObjectType.ROOM); + expect(result).toBe(false); + expect(mockSubscriptionsFindOneByRoomIdAndUserId).not.toHaveBeenCalled(); + expect(mockUsersFindOne).not.toHaveBeenCalled(); + }); + + it('returns false when room has no abacAttributes array', async () => { + const room = { ...baseRoom, abacAttributes: [] }; + const result = await service.canAccessObject(room as any, baseUser as any, AbacAccessOperation.READ, AbacObjectType.ROOM); + expect(result).toBe(false); + expect(mockSubscriptionsFindOneByRoomIdAndUserId).not.toHaveBeenCalled(); + expect(mockUsersFindOne).not.toHaveBeenCalled(); + }); + }); + + describe('subscription presence & compliance evaluation', () => { + it('returns false when user has no subscription to room', async () => { + mockSubscriptionsFindOneByRoomIdAndUserId.mockResolvedValue(null); + + const result = await service.canAccessObject(baseRoom as any, baseUser as any, AbacAccessOperation.READ, AbacObjectType.ROOM); + expect(result).toBe(false); + expect(mockSubscriptionsFindOneByRoomIdAndUserId).toHaveBeenCalledWith(baseRoom._id, baseUser._id, { + projection: { abacLastTimeChecked: 1 }, + }); + expect(mockUsersFindOne).not.toHaveBeenCalled(); + }); + + it('returns false for non-compliant subscription and removes user from room when full user exists', async () => { + mockSubscriptionsFindOneByRoomIdAndUserId.mockResolvedValue({ + _id: 'SUB', + abacLastTimeChecked: undefined, + }); + mockUsersFindOne.mockResolvedValue(null); // non-compliant + mockUsersFindOneById.mockResolvedValue({ _id: baseUser._id, username: baseUser.username }); // full user for removal + + const result = await service.canAccessObject(baseRoom as any, baseUser as any, AbacAccessOperation.READ, AbacObjectType.ROOM); + expect(result).toBe(false); + + // Compliance evaluation query assertions + expect(mockUsersFindOne).toHaveBeenCalledTimes(1); + const [query, options] = mockUsersFindOne.mock.calls[0]; + expect(query._id).toBe(baseUser._id); + expect(Array.isArray(query.$and)).toBe(true); + expect(query.$and).toHaveLength(baseRoom.abacAttributes.length); + query.$and.forEach((cond: any, idx: number) => { + expect(cond.abacAttributes.$elemMatch.key).toBe(baseRoom.abacAttributes[idx].key); + expect(cond.abacAttributes.$elemMatch.values.$all).toEqual(baseRoom.abacAttributes[idx].values); + }); + expect(options).toEqual({ projection: { _id: 1 } }); + + // Removal path assertions + expect(mockUsersFindOneById).toHaveBeenCalledWith(baseUser._id); + expect(mockRoomRemoveUserFromRoom).toHaveBeenCalledWith( + baseRoom._id, + { _id: baseUser._id, username: baseUser.username }, + { + skipAppPreEvents: true, + customSystemMessage: 'abac-removed-user-from-room', + }, + ); + expect(mockSubscriptionsSetAbacLastTimeCheckedByUserIdAndRoomId).not.toHaveBeenCalled(); + }); + + it('returns true and updates subscription timestamp when compliant', async () => { + const fakeSub = { _id: 'SUB' }; + mockSubscriptionsFindOneByRoomIdAndUserId.mockResolvedValue(fakeSub); + mockUsersFindOne.mockResolvedValue({ _id: baseUser._id }); + + const result = await service.canAccessObject(baseRoom as any, baseUser as any, AbacAccessOperation.READ, AbacObjectType.ROOM); + expect(result).toBe(true); + + expect(mockUsersFindOne).toHaveBeenCalledTimes(1); + expect(mockSubscriptionsSetAbacLastTimeCheckedByUserIdAndRoomId).toHaveBeenCalledWith(baseUser._id, baseRoom._id, expect.any(Date)); + }); + }); + + describe('decision cache behavior', () => { + it('uses cached decision (returns true) when within cache TTL and subscription exists', async () => { + const ttlSeconds = 120; + mockSettingsGetValueById.mockResolvedValue(ttlSeconds); + + const within = new Date(Date.now() - (ttlSeconds * 1000 - 500)); // 500ms before expiry + mockSubscriptionsFindOneByRoomIdAndUserId.mockResolvedValue({ + _id: 'SUB', + abacLastTimeChecked: within, + }); + + const internalLogger = (service as any).logger; + const loggerDebug = jest.spyOn(internalLogger, 'debug').mockImplementation(() => undefined); + + const result = await service.canAccessObject(baseRoom as any, baseUser as any, AbacAccessOperation.READ, AbacObjectType.ROOM); + + expect(result).toBe(true); + expect(mockUsersFindOne).not.toHaveBeenCalled(); + expect(mockSubscriptionsSetAbacLastTimeCheckedByUserIdAndRoomId).not.toHaveBeenCalled(); + expect(loggerDebug).toHaveBeenCalledWith({ + msg: 'Using cached ABAC decision', + userId: baseUser._id, + roomId: baseRoom._id, + }); + }); + + it('re-evaluates when cache expired (timestamp older than TTL)', async () => { + const ttlSeconds = 60; + mockSettingsGetValueById.mockResolvedValue(ttlSeconds); + + const expired = new Date(Date.now() - (ttlSeconds * 1000 + 1000)); + mockSubscriptionsFindOneByRoomIdAndUserId.mockResolvedValue({ + _id: 'SUB', + abacLastTimeChecked: expired, + }); + mockUsersFindOne.mockResolvedValue({ _id: baseUser._id }); + + const result = await service.canAccessObject(baseRoom as any, baseUser as any, AbacAccessOperation.READ, AbacObjectType.ROOM); + + expect(result).toBe(true); + expect(mockUsersFindOne).toHaveBeenCalled(); + expect(mockSubscriptionsSetAbacLastTimeCheckedByUserIdAndRoomId).toHaveBeenCalled(); + }); + + it('always evaluates when cache TTL is 0 (disabled)', async () => { + mockSettingsGetValueById.mockResolvedValue(0); + const recent = new Date(); // would be valid if TTL > 0 + mockSubscriptionsFindOneByRoomIdAndUserId.mockResolvedValue({ + _id: 'SUB', + abacLastTimeChecked: recent, + }); + mockUsersFindOne.mockResolvedValue({ _id: baseUser._id }); + + const result = await service.canAccessObject(baseRoom as any, baseUser as any, AbacAccessOperation.READ, AbacObjectType.ROOM); + + expect(result).toBe(true); + expect(mockUsersFindOne).toHaveBeenCalledTimes(1); + expect(mockSubscriptionsSetAbacLastTimeCheckedByUserIdAndRoomId).toHaveBeenCalledTimes(1); + }); + + it('returns false (non-compliant) after cache expiry without updating lastTime', async () => { + mockSettingsGetValueById.mockResolvedValue(10); // 10s TTL + const expired = new Date(Date.now() - 15_000); + mockSubscriptionsFindOneByRoomIdAndUserId.mockResolvedValue({ + _id: 'SUB', + abacLastTimeChecked: expired, + }); + mockUsersFindOne.mockResolvedValue(null); // not compliant + mockUsersFindOneById.mockResolvedValue(null); // user not found path (no removal) + + const result = await service.canAccessObject(baseRoom as any, baseUser as any, AbacAccessOperation.READ, AbacObjectType.ROOM); + expect(result).toBe(false); + expect(mockUsersFindOne).toHaveBeenCalled(); + expect(mockUsersFindOneById).toHaveBeenCalledWith(baseUser._id); + expect(mockRoomRemoveUserFromRoom).not.toHaveBeenCalled(); + expect(mockSubscriptionsSetAbacLastTimeCheckedByUserIdAndRoomId).not.toHaveBeenCalled(); + }); + }); + + describe('query shape robustness', () => { + it('builds $and conditions proportional to number of room attributes', async () => { + mockSubscriptionsFindOneByRoomIdAndUserId.mockResolvedValue({ _id: 'SUB' }); + mockUsersFindOne.mockResolvedValue({ _id: baseUser._id }); + + await service.canAccessObject(baseRoom as any, baseUser as any, AbacAccessOperation.READ, AbacObjectType.ROOM); + + const [query] = mockUsersFindOne.mock.calls[0]; + expect(Array.isArray(query.$and)).toBe(true); + expect(query.$and).toHaveLength(baseRoom.abacAttributes.length); + expect(baseRoom.abacAttributes[0]).toEqual({ key: 'dept', values: ['eng', 'sales'] }); + }); + + it('handles single attribute room correctly', async () => { + const singleAttrRoom = { + ...baseRoom, + abacAttributes: [{ key: 'dept', values: ['eng'] }], + }; + mockSubscriptionsFindOneByRoomIdAndUserId.mockResolvedValue({ _id: 'SUB' }); + mockUsersFindOne.mockResolvedValue({ _id: baseUser._id }); + + await service.canAccessObject(singleAttrRoom as any, baseUser as any, AbacAccessOperation.READ, AbacObjectType.ROOM); + + const [query] = mockUsersFindOne.mock.calls[0]; + expect(query.$and).toHaveLength(1); + expect(query.$and[0].abacAttributes.$elemMatch.key).toBe('dept'); + expect(query.$and[0].abacAttributes.$elemMatch.values.$all).toEqual(['eng']); + }); + }); +}); diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index 4c1084f68b315..0b066e7906331 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -1,8 +1,9 @@ import { MeteorError, Room, ServiceClass } from '@rocket.chat/core-services'; import type { IAbacService } from '@rocket.chat/core-services'; -import type { IAbacAttribute, IAbacAttributeDefinition, IRoom, AtLeast } from '@rocket.chat/core-typings'; +import { AbacAccessOperation, AbacObjectType } from '@rocket.chat/core-typings'; +import type { IAbacAttribute, IAbacAttributeDefinition, IRoom, AtLeast, IUser } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; -import { Rooms, AbacAttributes, Users } from '@rocket.chat/models'; +import { Rooms, AbacAttributes, Users, Subscriptions, Settings } from '@rocket.chat/models'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import type { Document, UpdateFilter } from 'mongodb'; import pLimit from 'p-limit'; @@ -442,6 +443,17 @@ export class AbacService extends ServiceClass implements IAbacService { })); } + private buildCompliantConditions(attributes: IAbacAttributeDefinition[]) { + return attributes.map(({ key, values }) => ({ + abacAttributes: { + $elemMatch: { + key, + values: { $all: values }, + }, + }, + })); + } + async checkUsernamesMatchAttributes(usernames: string[], attributes: IAbacAttributeDefinition[]): Promise { if (!usernames.length || !attributes.length) { return; @@ -537,6 +549,72 @@ export class AbacService extends ServiceClass implements IAbacService { }); } } + + async canAccessObject( + room: Pick, + user: Pick, + action: AbacAccessOperation, + objectType: AbacObjectType, + ) { + // We may need this flex for phase 2, but for now only ROOM/READ is supported + if (objectType !== AbacObjectType.ROOM) { + throw new Error('error-abac-unsupported-object-type'); + } + + if (action !== AbacAccessOperation.READ) { + throw new Error('error-abac-unsupported-operation'); + } + + if (!user?._id || !room?.abacAttributes?.length) { + return false; + } + + const decisionCacheTimeout = (await Settings.getValueById('Abac_Cache_Decision_Time_Seconds')) as number; + const userSub = await Subscriptions.findOneByRoomIdAndUserId(room._id, user._id, { projection: { abacLastTimeChecked: 1 } }); + if (!userSub) { + return false; + } + + // Cases: + // 1) Never checked before -> check now + // 2) Checked before, but cache expired -> check now + // 3) Checked before, and cache valid -> use cached decision (subsciprtion exists) + // 4) Cache disabled (0) -> always check + if ( + decisionCacheTimeout > 0 && + userSub.abacLastTimeChecked && + Date.now() - userSub.abacLastTimeChecked.getTime() < decisionCacheTimeout * 1000 + ) { + this.logger.debug({ msg: 'Using cached ABAC decision', userId: user._id, roomId: room._id }); + return !!userSub; + } + + const isUserCompliant = await Users.findOne( + { + _id: user._id, + $and: this.buildCompliantConditions(room.abacAttributes), + }, + { projection: { _id: 1 } }, + ); + + if (!isUserCompliant) { + const fullUser = await Users.findOneById(user._id); + if (!fullUser) { + return false; + } + + // When a user is not compliant, remove them from the room automatically + await Room.removeUserFromRoom(room._id, fullUser, { + skipAppPreEvents: true, + customSystemMessage: 'abac-removed-user-from-room' as const, + }); + return false; + } + + // Set last time the decision was made + await Subscriptions.setAbacLastTimeCheckedByUserIdAndRoomId(user._id, room._id, new Date()); + return true; + } } export default AbacService; diff --git a/packages/core-services/src/types/IAbacService.ts b/packages/core-services/src/types/IAbacService.ts index 8dda2f7f17727..26da77c7ff5b0 100644 --- a/packages/core-services/src/types/IAbacService.ts +++ b/packages/core-services/src/types/IAbacService.ts @@ -1,4 +1,11 @@ -import type { IAbacAttributeDefinition, IAbacAttribute } from '@rocket.chat/core-typings'; +import type { + IAbacAttributeDefinition, + IAbacAttribute, + IRoom, + IUser, + AbacAccessOperation, + AbacObjectType, +} from '@rocket.chat/core-typings'; export interface IAbacService { addAbacAttribute(attribute: IAbacAttributeDefinition): Promise; @@ -18,4 +25,10 @@ export interface IAbacService { addRoomAbacAttributeByKey(rid: string, key: string, values: string[]): Promise; replaceRoomAbacAttributeByKey(rid: string, key: string, values: string[]): Promise; checkUsernamesMatchAttributes(usernames: string[], attributes: IAbacAttributeDefinition[]): Promise; + canAccessObject( + room: Pick, + user: Pick, + action: AbacAccessOperation, + objectType: AbacObjectType, + ): Promise; } diff --git a/packages/core-services/src/types/IAuthorization.ts b/packages/core-services/src/types/IAuthorization.ts index 0176db5c9a9f0..d4bb1c1c67d4f 100644 --- a/packages/core-services/src/types/IAuthorization.ts +++ b/packages/core-services/src/types/IAuthorization.ts @@ -1,7 +1,7 @@ import type { IRoom, IUser, IRole } from '@rocket.chat/core-typings'; export type RoomAccessValidator = ( - room?: Pick, + room?: Pick, user?: Pick, extraData?: Record, ) => Promise; diff --git a/packages/core-typings/src/Abac.ts b/packages/core-typings/src/Abac.ts new file mode 100644 index 0000000000000..a4a9cbad6962e --- /dev/null +++ b/packages/core-typings/src/Abac.ts @@ -0,0 +1,9 @@ +export enum AbacAccessOperation { + READ = 'read', + WRITE = 'write', +} + +export enum AbacObjectType { + ROOM = 'room', + // Just room for now :) +} diff --git a/packages/core-typings/src/ISubscription.ts b/packages/core-typings/src/ISubscription.ts index 742f63dac9c39..7bf206740fdcc 100644 --- a/packages/core-typings/src/ISubscription.ts +++ b/packages/core-typings/src/ISubscription.ts @@ -72,6 +72,8 @@ export interface ISubscription extends IRocketChatRecord { customFields?: Record; oldRoomKeys?: OldKey[]; suggestedOldRoomKeys?: OldKey[]; + + abacLastTimeChecked?: Date; } export interface IOmnichannelSubscription extends ISubscription { diff --git a/packages/core-typings/src/index.ts b/packages/core-typings/src/index.ts index ec6daafc95e44..7334f522b309c 100644 --- a/packages/core-typings/src/index.ts +++ b/packages/core-typings/src/index.ts @@ -148,5 +148,6 @@ export * as Cloud from './cloud'; export * from './themes'; export * from './mediaCalls'; export * from './IAbacAttribute'; +export * from './Abac'; export { schemas } from './Ajv'; diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 920d0dd53559f..cf5e2744234f2 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -14,6 +14,8 @@ "ABAC": "Attribute Based Access Control", "ABAC_Enabled": "Enable Attribute Based Access Control (ABAC)", "ABAC_Enabled_Description": "Controls access to rooms based on user and room attributes.", + "Abac_Cache_Decision_Time_Seconds": "ABAC Cache Decision Time (seconds)", + "Abac_Cache_Decision_Time_Seconds_Description": "Time in seconds to cache access control decisions. Setting this value to 0 will disable caching.", "ABAC_Enabled_callout": "User attributes are synchronized via LDAP. <1>Learn more", "ABAC_Learn_More": "Learn about ABAC", "ABAC_automatically_disabled_callout": "ABAC automatically disabled", diff --git a/packages/model-typings/src/models/ISubscriptionsModel.ts b/packages/model-typings/src/models/ISubscriptionsModel.ts index 121da7e8ad1c3..7035d44b90d47 100644 --- a/packages/model-typings/src/models/ISubscriptionsModel.ts +++ b/packages/model-typings/src/models/ISubscriptionsModel.ts @@ -336,4 +336,5 @@ export interface ISubscriptionsModel extends IBaseModel { setE2EKeyByUserIdAndRoomId(userId: string, rid: string, key: string): Promise>; countUsersInRoles(roles: IRole['_id'][], rid: IRoom['_id'] | undefined): Promise; findUserFederatedRoomIds(userId: IUser['_id']): AggregationCursor<{ _id: IRoom['_id']; externalRoomId: string }>; + setAbacLastTimeCheckedByUserIdAndRoomId(userId: string, roomId: string, time: Date): Promise; } diff --git a/packages/models/src/models/Subscriptions.ts b/packages/models/src/models/Subscriptions.ts index dbd72d644cf11..26c3a44ab607d 100644 --- a/packages/models/src/models/Subscriptions.ts +++ b/packages/models/src/models/Subscriptions.ts @@ -2085,4 +2085,19 @@ export class SubscriptionsRaw extends BaseRaw implements ISubscri }, ]); } + + setAbacLastTimeCheckedByUserIdAndRoomId(userId: string, roomId: string, time: Date): Promise { + const query = { + 'rid': roomId, + 'u._id': userId, + }; + + const update: UpdateFilter = { + $set: { + abacLastTimeChecked: time, + }, + }; + + return this.updateOne(query, update); + } }