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
7 changes: 0 additions & 7 deletions ee/packages/abac/src/can-access-object.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,20 +169,13 @@ 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);

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 () => {
Expand Down
198 changes: 63 additions & 135 deletions ee/packages/abac/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -25,34 +23,33 @@ 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);

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<void> => {
const { value } = setting;
Expand All @@ -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<void> {
this.decisionCacheTimeout = await Settings.get<number>('Abac_Cache_Decision_Time_Seconds');
}
Expand Down Expand Up @@ -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);
}
}

Expand Down Expand Up @@ -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<void> {
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<string>(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<IRoom, '_id' | 't' | 'teamId' | 'prid' | 'abacAttributes'>,
user: Pick<IUser, '_id'>,
Expand All @@ -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<void> {
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<IRoom, '_id'>, user: IUser, reason: AbacAuditReason): Promise<void> {
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(
Expand All @@ -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,
});
Expand All @@ -587,94 +564,45 @@ 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,
});
}
}

private async removeUserFromRoom(room: AtLeast<IRoom, '_id'>, user: IUser, reason: AbacAuditReason): Promise<void> {
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<IRoom>, user: IUser, reason: AbacAuditReason): Promise<void> {
const removalPromises: Promise<void>[] = [];
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<void> {
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,
});
}
}
}

export { LocalPDP, ExternalPDP } from './pdp';
export type { IPolicyDecisionPoint } from './pdp';

Comment thread
KevLehman marked this conversation as resolved.
export default AbacService;
3 changes: 3 additions & 0 deletions ee/packages/abac/src/logger.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { Logger } from '@rocket.chat/logger';

export const logger = new Logger('AbacService');
29 changes: 29 additions & 0 deletions ee/packages/abac/src/pdp/ExternalPDP.ts
Original file line number Diff line number Diff line change
@@ -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<IRoom, '_id' | 'abacAttributes'>,
_user: AtLeast<IUser, '_id'>,
_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<void> {
throw new Error('ExternalPDP: checkUsernamesMatchAttributes not implemented');
}

async onRoomAttributesChanged(
_room: AtLeast<IRoom, '_id' | 't' | 'teamMain' | 'abacAttributes'>,
_newAttributes: IAbacAttributeDefinition[],
): Promise<IUser[]> {
throw new Error('ExternalPDP: onRoomAttributesChanged not implemented');
}

async onSubjectAttributesChanged(_user: IUser, _next: IAbacAttributeDefinition[]): Promise<IRoom[]> {
throw new Error('ExternalPDP: onSubjectAttributesChanged not implemented');
}
}
Loading
Loading