diff --git a/ee/packages/abac/src/can-access-object.spec.ts b/ee/packages/abac/src/can-access-object.spec.ts index 078bd6d89d3d1..49caf2ba1e147 100644 --- a/ee/packages/abac/src/can-access-object.spec.ts +++ b/ee/packages/abac/src/can-access-object.spec.ts @@ -169,8 +169,6 @@ describe('AbacService.canAccessObject (unit)', () => { abacLastTimeChecked: within, }); - const internalLogger = (service as any).logger; - const loggerDebug = jest.spyOn(internalLogger, 'debug').mockImplementation(() => undefined); service.decisionCacheTimeout = ttlSeconds; const result = await service.canAccessObject(baseRoom as any, baseUser as any, AbacAccessOperation.READ, AbacObjectType.ROOM); @@ -178,11 +176,6 @@ describe('AbacService.canAccessObject (unit)', () => { 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 () => { diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index d471fed70a756..a60df806e782a 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -8,13 +8,11 @@ import type { AtLeast, IUser, ILDAPEntry, - ISubscription, AbacAuditReason, } from '@rocket.chat/core-typings'; -import { Logger } from '@rocket.chat/logger'; import { Rooms, AbacAttributes, Users, Subscriptions } from '@rocket.chat/models'; import { escapeRegExp } from '@rocket.chat/string-helpers'; -import type { Document, FindCursor, UpdateFilter } from 'mongodb'; +import type { Document, UpdateFilter } from 'mongodb'; import pLimit from 'p-limit'; import { Audit } from './audit'; @@ -25,20 +23,19 @@ import { AbacInvalidAttributeValuesError, AbacUnsupportedObjectTypeError, AbacUnsupportedOperationError, - OnlyCompliantCanBeAddedToRoomError, } from './errors'; import { getAbacRoom, diffAttributes, extractAttribute, diffAttributeSets, - buildCompliantConditions, - buildNonCompliantConditions, validateAndNormalizeAttributes, ensureAttributeDefinitionsExist, - buildRoomNonCompliantConditionsFromSubject, MAX_ABAC_ATTRIBUTE_KEYS, } from './helper'; +import { logger } from './logger'; +import type { IPolicyDecisionPoint } from './pdp'; +import { LocalPDP, ExternalPDP } from './pdp'; // Limit concurrent user removals to avoid overloading the server with too many operations at once const limit = pLimit(20); @@ -46,13 +43,13 @@ const limit = pLimit(20); export class AbacService extends ServiceClass implements IAbacService { protected name = 'abac'; - protected logger: Logger; + private pdp!: IPolicyDecisionPoint; decisionCacheTimeout = 60; // seconds constructor() { super(); - this.logger = new Logger('AbacService'); + this.setPdpStrategy('local'); this.onSettingChanged('Abac_Cache_Decision_Time_Seconds', async ({ setting }): Promise => { const { value } = setting; @@ -63,6 +60,18 @@ export class AbacService extends ServiceClass implements IAbacService { }); } + setPdpStrategy(strategy: 'local' | 'external'): void { + switch (strategy) { + case 'external': + this.pdp = new ExternalPDP(); + break; + case 'local': + default: + this.pdp = new LocalPDP(); + break; + } + } + override async started(): Promise { this.decisionCacheTimeout = await Settings.get('Abac_Cache_Decision_Time_Seconds'); } @@ -335,7 +344,7 @@ export class AbacService extends ServiceClass implements IAbacService { const previous: IAbacAttributeDefinition[] = room.abacAttributes || []; if (diffAttributeSets(previous, normalized).added) { - await this.onRoomAttributesChanged(room, (updated?.abacAttributes as IAbacAttributeDefinition[] | undefined) ?? normalized); + await this.onRoomAttributesChanged(room, updated?.abacAttributes ?? normalized); } } @@ -477,46 +486,6 @@ export class AbacService extends ServiceClass implements IAbacService { await this.onRoomAttributesChanged(room, updated?.abacAttributes || []); } - async checkUsernamesMatchAttributes(usernames: string[], attributes: IAbacAttributeDefinition[], object: IRoom): Promise { - if (!usernames.length || !attributes.length) { - return; - } - - const nonCompliantUsersFromList = await Users.find( - { - username: { $in: usernames }, - $or: buildNonCompliantConditions(attributes), - }, - { projection: { username: 1 } }, - ) - .map((u) => u.username as string) - .toArray(); - - const nonCompliantSet = new Set(nonCompliantUsersFromList); - - if (nonCompliantSet.size) { - throw new OnlyCompliantCanBeAddedToRoomError(); - } - - usernames.forEach((username) => { - // TODO: Add room name - void Audit.actionPerformed({ username }, { _id: object._id, name: object.name }, 'system', 'granted-object-access'); - }); - } - - private shouldUseCache(decisionCacheTimeout: number, userSub: ISubscription) { - // 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 - return ( - decisionCacheTimeout > 0 && - userSub.abacLastTimeChecked && - Date.now() - userSub.abacLastTimeChecked.getTime() < decisionCacheTimeout * 1000 - ); - } - async canAccessObject( room: Pick, user: Pick, @@ -541,34 +510,42 @@ export class AbacService extends ServiceClass implements IAbacService { return false; } - if (this.shouldUseCache(this.decisionCacheTimeout, userSub)) { - this.logger.debug({ msg: 'Using cached ABAC decision', userId: user._id, roomId: room._id }); - return !!userSub; + const decision = await this.pdp.canAccessObject(room, user, userSub, this.decisionCacheTimeout); + + if (decision.userToRemove) { + // When a user is not compliant, remove them from the room automatically + await this.removeUserFromRoom(room, decision.userToRemove, 'realtime-policy-eval'); } - const isUserCompliant = await Users.findOne( - { - _id: user._id, - $and: buildCompliantConditions(room.abacAttributes), - }, - { projection: { _id: 1 } }, - ); + return decision.granted; + } - if (!isUserCompliant) { - const fullUser = await Users.findOneById(user._id); - if (!fullUser) { - return false; - } + async checkUsernamesMatchAttributes(usernames: string[], attributes: IAbacAttributeDefinition[], object: IRoom): Promise { + if (!usernames.length || !attributes.length) { + return; + } - // When a user is not compliant, remove them from the room automatically - await this.removeUserFromRoom(room, fullUser, 'realtime-policy-eval'); + await this.pdp.checkUsernamesMatchAttributes(usernames, attributes, object); - return false; - } + usernames.forEach((username) => { + void Audit.actionPerformed({ username }, { _id: object._id, name: object.name }, 'system', 'granted-object-access'); + }); + } - // Set last time the decision was made - await Subscriptions.setAbacLastTimeCheckedByUserIdAndRoomId(user._id, room._id, new Date()); - return true; + private async removeUserFromRoom(room: AtLeast, user: IUser, reason: AbacAuditReason): Promise { + return Room.removeUserFromRoom(room._id, user, { + skipAppPreEvents: true, + customSystemMessage: 'abac-removed-user-from-room' as const, + }) + .then(() => void Audit.actionPerformed({ _id: user._id, username: user.username }, { _id: room._id, name: room.name }, reason)) + .catch((err) => { + logger.error({ + msg: 'Failed to remove user from ABAC room', + rid: room._id, + err, + reason, + }); + }); } protected async onRoomAttributesChanged( @@ -578,7 +555,7 @@ export class AbacService extends ServiceClass implements IAbacService { const rid = room._id; if (!newAttributes?.length) { // When a room has no ABAC attributes, it becomes a normal private group and no user removal is necessary - this.logger.warn({ + logger.warn({ msg: 'Room ABAC attributes removed. Room is not abac managed anymore', rid, }); @@ -587,27 +564,15 @@ export class AbacService extends ServiceClass implements IAbacService { } try { - const query = { - __rooms: rid, - $or: buildNonCompliantConditions(newAttributes), - }; - - const cursor = Users.find(query, { projection: { __rooms: 0 } }); - - const usersToRemove: string[] = []; - const userRemovalPromises = []; - for await (const doc of cursor) { - usersToRemove.push(doc._id); - userRemovalPromises.push(limit(() => this.removeUserFromRoom(room, doc, 'room-attributes-change'))); - } + const nonCompliantUsers = await this.pdp.onRoomAttributesChanged(room, newAttributes); - if (!usersToRemove.length) { + if (!nonCompliantUsers.length) { return; } - await Promise.all(userRemovalPromises); + await Promise.all(nonCompliantUsers.map((user) => limit(() => this.removeUserFromRoom(room, user, 'room-attributes-change')))); } catch (err) { - this.logger.error({ + logger.error({ msg: 'Failed to re-evaluate room subscriptions after ABAC attributes changed', rid, err, @@ -615,61 +580,21 @@ export class AbacService extends ServiceClass implements IAbacService { } } - private async removeUserFromRoom(room: AtLeast, user: IUser, reason: AbacAuditReason): Promise { - return Room.removeUserFromRoom(room._id, user, { - skipAppPreEvents: true, - customSystemMessage: 'abac-removed-user-from-room' as const, - }) - .then(() => void Audit.actionPerformed({ _id: user._id, username: user.username }, { _id: room._id, name: room.name }, reason)) - .catch((err) => { - this.logger.error({ - msg: 'Failed to remove user from ABAC room', - rid: room._id, - err, - reason, - }); - }); - } - - private async removeUserFromRoomList(roomList: FindCursor, user: IUser, reason: AbacAuditReason): Promise { - const removalPromises: Promise[] = []; - for await (const room of roomList) { - removalPromises.push(limit(() => this.removeUserFromRoom(room, user, reason))); - } - - await Promise.all(removalPromises); - } - protected async onSubjectAttributesChanged(user: IUser, _next: IAbacAttributeDefinition[]): Promise { if (!user?._id || !Array.isArray(user.__rooms) || !user.__rooms.length) { return; } - const roomIds = user.__rooms; try { - // No attributes: no rooms :( - if (!_next.length) { - const cursor = Rooms.find( - { - _id: { $in: roomIds }, - abacAttributes: { $exists: true, $ne: [] }, - }, - { projection: { _id: 1 } }, - ); - - return await this.removeUserFromRoomList(cursor, user, 'ldap-sync'); - } + const nonCompliantRooms = await this.pdp.onSubjectAttributesChanged(user, _next); - const query = { - _id: { $in: roomIds }, - $or: buildRoomNonCompliantConditionsFromSubject(_next), - }; - - const cursor = Rooms.find(query, { projection: { _id: 1 } }); + if (!nonCompliantRooms.length) { + return; + } - return await this.removeUserFromRoomList(cursor, user, 'ldap-sync'); + await Promise.all(nonCompliantRooms.map((room) => limit(() => this.removeUserFromRoom(room, user, 'ldap-sync')))); } catch (err) { - this.logger.error({ + logger.error({ msg: 'Failed to query and remove user from non-compliant ABAC rooms', err, }); @@ -677,4 +602,7 @@ export class AbacService extends ServiceClass implements IAbacService { } } +export { LocalPDP, ExternalPDP } from './pdp'; +export type { IPolicyDecisionPoint } from './pdp'; + export default AbacService; diff --git a/ee/packages/abac/src/logger.ts b/ee/packages/abac/src/logger.ts new file mode 100644 index 0000000000000..138640ba368ec --- /dev/null +++ b/ee/packages/abac/src/logger.ts @@ -0,0 +1,3 @@ +import { Logger } from '@rocket.chat/logger'; + +export const logger = new Logger('AbacService'); diff --git a/ee/packages/abac/src/pdp/ExternalPDP.ts b/ee/packages/abac/src/pdp/ExternalPDP.ts new file mode 100644 index 0000000000000..55ba1113ff3de --- /dev/null +++ b/ee/packages/abac/src/pdp/ExternalPDP.ts @@ -0,0 +1,29 @@ +import type { IAbacAttributeDefinition, IRoom, IUser, AtLeast, ISubscription } from '@rocket.chat/core-typings'; + +import type { IPolicyDecisionPoint } from './types'; + +export class ExternalPDP implements IPolicyDecisionPoint { + async canAccessObject( + _room: AtLeast, + _user: AtLeast, + _userSub: ISubscription, + _decisionCacheTimeout: number, + ): Promise<{ granted: boolean; userToRemove?: IUser }> { + throw new Error('ExternalPDP: canAccessObject not implemented'); + } + + async checkUsernamesMatchAttributes(_usernames: string[], _attributes: IAbacAttributeDefinition[], _object: IRoom): Promise { + throw new Error('ExternalPDP: checkUsernamesMatchAttributes not implemented'); + } + + async onRoomAttributesChanged( + _room: AtLeast, + _newAttributes: IAbacAttributeDefinition[], + ): Promise { + throw new Error('ExternalPDP: onRoomAttributesChanged not implemented'); + } + + async onSubjectAttributesChanged(_user: IUser, _next: IAbacAttributeDefinition[]): Promise { + throw new Error('ExternalPDP: onSubjectAttributesChanged not implemented'); + } +} diff --git a/ee/packages/abac/src/pdp/LocalPDP.ts b/ee/packages/abac/src/pdp/LocalPDP.ts new file mode 100644 index 0000000000000..d1ed31f909752 --- /dev/null +++ b/ee/packages/abac/src/pdp/LocalPDP.ts @@ -0,0 +1,109 @@ +import type { IAbacAttributeDefinition, IRoom, AtLeast, IUser, ISubscription } from '@rocket.chat/core-typings'; +import { Rooms, Users, Subscriptions } from '@rocket.chat/models'; + +import { OnlyCompliantCanBeAddedToRoomError } from '../errors'; +import { buildCompliantConditions, buildNonCompliantConditions, buildRoomNonCompliantConditionsFromSubject } from '../helper'; +import { logger } from '../logger'; +import type { IPolicyDecisionPoint } from './types'; + +const pdpLogger = logger.section('LocalPDP'); + +export class LocalPDP implements IPolicyDecisionPoint { + private shouldUseCache(decisionCacheTimeout: number, userSub: ISubscription) { + // 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 + return ( + decisionCacheTimeout > 0 && + userSub.abacLastTimeChecked && + Date.now() - userSub.abacLastTimeChecked.getTime() < decisionCacheTimeout * 1000 + ); + } + + async canAccessObject( + room: AtLeast, + user: AtLeast, + userSub: ISubscription, + decisionCacheTimeout: number, + ): Promise<{ granted: boolean; userToRemove?: IUser }> { + if (this.shouldUseCache(decisionCacheTimeout, userSub)) { + pdpLogger.debug({ msg: 'Using cached ABAC decision', userId: user._id, roomId: room._id }); + return { granted: !!userSub }; + } + + const isUserCompliant = await Users.findOne( + { + _id: user._id, + $and: buildCompliantConditions(room.abacAttributes ?? []), + }, + { projection: { _id: 1 } }, + ); + + if (!isUserCompliant) { + const fullUser = await Users.findOneById(user._id); + if (!fullUser) { + return { granted: false }; + } + + return { granted: false, userToRemove: fullUser }; + } + + // Set last time the decision was made + await Subscriptions.setAbacLastTimeCheckedByUserIdAndRoomId(user._id, room._id, new Date()); + return { granted: true }; + } + + async onRoomAttributesChanged( + room: AtLeast, + newAttributes: IAbacAttributeDefinition[], + ): Promise { + const query = { + __rooms: room._id, + $or: buildNonCompliantConditions(newAttributes), + }; + + return Users.find(query, { projection: { __rooms: 0 } }).toArray(); + } + + async onSubjectAttributesChanged(user: IUser, _next: IAbacAttributeDefinition[]): Promise { + const roomIds = user.__rooms; + + // No attributes: no rooms :( + if (!_next.length) { + return Rooms.find( + { + _id: { $in: roomIds }, + abacAttributes: { $exists: true, $ne: [] }, + }, + { projection: { _id: 1 } }, + ).toArray(); + } + + const query = { + _id: { $in: roomIds }, + $or: buildRoomNonCompliantConditionsFromSubject(_next), + }; + + return Rooms.find(query, { projection: { _id: 1 } }).toArray(); + } + + async checkUsernamesMatchAttributes(usernames: string[], attributes: IAbacAttributeDefinition[], _object: IRoom): Promise { + const nonCompliantUsersFromList = await Users.find( + { + username: { $in: usernames }, + $or: buildNonCompliantConditions(attributes), + }, + { projection: { username: 1 } }, + ) + .map((u) => u.username as string) + .toArray(); + + const nonCompliantSet = new Set(nonCompliantUsersFromList); + + if (nonCompliantSet.size) { + throw new OnlyCompliantCanBeAddedToRoomError(); + } + } +} diff --git a/ee/packages/abac/src/pdp/index.ts b/ee/packages/abac/src/pdp/index.ts new file mode 100644 index 0000000000000..210941a411dbc --- /dev/null +++ b/ee/packages/abac/src/pdp/index.ts @@ -0,0 +1,3 @@ +export { LocalPDP } from './LocalPDP'; +export { ExternalPDP } from './ExternalPDP'; +export type { IPolicyDecisionPoint } from './types'; diff --git a/ee/packages/abac/src/pdp/types.ts b/ee/packages/abac/src/pdp/types.ts new file mode 100644 index 0000000000000..27ea14fa51c74 --- /dev/null +++ b/ee/packages/abac/src/pdp/types.ts @@ -0,0 +1,19 @@ +import type { IAbacAttributeDefinition, IRoom, IUser, AtLeast, ISubscription } from '@rocket.chat/core-typings'; + +export interface IPolicyDecisionPoint { + canAccessObject( + room: AtLeast, + user: AtLeast, + userSub: ISubscription, + decisionCacheTimeout: number, + ): Promise<{ granted: boolean; userToRemove?: IUser }>; + + checkUsernamesMatchAttributes(usernames: string[], attributes: IAbacAttributeDefinition[], object: IRoom): Promise; + + onRoomAttributesChanged( + room: AtLeast, + newAttributes: IAbacAttributeDefinition[], + ): Promise; + + onSubjectAttributesChanged(user: IUser, next: IAbacAttributeDefinition[]): Promise; +} diff --git a/ee/packages/abac/src/user-auto-removal.spec.ts b/ee/packages/abac/src/user-auto-removal.spec.ts index eabe4ffe4acde..5c26a50ef248e 100644 --- a/ee/packages/abac/src/user-auto-removal.spec.ts +++ b/ee/packages/abac/src/user-auto-removal.spec.ts @@ -4,6 +4,7 @@ import type { Collection, Db } from 'mongodb'; import { Audit } from './audit'; import { AbacService } from './index'; +import { logger } from './logger'; import { acquireSharedInMemoryMongo, SHARED_ABAC_TEST_DB, type SharedMongoConnection } from './test-helpers/mongoMemoryServer'; jest.mock('@rocket.chat/core-services', () => ({ @@ -213,7 +214,7 @@ describe('AbacService integration (onRoomAttributesChanged)', () => { sharedMongo = await acquireSharedInMemoryMongo(SHARED_ABAC_TEST_DB); db = sharedMongo.db; - debugSpy = jest.spyOn((service as any).logger, 'debug').mockImplementation(() => undefined); + debugSpy = jest.spyOn(logger, 'debug').mockImplementation(() => undefined); auditSpy = jest.spyOn(Audit, 'actionPerformed').mockResolvedValue(); roomsCol = db.collection('rocketchat_room');