From 8a2a37ebef60627d9a7b4027111a61315cb31962 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 29 Sep 2025 08:25:37 -0600 Subject: [PATCH 001/125] abac license module --- packages/core-typings/src/license/LicenseModule.ts | 1 + packages/jwt/__tests__/jwt.spec.ts | 1 + 2 files changed, 2 insertions(+) diff --git a/packages/core-typings/src/license/LicenseModule.ts b/packages/core-typings/src/license/LicenseModule.ts index 6174d5542dd8d..5267817164db6 100644 --- a/packages/core-typings/src/license/LicenseModule.ts +++ b/packages/core-typings/src/license/LicenseModule.ts @@ -23,6 +23,7 @@ export const CoreModules = [ 'contact-id-verification', 'teams-voip', 'outbound-messaging', + 'abac', ] as const; export type InternalModuleName = (typeof CoreModules)[number]; diff --git a/packages/jwt/__tests__/jwt.spec.ts b/packages/jwt/__tests__/jwt.spec.ts index 2c96a394dd5f4..c5398985544d8 100644 --- a/packages/jwt/__tests__/jwt.spec.ts +++ b/packages/jwt/__tests__/jwt.spec.ts @@ -54,6 +54,7 @@ it('should sign and verify a jwt with RS256', async () => { { module: 'outlook-calendar' }, { module: 'unlimited-presence' }, { module: 'outbound-messaging' }, + { module: 'abac' }, ], limits: { activeUsers: [ From 518e5b958e834953433f2957748b925a689baeee Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 29 Sep 2025 10:25:26 -0600 Subject: [PATCH 002/125] abac global enable setting --- apps/meteor/ee/server/configuration/abac.ts | 8 ++++++++ apps/meteor/ee/server/configuration/index.ts | 1 + apps/meteor/ee/server/settings/abac.ts | 19 +++++++++++++++++++ 3 files changed, 28 insertions(+) create mode 100644 apps/meteor/ee/server/configuration/abac.ts create mode 100644 apps/meteor/ee/server/settings/abac.ts diff --git a/apps/meteor/ee/server/configuration/abac.ts b/apps/meteor/ee/server/configuration/abac.ts new file mode 100644 index 0000000000000..cdb02f1064d2c --- /dev/null +++ b/apps/meteor/ee/server/configuration/abac.ts @@ -0,0 +1,8 @@ +import { License } from '@rocket.chat/license'; + +Meteor.startup(async () => { + await License.onLicense('abac', async () => { + const { addSettings } = await import('../settings/abac'); + await addSettings(); + }); +}); diff --git a/apps/meteor/ee/server/configuration/index.ts b/apps/meteor/ee/server/configuration/index.ts index 6469ef287d832..8e422c47cc139 100644 --- a/apps/meteor/ee/server/configuration/index.ts +++ b/apps/meteor/ee/server/configuration/index.ts @@ -5,3 +5,4 @@ import './outlookCalendar'; import './saml'; import './videoConference'; import './voip'; +import './abac'; diff --git a/apps/meteor/ee/server/settings/abac.ts b/apps/meteor/ee/server/settings/abac.ts new file mode 100644 index 0000000000000..9e7e95620e259 --- /dev/null +++ b/apps/meteor/ee/server/settings/abac.ts @@ -0,0 +1,19 @@ +import { settingsRegistry } from '../../../app/settings/server'; + +export function addSettings(): void { + void settingsRegistry.addGroup('ABAC', async function () { + await this.with( + { + enterprise: true, + modules: ['abac'], + }, + async function () { + await this.add('ABAC_Enabled', false, { + type: 'boolean', + public: true, + invalidValue: false, + }); + }, + ); + }); +} From eab8a9bd96cf19f0b440b35bad31e794f631afea Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 29 Sep 2025 13:18:36 -0600 Subject: [PATCH 003/125] abac package and endpoints --- apps/meteor/ee/server/api/abac.ts | 45 ++++++++++++++++++++++++++ apps/meteor/ee/server/api/index.ts | 1 + apps/meteor/ee/server/settings/abac.ts | 3 +- apps/meteor/package.json | 1 + ee/packages/abac/.eslintrc.json | 4 +++ ee/packages/abac/jest.config.ts | 6 ++++ ee/packages/abac/package.json | 40 +++++++++++++++++++++++ ee/packages/abac/src/index.ts | 3 ++ ee/packages/abac/tsconfig.json | 9 ++++++ yarn.lock | 17 ++++++++++ 10 files changed, 128 insertions(+), 1 deletion(-) create mode 100644 apps/meteor/ee/server/api/abac.ts create mode 100644 ee/packages/abac/.eslintrc.json create mode 100644 ee/packages/abac/jest.config.ts create mode 100644 ee/packages/abac/package.json create mode 100644 ee/packages/abac/src/index.ts create mode 100644 ee/packages/abac/tsconfig.json diff --git a/apps/meteor/ee/server/api/abac.ts b/apps/meteor/ee/server/api/abac.ts new file mode 100644 index 0000000000000..9171eac27b1ad --- /dev/null +++ b/apps/meteor/ee/server/api/abac.ts @@ -0,0 +1,45 @@ +import { API } from '../../../app/api/server'; +import type { ExtractRoutesFromAPI } from '../../../app/api/server/ApiClass'; + +const abacEndpoints = API.v1 + // enable/disable for room + .post('abac/rooms/:rid', { authRequired: true, permissionsRequired: [], response: {}, license: ['abac'] }, async function action() {}) + // add attributes for a room (bulk) + .post( + 'abac/room/:rid/attributes', + { authRequired: true, permissionsRequired: [], response: {}, license: ['abac'] }, + async function action() {}, + ) + // edit a room attribute + .put( + 'abac/room/:rid/attributes/:key', + { authRequired: true, permissionsRequired: [], response: {}, license: ['abac'] }, + async function action() {}, + ) + // delete a room attribute + .delete( + 'abac/room/:rid/attributes/:key', + { authRequired: true, permissionsRequired: [], response: {}, license: ['abac'] }, + async function action() {}, + ) + // attribute endpoints + // list attributes + .get('abac/attributes', { authRequired: true, permissionsRequired: [], response: {}, license: ['abac'] }, async function action() {}) + // create attribute + .post('abac/attributes', { authRequired: true, permissionsRequired: [], response: {}, license: ['abac'] }, async function action() {}) + // edit attribute and values (do not allow to modify attribute value if in use) + .put('abac/attributes/:key', { authRequired: true, permissionsRequired: [], response: {}, license: ['abac'] }, async function action() {}) + // delete attribute (only if not in use) + .delete( + 'abac/attributes/:key', + { authRequired: true, permissionsRequired: [], response: {}, license: ['abac'] }, + async function action() {}, + ) + // check if attribute is in use + .get( + 'abac/attributes/:key/is-in-use', + { authRequired: true, permissionsRequired: [], response: {}, license: ['abac'] }, + async function action() {}, + ); + +export type AbacEndpoints = ExtractRoutesFromAPI; diff --git a/apps/meteor/ee/server/api/index.ts b/apps/meteor/ee/server/api/index.ts index 93a4c99d8f1b8..35e8b0f0f60df 100644 --- a/apps/meteor/ee/server/api/index.ts +++ b/apps/meteor/ee/server/api/index.ts @@ -7,3 +7,4 @@ import './roles'; import '../apps/communication/uikit'; import './engagementDashboard'; import './audit'; +import './abac'; diff --git a/apps/meteor/ee/server/settings/abac.ts b/apps/meteor/ee/server/settings/abac.ts index 9e7e95620e259..e61c61f730c9b 100644 --- a/apps/meteor/ee/server/settings/abac.ts +++ b/apps/meteor/ee/server/settings/abac.ts @@ -1,7 +1,7 @@ import { settingsRegistry } from '../../../app/settings/server'; export function addSettings(): void { - void settingsRegistry.addGroup('ABAC', async function () { + void settingsRegistry.addGroup('General', async function () { await this.with( { enterprise: true, @@ -12,6 +12,7 @@ export function addSettings(): void { type: 'boolean', public: true, invalidValue: false, + section: 'ABAC', }); }, ); diff --git a/apps/meteor/package.json b/apps/meteor/package.json index 0d435094b6466..90415f059fc89 100644 --- a/apps/meteor/package.json +++ b/apps/meteor/package.json @@ -84,6 +84,7 @@ "@parse/node-apn": "^6.3.0", "@react-aria/toolbar": "^3.0.0-nightly.5042", "@react-pdf/renderer": "^3.4.5", + "@rocket.chat/abac": "workspace:^", "@rocket.chat/account-utils": "workspace:^", "@rocket.chat/agenda": "workspace:^", "@rocket.chat/api-client": "workspace:^", diff --git a/ee/packages/abac/.eslintrc.json b/ee/packages/abac/.eslintrc.json new file mode 100644 index 0000000000000..6744bc0544383 --- /dev/null +++ b/ee/packages/abac/.eslintrc.json @@ -0,0 +1,4 @@ +{ + "extends": ["@rocket.chat/eslint-config"], + "ignorePatterns": ["**/dist"] +} diff --git a/ee/packages/abac/jest.config.ts b/ee/packages/abac/jest.config.ts new file mode 100644 index 0000000000000..c18c8ae02465c --- /dev/null +++ b/ee/packages/abac/jest.config.ts @@ -0,0 +1,6 @@ +import server from '@rocket.chat/jest-presets/server'; +import type { Config } from 'jest'; + +export default { + preset: server.preset, +} satisfies Config; diff --git a/ee/packages/abac/package.json b/ee/packages/abac/package.json new file mode 100644 index 0000000000000..a8dbaeb245f12 --- /dev/null +++ b/ee/packages/abac/package.json @@ -0,0 +1,40 @@ +{ + "name": "@rocket.chat/abac", + "version": "0.0.1", + "private": true, + "description": "Rocket.Chat Enterprise - Attribute Based Access Control (ABAC) support utilities", + "main": "./dist/index.js", + "typings": "./dist/index.d.ts", + "files": [ + "/dist" + ], + "scripts": { + "build": "tsc", + "dev": "tsc --watch --preserveWatchOutput", + "lint": "eslint --ext .js,.jsx,.ts,.tsx src", + "lint:fix": "eslint --ext .js,.jsx,.ts,.tsx src --fix", + "test": "jest", + "testunit": "jest", + "typecheck": "tsc --noEmit --skipLibCheck" + }, + "volta": { + "extends": "../../../package.json" + }, + "dependencies": { + "@rocket.chat/core-services": "workspace:^", + "@rocket.chat/core-typings": "workspace:^" + }, + "devDependencies": { + "@rocket.chat/eslint-config": "workspace:^", + "@rocket.chat/tsconfig": "workspace:*", + "@types/jest": "~30.0.0", + "@types/node": "~22.16.1", + "eslint": "~8.45.0", + "jest": "~30.0.5", + "typescript": "~5.9.2" + }, + "engines": { + "node": ">=18.0.0" + }, + "license": "SEE LICENSE IN ../../../../LICENSE" +} diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts new file mode 100644 index 0000000000000..d7b8a448eb287 --- /dev/null +++ b/ee/packages/abac/src/index.ts @@ -0,0 +1,3 @@ +export function hello() { + return 'hello'; +} diff --git a/ee/packages/abac/tsconfig.json b/ee/packages/abac/tsconfig.json new file mode 100644 index 0000000000000..c83fcd25efaa4 --- /dev/null +++ b/ee/packages/abac/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "@rocket.chat/tsconfig/server.json", + "compilerOptions": { + "declaration": true, + "rootDir": "./src", + "outDir": "./dist" + }, + "files": ["./src/index.ts"] +} diff --git a/yarn.lock b/yarn.lock index b5ce68fe6b697..b0eecbf7e854a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8075,6 +8075,22 @@ __metadata: languageName: node linkType: hard +"@rocket.chat/abac@workspace:^, @rocket.chat/abac@workspace:ee/packages/abac": + version: 0.0.0-use.local + resolution: "@rocket.chat/abac@workspace:ee/packages/abac" + dependencies: + "@rocket.chat/core-services": "workspace:^" + "@rocket.chat/core-typings": "workspace:^" + "@rocket.chat/eslint-config": "workspace:^" + "@rocket.chat/tsconfig": "workspace:*" + "@types/jest": "npm:~30.0.0" + "@types/node": "npm:~22.16.1" + eslint: "npm:~8.45.0" + jest: "npm:~30.0.5" + typescript: "npm:~5.9.2" + languageName: unknown + linkType: soft + "@rocket.chat/account-service@workspace:ee/apps/account-service": version: 0.0.0-use.local resolution: "@rocket.chat/account-service@workspace:ee/apps/account-service" @@ -9164,6 +9180,7 @@ __metadata: "@playwright/test": "npm:^1.52.0" "@react-aria/toolbar": "npm:^3.0.0-nightly.5042" "@react-pdf/renderer": "npm:^3.4.5" + "@rocket.chat/abac": "workspace:^" "@rocket.chat/account-utils": "workspace:^" "@rocket.chat/agenda": "workspace:^" "@rocket.chat/api-client": "workspace:^" From dc3741f20f06c7810d89fce87d2d9e4fe718f91d Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 29 Sep 2025 13:40:24 -0600 Subject: [PATCH 004/125] abac model --- apps/meteor/server/models.ts | 2 ++ packages/core-typings/src/IAbacAttribute.ts | 3 +++ packages/core-typings/src/index.ts | 1 + packages/model-typings/src/index.ts | 1 + .../model-typings/src/models/IAbacAttributesModel.ts | 4 ++++ packages/models/src/index.ts | 4 ++++ packages/models/src/modelClasses.ts | 1 + packages/models/src/models/AbacAttributes.ts | 10 ++++++++++ 8 files changed, 26 insertions(+) create mode 100644 packages/core-typings/src/IAbacAttribute.ts create mode 100644 packages/model-typings/src/models/IAbacAttributesModel.ts create mode 100644 packages/models/src/models/AbacAttributes.ts diff --git a/apps/meteor/server/models.ts b/apps/meteor/server/models.ts index e9e577a74730a..343b1dce01c21 100644 --- a/apps/meteor/server/models.ts +++ b/apps/meteor/server/models.ts @@ -81,6 +81,7 @@ import { VoipRoomRaw, WebdavAccountsRaw, WorkspaceCredentialsRaw, + AbacAttributesRaw, } from '@rocket.chat/models'; import type { Collection } from 'mongodb'; @@ -173,3 +174,4 @@ registerModel('IVideoConferenceModel', new VideoConferenceRaw(db)); registerModel('IVoipRoomModel', new VoipRoomRaw(db, trashCollection)); registerModel('IWebdavAccountsModel', new WebdavAccountsRaw(db)); registerModel('IWorkspaceCredentialsModel', new WorkspaceCredentialsRaw(db)); +registerModel('IAbacAttributesModel', new AbacAttributesRaw(db)); diff --git a/packages/core-typings/src/IAbacAttribute.ts b/packages/core-typings/src/IAbacAttribute.ts new file mode 100644 index 0000000000000..bcbdadc2dd464 --- /dev/null +++ b/packages/core-typings/src/IAbacAttribute.ts @@ -0,0 +1,3 @@ +import type { IRocketChatRecord } from './IRocketChatRecord'; + +export interface IAbacAttribute extends IRocketChatRecord {} diff --git a/packages/core-typings/src/index.ts b/packages/core-typings/src/index.ts index fb6335e1c6bce..bdcdc539717e7 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 './ICallHistoryItem'; +export * from './IAbacAttribute'; export { schemas } from './Ajv'; diff --git a/packages/model-typings/src/index.ts b/packages/model-typings/src/index.ts index 612b471b10b54..3d7e5eb1c3525 100644 --- a/packages/model-typings/src/index.ts +++ b/packages/model-typings/src/index.ts @@ -87,3 +87,4 @@ export * from './models/IMediaCallNegotiationsModel'; export * from './updater'; export * from './models/IWorkspaceCredentialsModel'; export * from './models/ICallHistoryModel'; +export * from './models/IAbacAttributesModel'; diff --git a/packages/model-typings/src/models/IAbacAttributesModel.ts b/packages/model-typings/src/models/IAbacAttributesModel.ts new file mode 100644 index 0000000000000..581411c2dfba3 --- /dev/null +++ b/packages/model-typings/src/models/IAbacAttributesModel.ts @@ -0,0 +1,4 @@ +import type { IAbacAttribute } from '@rocket.chat/core-typings'; +import type { IBaseModel } from './IBaseModel'; + +export interface IAbacAttributesModel extends IBaseModel {} diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index dc77a653dcdce..1920a02d62e5a 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -92,6 +92,7 @@ import type { IMediaCallChannelsModel, IMediaCallNegotiationsModel, ICallHistoryModel, + IAbacAttributesModel, } from '@rocket.chat/model-typings'; import type { Collection, Db } from 'mongodb'; @@ -118,6 +119,7 @@ import { TeamRaw, UsersRaw, UsersSessionsRaw, + AbacAttributesRaw, } from './modelClasses'; import { proxify, registerModel } from './proxify'; @@ -226,6 +228,7 @@ export const CronHistory = proxify('ICronHistoryModel'); export const Migrations = proxify('IMigrationsModel'); export const ModerationReports = proxify('IModerationReportsModel'); export const WorkspaceCredentials = proxify('IWorkspaceCredentialsModel'); +export const AbacAttributes = proxify('IAbacAttributesModel'); export function registerServiceModels(db: Db, trash?: Collection>): void { registerModel('ISettingsModel', () => new SettingsRaw(db, trash as Collection>)); @@ -260,6 +263,7 @@ export function registerServiceModels(db: Db, trash?: Collection new LivechatRoomsRaw(db)); registerModel('IUploadsModel', () => new UploadsRaw(db)); registerModel('ILivechatVisitorsModel', () => new LivechatVisitorsRaw(db)); + registerModel('IAbacAttributesModel', () => new AbacAttributesRaw(db)); } if (!dbWatchersDisabled) { diff --git a/packages/models/src/modelClasses.ts b/packages/models/src/modelClasses.ts index bb7c60fd209ac..04405a18c4d8e 100644 --- a/packages/models/src/modelClasses.ts +++ b/packages/models/src/modelClasses.ts @@ -1,4 +1,5 @@ export * from './models/BaseRaw'; +export * from './models/AbacAttributes'; export * from './models/Analytics'; export * from './models/Apps'; export * from './models/AppsPersistence'; diff --git a/packages/models/src/models/AbacAttributes.ts b/packages/models/src/models/AbacAttributes.ts new file mode 100644 index 0000000000000..f5a39efa73f85 --- /dev/null +++ b/packages/models/src/models/AbacAttributes.ts @@ -0,0 +1,10 @@ +import type { RocketChatRecordDeleted, IAbacAttribute } from '@rocket.chat/core-typings'; +import type { Collection, Db } from 'mongodb'; + +import { BaseRaw } from './BaseRaw'; + +export class AbacAttributesRaw extends BaseRaw { + constructor(db: Db, trash?: Collection>) { + super(db, 'abac_attributes', trash); + } +} From 7105d5217843bbca51ba9bc15953cf1ec5be759d Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 29 Sep 2025 13:54:17 -0600 Subject: [PATCH 005/125] permission --- apps/meteor/ee/server/api/abac.ts | 34 +++++++++++++++------ apps/meteor/ee/server/configuration/abac.ts | 3 ++ apps/meteor/ee/server/lib/abac/index.ts | 9 ++++++ 3 files changed, 37 insertions(+), 9 deletions(-) create mode 100644 apps/meteor/ee/server/lib/abac/index.ts diff --git a/apps/meteor/ee/server/api/abac.ts b/apps/meteor/ee/server/api/abac.ts index 9171eac27b1ad..8f6e883cb55e4 100644 --- a/apps/meteor/ee/server/api/abac.ts +++ b/apps/meteor/ee/server/api/abac.ts @@ -3,42 +3,58 @@ import type { ExtractRoutesFromAPI } from '../../../app/api/server/ApiClass'; const abacEndpoints = API.v1 // enable/disable for room - .post('abac/rooms/:rid', { authRequired: true, permissionsRequired: [], response: {}, license: ['abac'] }, async function action() {}) + .post( + 'abac/rooms/:rid', + { authRequired: true, permissionsRequired: ['abac-management'], response: {}, license: ['abac'] }, + async function action() {}, + ) // add attributes for a room (bulk) .post( 'abac/room/:rid/attributes', - { authRequired: true, permissionsRequired: [], response: {}, license: ['abac'] }, + { authRequired: true, permissionsRequired: ['abac-management'], response: {}, license: ['abac'] }, async function action() {}, ) // edit a room attribute .put( 'abac/room/:rid/attributes/:key', - { authRequired: true, permissionsRequired: [], response: {}, license: ['abac'] }, + { authRequired: true, permissionsRequired: ['abac-management'], response: {}, license: ['abac'] }, async function action() {}, ) // delete a room attribute .delete( 'abac/room/:rid/attributes/:key', - { authRequired: true, permissionsRequired: [], response: {}, license: ['abac'] }, + { authRequired: true, permissionsRequired: ['abac-management'], response: {}, license: ['abac'] }, async function action() {}, ) // attribute endpoints // list attributes - .get('abac/attributes', { authRequired: true, permissionsRequired: [], response: {}, license: ['abac'] }, async function action() {}) + .get( + 'abac/attributes', + { authRequired: true, permissionsRequired: ['abac-management'], response: {}, license: ['abac'] }, + async function action() {}, + ) // create attribute - .post('abac/attributes', { authRequired: true, permissionsRequired: [], response: {}, license: ['abac'] }, async function action() {}) + .post( + 'abac/attributes', + { authRequired: true, permissionsRequired: ['abac-management'], response: {}, license: ['abac'] }, + async function action() {}, + ) // edit attribute and values (do not allow to modify attribute value if in use) - .put('abac/attributes/:key', { authRequired: true, permissionsRequired: [], response: {}, license: ['abac'] }, async function action() {}) + .put( + 'abac/attributes/:key', + { authRequired: true, permissionsRequired: ['abac-management'], response: {}, license: ['abac'] }, + async function action() {}, + ) // delete attribute (only if not in use) .delete( 'abac/attributes/:key', - { authRequired: true, permissionsRequired: [], response: {}, license: ['abac'] }, + { authRequired: true, permissionsRequired: ['abac-management'], response: {}, license: ['abac'] }, async function action() {}, ) // check if attribute is in use .get( 'abac/attributes/:key/is-in-use', - { authRequired: true, permissionsRequired: [], response: {}, license: ['abac'] }, + { authRequired: true, permissionsRequired: ['abac-management'], response: {}, license: ['abac'] }, async function action() {}, ); diff --git a/apps/meteor/ee/server/configuration/abac.ts b/apps/meteor/ee/server/configuration/abac.ts index cdb02f1064d2c..ae4d67258fda0 100644 --- a/apps/meteor/ee/server/configuration/abac.ts +++ b/apps/meteor/ee/server/configuration/abac.ts @@ -3,6 +3,9 @@ import { License } from '@rocket.chat/license'; Meteor.startup(async () => { await License.onLicense('abac', async () => { const { addSettings } = await import('../settings/abac'); + const { createPermissions } = await import('../lib/abac'); + await addSettings(); + await createPermissions(); }); }); diff --git a/apps/meteor/ee/server/lib/abac/index.ts b/apps/meteor/ee/server/lib/abac/index.ts new file mode 100644 index 0000000000000..1b03e8d457441 --- /dev/null +++ b/apps/meteor/ee/server/lib/abac/index.ts @@ -0,0 +1,9 @@ +import { Permissions } from '@rocket.chat/models'; + +export const createPermissions = async () => { + const permissions = [{ _id: 'abac-management', roles: ['admin'] }]; + + for (const permission of permissions) { + void Permissions.create(permission._id, permission.roles); + } +}; From 02f6aca59f9daaf116cef0cbc3bd68e56bc2e4f9 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 29 Sep 2025 15:06:53 -0600 Subject: [PATCH 006/125] lint --- packages/model-typings/src/models/IAbacAttributesModel.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/model-typings/src/models/IAbacAttributesModel.ts b/packages/model-typings/src/models/IAbacAttributesModel.ts index 581411c2dfba3..0592d680a0dda 100644 --- a/packages/model-typings/src/models/IAbacAttributesModel.ts +++ b/packages/model-typings/src/models/IAbacAttributesModel.ts @@ -1,4 +1,6 @@ import type { IAbacAttribute } from '@rocket.chat/core-typings'; + import type { IBaseModel } from './IBaseModel'; +// eslint-disable-next-line @typescript-eslint/no-empty-interface export interface IAbacAttributesModel extends IBaseModel {} From 9e185347fef561295a5ffb841aee571990abf5be Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 30 Sep 2025 09:38:18 -0600 Subject: [PATCH 007/125] abac flag --- ee/packages/abac/package.json | 3 ++- ee/packages/abac/src/index.ts | 12 ++++++++++-- packages/core-typings/src/IRoom.ts | 1 + yarn.lock | 1 + 4 files changed, 14 insertions(+), 3 deletions(-) diff --git a/ee/packages/abac/package.json b/ee/packages/abac/package.json index a8dbaeb245f12..3042ee9fff636 100644 --- a/ee/packages/abac/package.json +++ b/ee/packages/abac/package.json @@ -22,7 +22,8 @@ }, "dependencies": { "@rocket.chat/core-services": "workspace:^", - "@rocket.chat/core-typings": "workspace:^" + "@rocket.chat/core-typings": "workspace:^", + "@rocket.chat/models": "workspace:^" }, "devDependencies": { "@rocket.chat/eslint-config": "workspace:^", diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index d7b8a448eb287..9be7eb3cddb3c 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -1,3 +1,11 @@ -export function hello() { - return 'hello'; +import { Rooms } from '@rocket.chat/models'; + +export async function toggleAbacConfigurationForRoom(rid: string) { + const room = await Rooms.findOneByIdAndType(rid, 'p'); + + if (!room) { + throw new Error('error-invalid-room'); + } + + await Rooms.updateAbacConfigurationById(rid, !room.abac); } diff --git a/packages/core-typings/src/IRoom.ts b/packages/core-typings/src/IRoom.ts index c2975b49b07e7..4382117787a8b 100644 --- a/packages/core-typings/src/IRoom.ts +++ b/packages/core-typings/src/IRoom.ts @@ -33,6 +33,7 @@ export interface IRoom extends IRocketChatRecord { style?: string; }; encrypted?: boolean; + abac?: boolean; topic?: string; reactWhenReadOnly?: boolean; diff --git a/yarn.lock b/yarn.lock index b0eecbf7e854a..fb8f2506b671a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8082,6 +8082,7 @@ __metadata: "@rocket.chat/core-services": "workspace:^" "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/eslint-config": "workspace:^" + "@rocket.chat/models": "workspace:^" "@rocket.chat/tsconfig": "workspace:*" "@types/jest": "npm:~30.0.0" "@types/node": "npm:~22.16.1" From b3fab5ff0c1ab528bbd31d100c862adbaf4ef3f2 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 30 Sep 2025 09:43:15 -0600 Subject: [PATCH 008/125] model --- packages/model-typings/src/models/IRoomsModel.ts | 1 + packages/models/src/models/Rooms.ts | 12 ++++++++++++ 2 files changed, 13 insertions(+) diff --git a/packages/model-typings/src/models/IRoomsModel.ts b/packages/model-typings/src/models/IRoomsModel.ts index 5408cdbf94031..74546be2d2277 100644 --- a/packages/model-typings/src/models/IRoomsModel.ts +++ b/packages/model-typings/src/models/IRoomsModel.ts @@ -316,4 +316,5 @@ export interface IRoomsModel extends IBaseModel { markRolePrioritesCreatedForRoom(rid: IRoom['_id'], version: number): Promise; hasCreatedRolePrioritiesForRoom(rid: IRoom['_id'], syncVersion: number): Promise; countDistinctFederationRoomsExcluding(serverNames?: string[]): Promise; + updateAbacConfigurationById(rid: IRoom['_id'], abac: boolean): Promise; } diff --git a/packages/models/src/models/Rooms.ts b/packages/models/src/models/Rooms.ts index 949014bc360f0..9147ea878e8b3 100644 --- a/packages/models/src/models/Rooms.ts +++ b/packages/models/src/models/Rooms.ts @@ -1932,6 +1932,18 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { return this.updateOne(query, update); } + updateAbacConfigurationById(_id: IRoom['_id'], value: boolean): Promise { + const query: Filter = { _id }; + + const update: UpdateFilter = { + $set: { + abac: value === true, + }, + }; + + return this.updateOne(query, update); + } + updateGroupDMsRemovingUsernamesByUsername(username: string, userId: string): Promise { const query: Filter = { t: 'd', From 852afdff79bacd4e10c84c94ed2558043c9d327f Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 30 Sep 2025 10:16:24 -0600 Subject: [PATCH 009/125] abac service --- .../ee/server/api/{abac.ts => abac/index.ts} | 21 ++++++++++++--- apps/meteor/ee/server/api/abac/schemas.ts | 15 +++++++++++ apps/meteor/ee/server/startup/services.ts | 2 ++ ee/packages/abac/src/index.ts | 27 ++++++++++++++----- ee/packages/abac/tsconfig.json | 15 ++++++----- packages/core-services/src/index.ts | 3 +++ .../core-services/src/types/IAbacService.ts | 5 ++++ 7 files changed, 71 insertions(+), 17 deletions(-) rename apps/meteor/ee/server/api/{abac.ts => abac/index.ts} (76%) create mode 100644 apps/meteor/ee/server/api/abac/schemas.ts create mode 100644 packages/core-services/src/types/IAbacService.ts diff --git a/apps/meteor/ee/server/api/abac.ts b/apps/meteor/ee/server/api/abac/index.ts similarity index 76% rename from apps/meteor/ee/server/api/abac.ts rename to apps/meteor/ee/server/api/abac/index.ts index 8f6e883cb55e4..c1761929acf88 100644 --- a/apps/meteor/ee/server/api/abac.ts +++ b/apps/meteor/ee/server/api/abac/index.ts @@ -1,12 +1,25 @@ -import { API } from '../../../app/api/server'; -import type { ExtractRoutesFromAPI } from '../../../app/api/server/ApiClass'; +import { Abac } from '@rocket.chat/core-services'; + +import { POSTAbacToggleRoomStatusSuccessSchema } from './schemas'; +import { API } from '../../../../app/api/server'; +import type { ExtractRoutesFromAPI } from '../../../../app/api/server/ApiClass'; const abacEndpoints = API.v1 // enable/disable for room .post( 'abac/rooms/:rid', - { authRequired: true, permissionsRequired: ['abac-management'], response: {}, license: ['abac'] }, - async function action() {}, + { + authRequired: true, + permissionsRequired: ['abac-management'], + response: { 200: POSTAbacToggleRoomStatusSuccessSchema }, + license: ['abac'], + }, + async function action() { + const { rid } = this.urlParams; + await Abac.toggleAbacConfigurationForRoom(rid); + + return API.v1.success(); + }, ) // add attributes for a room (bulk) .post( diff --git a/apps/meteor/ee/server/api/abac/schemas.ts b/apps/meteor/ee/server/api/abac/schemas.ts new file mode 100644 index 0000000000000..8313427af2592 --- /dev/null +++ b/apps/meteor/ee/server/api/abac/schemas.ts @@ -0,0 +1,15 @@ +import Ajv from 'ajv'; + +const ajv = new Ajv({ + coerceTypes: true, +}); + +const ABACToggleRoomStatusSuccess = { + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + }, + additionalProperties: false, +}; + +export const POSTAbacToggleRoomStatusSuccessSchema = ajv.compile(ABACToggleRoomStatusSuccess); diff --git a/apps/meteor/ee/server/startup/services.ts b/apps/meteor/ee/server/startup/services.ts index 1d49d9ce20968..5e33f9e17c771 100644 --- a/apps/meteor/ee/server/startup/services.ts +++ b/apps/meteor/ee/server/startup/services.ts @@ -1,3 +1,4 @@ +import { AbacService } from '@rocket.chat/abac'; import { api } from '@rocket.chat/core-services'; import { isRunningMs } from '../../../server/lib/isRunningMs'; @@ -19,5 +20,6 @@ api.registerService(new VoipFreeSwitchService()); // when not running micro services we want to start up the instance intercom if (!isRunningMs()) { + api.registerService(new AbacService()); api.registerService(new InstanceService()); } diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index 9be7eb3cddb3c..eb0cdf94c2db6 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -1,11 +1,26 @@ +import { ServiceClass } from '@rocket.chat/core-services'; +import type { IAbacService } from '@rocket.chat/core-services'; import { Rooms } from '@rocket.chat/models'; -export async function toggleAbacConfigurationForRoom(rid: string) { - const room = await Rooms.findOneByIdAndType(rid, 'p'); +export class AbacService extends ServiceClass implements IAbacService { + protected name = 'abac'; - if (!room) { - throw new Error('error-invalid-room'); - } + /** + * Toggles the ABAC flag for a private room. + * Only rooms of type 'p' (private channels or teams) are currently eligible. + * + * @param rid Room ID + * @throws Error('error-invalid-room') if the room does not exist or is not a private room + */ + async toggleAbacConfigurationForRoom(rid: string): Promise { + const room = await Rooms.findOneByIdAndType(rid, 'p'); + + if (!room) { + throw new Error('error-invalid-room'); + } - await Rooms.updateAbacConfigurationById(rid, !room.abac); + await Rooms.updateAbacConfigurationById(rid, !room.abac); + } } + +export default AbacService; diff --git a/ee/packages/abac/tsconfig.json b/ee/packages/abac/tsconfig.json index c83fcd25efaa4..8afe4ba1f5ce2 100644 --- a/ee/packages/abac/tsconfig.json +++ b/ee/packages/abac/tsconfig.json @@ -1,9 +1,10 @@ { - "extends": "@rocket.chat/tsconfig/server.json", - "compilerOptions": { - "declaration": true, - "rootDir": "./src", - "outDir": "./dist" - }, - "files": ["./src/index.ts"] + "extends": "@rocket.chat/tsconfig/server.json", + "compilerOptions": { + "declaration": true, + "rootDir": "./src", + "outDir": "./dist" + }, + "include": ["./src/**/*"], + "exclude": ["./dist"] } diff --git a/packages/core-services/src/index.ts b/packages/core-services/src/index.ts index 2d7e4b9a96081..b1923d2732669 100644 --- a/packages/core-services/src/index.ts +++ b/packages/core-services/src/index.ts @@ -1,4 +1,5 @@ import { proxify } from './lib/proxify'; +import type { IAbacService } from './types/IAbacService'; import type { IAccount, ILoginResult } from './types/IAccount'; import type { IAnalyticsService } from './types/IAnalyticsService'; import { IApiService } from './types/IApiService'; @@ -52,6 +53,7 @@ import type { IVoipFreeSwitchService } from './types/IVoipFreeSwitchService'; import type { IVoipService } from './types/IVoipService'; export { AppStatusReport } from './types/IAppsEngineService'; +export { IAbacService } from './types/IAbacService'; export { asyncLocalStorage } from './lib/asyncLocalStorage'; export { MeteorError, isMeteorError } from './MeteorError'; export { api } from './api'; @@ -197,3 +199,4 @@ export const User = proxify('user'); export const EnterpriseSettings = proxify('ee-settings'); export const FederationMatrix = proxify('federation-matrix'); +export const Abac = proxify('abac'); diff --git a/packages/core-services/src/types/IAbacService.ts b/packages/core-services/src/types/IAbacService.ts new file mode 100644 index 0000000000000..60db9a4e2d263 --- /dev/null +++ b/packages/core-services/src/types/IAbacService.ts @@ -0,0 +1,5 @@ +import type { IRoom } from '@rocket.chat/core-typings'; + +export interface IAbacService { + toggleAbacConfigurationForRoom(rid: IRoom['_id']): Promise; +} From 40225c2c8d1d34b50f28df78f3bd18e481636690 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 30 Sep 2025 12:10:51 -0600 Subject: [PATCH 010/125] translations --- packages/i18n/src/locales/en.i18n.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index ce902b70ee484..b1a011a71305c 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -11,6 +11,9 @@ "2_Erros_Information_and_Debug": "2 - Errors, Information and Debug", "@username": "@username", "@username_message": "@username ", + "ABAC": "Attribute Based Access Control (ABAC)", + "ABAC_Enabled": "Enable Attribute Based Access Control (ABAC)", + "abac-management": "Manage ABAC configuration", "AI_Actions": "AI actions", "API": "API", "API_Add_Personal_Access_Token": "Add new Personal Access Token", From ff0094980f8ded123ddd804bcfb06777fa1354b4 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 30 Sep 2025 12:30:15 -0600 Subject: [PATCH 011/125] tests --- ee/packages/abac/src/index.spec.ts | 83 ++++++++++++++++++++++++++++++ 1 file changed, 83 insertions(+) create mode 100644 ee/packages/abac/src/index.spec.ts diff --git a/ee/packages/abac/src/index.spec.ts b/ee/packages/abac/src/index.spec.ts new file mode 100644 index 0000000000000..0ce2be3500407 --- /dev/null +++ b/ee/packages/abac/src/index.spec.ts @@ -0,0 +1,83 @@ +import { AbacService } from './index'; + +const mockFindOneByIdAndType = jest.fn(); +const mockUpdateAbacConfigurationById = jest.fn(); + +jest.mock('@rocket.chat/models', () => ({ + Rooms: { + findOneByIdAndType: (...args: any[]) => mockFindOneByIdAndType(...args), + updateAbacConfigurationById: (...args: any[]) => mockUpdateAbacConfigurationById(...args), + }, +})); + +// Minimal mock for ServiceClass (we don't need its real behavior in unit scope) +jest.mock('@rocket.chat/core-services', () => ({ + ServiceClass: class {}, +})); + +describe('AbacService (unit)', () => { + let service: AbacService; + + beforeEach(() => { + service = new AbacService(); + jest.clearAllMocks(); + }); + + describe('toggleAbacConfigurationForRoom', () => { + it('enables ABAC when room.abac is undefined', async () => { + mockFindOneByIdAndType.mockResolvedValueOnce({ + _id: 'room1', + t: 'p', + abac: undefined, + }); + + await service.toggleAbacConfigurationForRoom('room1'); + + expect(mockFindOneByIdAndType).toHaveBeenCalledWith('room1', 'p'); + expect(mockUpdateAbacConfigurationById).toHaveBeenCalledWith('room1', true); + }); + + it('enables ABAC when room.abac is false', async () => { + mockFindOneByIdAndType.mockResolvedValueOnce({ + _id: 'room2', + t: 'p', + abac: false, + }); + + await service.toggleAbacConfigurationForRoom('room2'); + + expect(mockFindOneByIdAndType).toHaveBeenCalledWith('room2', 'p'); + expect(mockUpdateAbacConfigurationById).toHaveBeenCalledWith('room2', true); + }); + + it('disables ABAC when room.abac is true', async () => { + mockFindOneByIdAndType.mockResolvedValueOnce({ + _id: 'room3', + t: 'p', + abac: true, + }); + + await service.toggleAbacConfigurationForRoom('room3'); + + expect(mockFindOneByIdAndType).toHaveBeenCalledWith('room3', 'p'); + expect(mockUpdateAbacConfigurationById).toHaveBeenCalledWith('room3', false); + }); + + it('throws error-invalid-room when room is not found', async () => { + mockFindOneByIdAndType.mockResolvedValueOnce(null); + + await expect(service.toggleAbacConfigurationForRoom('missing')).rejects.toThrow('error-invalid-room'); + + expect(mockFindOneByIdAndType).toHaveBeenCalledWith('missing', 'p'); + expect(mockUpdateAbacConfigurationById).not.toHaveBeenCalled(); + }); + + it('propagates underlying model errors', async () => { + const err = new Error('database-failure'); + mockFindOneByIdAndType.mockRejectedValueOnce(err); + + await expect(service.toggleAbacConfigurationForRoom('roomX')).rejects.toThrow('database-failure'); + expect(mockUpdateAbacConfigurationById).not.toHaveBeenCalled(); + }); + }); +}); From 8e84b6c41642aa2d9b98100c3cb6c4c7d3d516f1 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 30 Sep 2025 12:59:14 -0600 Subject: [PATCH 012/125] attributes on room --- packages/core-typings/src/ABACAttributeDefinition.ts | 12 ++++++++++++ packages/core-typings/src/IRoom.ts | 2 ++ 2 files changed, 14 insertions(+) create mode 100644 packages/core-typings/src/ABACAttributeDefinition.ts diff --git a/packages/core-typings/src/ABACAttributeDefinition.ts b/packages/core-typings/src/ABACAttributeDefinition.ts new file mode 100644 index 0000000000000..0a10ef9ec4afa --- /dev/null +++ b/packages/core-typings/src/ABACAttributeDefinition.ts @@ -0,0 +1,12 @@ +export interface IAbacAttributeDefinition { + /** + * Validation expectation (NOT enforced here, must be enforced by caller): + * /^[A-Za-z0-9_-]+$/ + */ + key: string; + + /** + * List of string values for this attribute key. + */ + values: string[]; +} diff --git a/packages/core-typings/src/IRoom.ts b/packages/core-typings/src/IRoom.ts index 4382117787a8b..a8ef4aaef89aa 100644 --- a/packages/core-typings/src/IRoom.ts +++ b/packages/core-typings/src/IRoom.ts @@ -1,3 +1,4 @@ +import type { IAbacAttributeDefinition } from './ABACAttributeDefinition'; import type { ILivechatDepartment } from './ILivechatDepartment'; import type { ILivechatPriority } from './ILivechatPriority'; import type { ILivechatVisitor } from './ILivechatVisitor'; @@ -34,6 +35,7 @@ export interface IRoom extends IRocketChatRecord { }; encrypted?: boolean; abac?: boolean; + abacAttributes: IAbacAttributeDefinition[]; topic?: string; reactWhenReadOnly?: boolean; From 277a2e4fd0646f402ec76f3839fc150fe2fe3b28 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 30 Sep 2025 14:07:20 -0600 Subject: [PATCH 013/125] create an abac attribute --- apps/meteor/ee/server/api/abac/index.ts | 17 ++++++--- apps/meteor/ee/server/api/abac/schemas.ts | 24 +++++++++++-- ee/packages/abac/src/index.spec.ts | 8 ++--- ee/packages/abac/src/index.ts | 35 +++++++++++++++++-- .../core-services/src/types/IAbacService.ts | 3 +- .../src/ABACAttributeDefinition.ts | 12 ------- packages/core-typings/src/IAbacAttribute.ts | 15 +++++++- packages/core-typings/src/IRoom.ts | 4 +-- packages/models/src/models/AbacAttributes.ts | 12 ++++--- 9 files changed, 98 insertions(+), 32 deletions(-) delete mode 100644 packages/core-typings/src/ABACAttributeDefinition.ts diff --git a/apps/meteor/ee/server/api/abac/index.ts b/apps/meteor/ee/server/api/abac/index.ts index c1761929acf88..eef627cb17933 100644 --- a/apps/meteor/ee/server/api/abac/index.ts +++ b/apps/meteor/ee/server/api/abac/index.ts @@ -1,6 +1,6 @@ import { Abac } from '@rocket.chat/core-services'; -import { POSTAbacToggleRoomStatusSuccessSchema } from './schemas'; +import { GenericSuccessSchema, POSTAbacAttributeDefinitionSchema } from './schemas'; import { API } from '../../../../app/api/server'; import type { ExtractRoutesFromAPI } from '../../../../app/api/server/ApiClass'; @@ -11,7 +11,7 @@ const abacEndpoints = API.v1 { authRequired: true, permissionsRequired: ['abac-management'], - response: { 200: POSTAbacToggleRoomStatusSuccessSchema }, + response: { 200: GenericSuccessSchema }, license: ['abac'], }, async function action() { @@ -49,8 +49,17 @@ const abacEndpoints = API.v1 // create attribute .post( 'abac/attributes', - { authRequired: true, permissionsRequired: ['abac-management'], response: {}, license: ['abac'] }, - async function action() {}, + { + authRequired: true, + permissionsRequired: ['abac-management'], + license: ['abac'], + validateParams: POSTAbacAttributeDefinitionSchema, + response: { 200: GenericSuccessSchema }, + }, + async function action() { + await Abac.addAbacAttribute(this.bodyParams); + return API.v1.success(); + }, ) // edit attribute and values (do not allow to modify attribute value if in use) .put( diff --git a/apps/meteor/ee/server/api/abac/schemas.ts b/apps/meteor/ee/server/api/abac/schemas.ts index 8313427af2592..9a195f755d08c 100644 --- a/apps/meteor/ee/server/api/abac/schemas.ts +++ b/apps/meteor/ee/server/api/abac/schemas.ts @@ -4,7 +4,7 @@ const ajv = new Ajv({ coerceTypes: true, }); -const ABACToggleRoomStatusSuccess = { +const GenericSuccess = { type: 'object', properties: { success: { type: 'boolean', enum: [true] }, @@ -12,4 +12,24 @@ const ABACToggleRoomStatusSuccess = { additionalProperties: false, }; -export const POSTAbacToggleRoomStatusSuccessSchema = ajv.compile(ABACToggleRoomStatusSuccess); +export const GenericSuccessSchema = ajv.compile(GenericSuccess); + +// Create an abac attribute using the IAbacAttributeDefintion type, create the ajv schemas + +const AbacAttributeDefinition = { + type: 'object', + properties: { + key: { type: 'string', minLength: 1 }, + values: { + type: 'array', + items: { type: 'string', minLength: 1 }, + minItems: 1, + maxItems: 10, + uniqueItems: true, + }, + }, + required: ['key', 'values'], + additionalProperties: false, +}; + +export const POSTAbacAttributeDefinitionSchema = ajv.compile(AbacAttributeDefinition); diff --git a/ee/packages/abac/src/index.spec.ts b/ee/packages/abac/src/index.spec.ts index 0ce2be3500407..914c6fa7c589f 100644 --- a/ee/packages/abac/src/index.spec.ts +++ b/ee/packages/abac/src/index.spec.ts @@ -33,7 +33,7 @@ describe('AbacService (unit)', () => { await service.toggleAbacConfigurationForRoom('room1'); - expect(mockFindOneByIdAndType).toHaveBeenCalledWith('room1', 'p'); + expect(mockFindOneByIdAndType).toHaveBeenCalledWith('room1', 'p', { projection: { abac: 1 } }); expect(mockUpdateAbacConfigurationById).toHaveBeenCalledWith('room1', true); }); @@ -46,7 +46,7 @@ describe('AbacService (unit)', () => { await service.toggleAbacConfigurationForRoom('room2'); - expect(mockFindOneByIdAndType).toHaveBeenCalledWith('room2', 'p'); + expect(mockFindOneByIdAndType).toHaveBeenCalledWith('room2', 'p', { projection: { abac: 1 } }); expect(mockUpdateAbacConfigurationById).toHaveBeenCalledWith('room2', true); }); @@ -59,7 +59,7 @@ describe('AbacService (unit)', () => { await service.toggleAbacConfigurationForRoom('room3'); - expect(mockFindOneByIdAndType).toHaveBeenCalledWith('room3', 'p'); + expect(mockFindOneByIdAndType).toHaveBeenCalledWith('room3', 'p', { projection: { abac: 1 } }); expect(mockUpdateAbacConfigurationById).toHaveBeenCalledWith('room3', false); }); @@ -68,7 +68,7 @@ describe('AbacService (unit)', () => { await expect(service.toggleAbacConfigurationForRoom('missing')).rejects.toThrow('error-invalid-room'); - expect(mockFindOneByIdAndType).toHaveBeenCalledWith('missing', 'p'); + expect(mockFindOneByIdAndType).toHaveBeenCalledWith('missing', 'p', { projection: { abac: 1 } }); expect(mockUpdateAbacConfigurationById).not.toHaveBeenCalled(); }); diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index eb0cdf94c2db6..db7a490633927 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -1,6 +1,7 @@ import { ServiceClass } from '@rocket.chat/core-services'; import type { IAbacService } from '@rocket.chat/core-services'; -import { Rooms } from '@rocket.chat/models'; +import type { IAbacAttributeDefinition } from '@rocket.chat/core-typings'; +import { Rooms, AbacAttributes } from '@rocket.chat/models'; export class AbacService extends ServiceClass implements IAbacService { protected name = 'abac'; @@ -13,7 +14,7 @@ export class AbacService extends ServiceClass implements IAbacService { * @throws Error('error-invalid-room') if the room does not exist or is not a private room */ async toggleAbacConfigurationForRoom(rid: string): Promise { - const room = await Rooms.findOneByIdAndType(rid, 'p'); + const room = await Rooms.findOneByIdAndType(rid, 'p', { projection: { abac: 1 } }); if (!room) { throw new Error('error-invalid-room'); @@ -21,6 +22,36 @@ export class AbacService extends ServiceClass implements IAbacService { await Rooms.updateAbacConfigurationById(rid, !room.abac); } + + /** + * Adds a new ABAC attribute definition entry for a given private room. + * + * @param rid Room ID + * @param attribute Attribute definition payload + * + * @throws Error('error-invalid-room') if the room does not exist or is not private + * @throws Error('error-invalid-attribute-key') if key fails validation + * @throws Error('error-invalid-attribute-values') if values list is empty after normalization + */ + async addAbacAttribute(attribute: IAbacAttributeDefinition): Promise { + const keyPattern = /^[A-Za-z0-9_-]+$/; + if (!keyPattern.test(attribute.key)) { + throw new Error('error-invalid-attribute-key'); + } + + if (!attribute.values.length) { + throw new Error('error-invalid-attribute-values'); + } + + try { + await AbacAttributes.insertOne(attribute); + } catch (e) { + if (e instanceof Error && e.message.includes('E11000')) { + throw new Error('error-duplicate-attribute-key'); + } + throw e; + } + } } export default AbacService; diff --git a/packages/core-services/src/types/IAbacService.ts b/packages/core-services/src/types/IAbacService.ts index 60db9a4e2d263..04faa45b09271 100644 --- a/packages/core-services/src/types/IAbacService.ts +++ b/packages/core-services/src/types/IAbacService.ts @@ -1,5 +1,6 @@ -import type { IRoom } from '@rocket.chat/core-typings'; +import type { IAbacAttributeDefinition, IRoom } from '@rocket.chat/core-typings'; export interface IAbacService { toggleAbacConfigurationForRoom(rid: IRoom['_id']): Promise; + addAbacAttribute(attribute: IAbacAttributeDefinition): Promise; } diff --git a/packages/core-typings/src/ABACAttributeDefinition.ts b/packages/core-typings/src/ABACAttributeDefinition.ts deleted file mode 100644 index 0a10ef9ec4afa..0000000000000 --- a/packages/core-typings/src/ABACAttributeDefinition.ts +++ /dev/null @@ -1,12 +0,0 @@ -export interface IAbacAttributeDefinition { - /** - * Validation expectation (NOT enforced here, must be enforced by caller): - * /^[A-Za-z0-9_-]+$/ - */ - key: string; - - /** - * List of string values for this attribute key. - */ - values: string[]; -} diff --git a/packages/core-typings/src/IAbacAttribute.ts b/packages/core-typings/src/IAbacAttribute.ts index bcbdadc2dd464..d46a0866b5f63 100644 --- a/packages/core-typings/src/IAbacAttribute.ts +++ b/packages/core-typings/src/IAbacAttribute.ts @@ -1,3 +1,16 @@ import type { IRocketChatRecord } from './IRocketChatRecord'; -export interface IAbacAttribute extends IRocketChatRecord {} +export interface IAbacAttributeDefinition { + /** + * Validation expectation (NOT enforced here, must be enforced by caller): + * /^[A-Za-z0-9_-]+$/ + */ + key: string; + + /** + * List of string values for this attribute key. + */ + values: string[]; +} + +export interface IAbacAttribute extends IRocketChatRecord, IAbacAttributeDefinition {} diff --git a/packages/core-typings/src/IRoom.ts b/packages/core-typings/src/IRoom.ts index a8ef4aaef89aa..6014e7d3f3d4d 100644 --- a/packages/core-typings/src/IRoom.ts +++ b/packages/core-typings/src/IRoom.ts @@ -1,4 +1,4 @@ -import type { IAbacAttributeDefinition } from './ABACAttributeDefinition'; +import type { IAbacAttributeDefinition } from './IAbacAttribute'; import type { ILivechatDepartment } from './ILivechatDepartment'; import type { ILivechatPriority } from './ILivechatPriority'; import type { ILivechatVisitor } from './ILivechatVisitor'; @@ -35,7 +35,7 @@ export interface IRoom extends IRocketChatRecord { }; encrypted?: boolean; abac?: boolean; - abacAttributes: IAbacAttributeDefinition[]; + abacAttributes?: IAbacAttributeDefinition[]; topic?: string; reactWhenReadOnly?: boolean; diff --git a/packages/models/src/models/AbacAttributes.ts b/packages/models/src/models/AbacAttributes.ts index f5a39efa73f85..12c14d3890f32 100644 --- a/packages/models/src/models/AbacAttributes.ts +++ b/packages/models/src/models/AbacAttributes.ts @@ -1,10 +1,14 @@ -import type { RocketChatRecordDeleted, IAbacAttribute } from '@rocket.chat/core-typings'; -import type { Collection, Db } from 'mongodb'; +import type { IAbacAttribute } from '@rocket.chat/core-typings'; +import type { Db, IndexDescription } from 'mongodb'; import { BaseRaw } from './BaseRaw'; export class AbacAttributesRaw extends BaseRaw { - constructor(db: Db, trash?: Collection>) { - super(db, 'abac_attributes', trash); + constructor(db: Db) { + super(db, 'abac_attributes'); + } + + protected modelIndexes(): IndexDescription[] { + return [{ key: { key: 1 }, unique: true }, { key: { values: 1 } }]; } } From c6af020fad63009cccabce941ed6656fc7c32492 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 1 Oct 2025 09:50:27 -0600 Subject: [PATCH 014/125] Tests for other function --- ee/packages/abac/jest.config.ts | 1 + ee/packages/abac/src/index.spec.ts | 37 ++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/ee/packages/abac/jest.config.ts b/ee/packages/abac/jest.config.ts index c18c8ae02465c..46e41b9ab1707 100644 --- a/ee/packages/abac/jest.config.ts +++ b/ee/packages/abac/jest.config.ts @@ -3,4 +3,5 @@ import type { Config } from 'jest'; export default { preset: server.preset, + testMatch: ['/src/**/*.spec.(ts|js|mjs)'], } satisfies Config; diff --git a/ee/packages/abac/src/index.spec.ts b/ee/packages/abac/src/index.spec.ts index 914c6fa7c589f..eb0006d56990b 100644 --- a/ee/packages/abac/src/index.spec.ts +++ b/ee/packages/abac/src/index.spec.ts @@ -2,12 +2,16 @@ import { AbacService } from './index'; const mockFindOneByIdAndType = jest.fn(); const mockUpdateAbacConfigurationById = jest.fn(); +const mockAbacInsertOne = jest.fn(); jest.mock('@rocket.chat/models', () => ({ Rooms: { findOneByIdAndType: (...args: any[]) => mockFindOneByIdAndType(...args), updateAbacConfigurationById: (...args: any[]) => mockUpdateAbacConfigurationById(...args), }, + AbacAttributes: { + insertOne: (...args: any[]) => mockAbacInsertOne(...args), + }, })); // Minimal mock for ServiceClass (we don't need its real behavior in unit scope) @@ -79,5 +83,38 @@ describe('AbacService (unit)', () => { await expect(service.toggleAbacConfigurationForRoom('roomX')).rejects.toThrow('database-failure'); expect(mockUpdateAbacConfigurationById).not.toHaveBeenCalled(); }); + + describe('addAbacAttribute', () => { + it('inserts attribute when valid', async () => { + const attribute = { key: 'Valid_Key-1', values: ['v1', 'v2'] }; + await service.addAbacAttribute(attribute); + expect(mockAbacInsertOne).toHaveBeenCalledTimes(1); + expect(mockAbacInsertOne).toHaveBeenCalledWith(attribute); + }); + + it('throws error-invalid-attribute-key for invalid key', async () => { + const attribute = { key: 'Invalid Key!', values: ['v1'] }; + await expect(service.addAbacAttribute(attribute as any)).rejects.toThrow('error-invalid-attribute-key'); + expect(mockAbacInsertOne).not.toHaveBeenCalled(); + }); + + it('throws error-invalid-attribute-values for empty values array', async () => { + const attribute = { key: 'ValidKey', values: [] as string[] }; + await expect(service.addAbacAttribute(attribute)).rejects.toThrow('error-invalid-attribute-values'); + expect(mockAbacInsertOne).not.toHaveBeenCalled(); + }); + + it('throws error-duplicate-attribute-key when duplicate index error occurs', async () => { + const attribute = { key: 'DupKey', values: ['a'] }; + mockAbacInsertOne.mockRejectedValueOnce(new Error('E11000 duplicate key error collection: abac_attributes')); + await expect(service.addAbacAttribute(attribute)).rejects.toThrow('error-duplicate-attribute-key'); + }); + + it('propagates unexpected insert errors', async () => { + const attribute = { key: 'OtherKey', values: ['x'] }; + mockAbacInsertOne.mockRejectedValueOnce(new Error('network-failure')); + await expect(service.addAbacAttribute(attribute)).rejects.toThrow('network-failure'); + }); + }); }); }); From 16d01e6e8d450b0b83feb5cb20c2eb4a00c474f2 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 1 Oct 2025 10:44:35 -0600 Subject: [PATCH 015/125] list attirbutes endpoint --- apps/meteor/ee/server/api/abac/index.ts | 54 ++++++++-- apps/meteor/ee/server/api/abac/schemas.ts | 71 +++++++++++- ee/packages/abac/package.json | 3 +- ee/packages/abac/src/index.spec.ts | 102 ++++++++++++++++++ ee/packages/abac/src/index.ts | 41 ++++++- .../core-services/src/types/IAbacService.ts | 8 +- yarn.lock | 1 + 7 files changed, 266 insertions(+), 14 deletions(-) diff --git a/apps/meteor/ee/server/api/abac/index.ts b/apps/meteor/ee/server/api/abac/index.ts index eef627cb17933..aaab450656dac 100644 --- a/apps/meteor/ee/server/api/abac/index.ts +++ b/apps/meteor/ee/server/api/abac/index.ts @@ -1,6 +1,11 @@ import { Abac } from '@rocket.chat/core-services'; -import { GenericSuccessSchema, POSTAbacAttributeDefinitionSchema } from './schemas'; +import { + GenericSuccessSchema, + POSTAbacAttributeDefinitionSchema, + GETAbacAttributesQuerySchema, + GETAbacAttributesResponseSchema, +} from './schemas'; import { API } from '../../../../app/api/server'; import type { ExtractRoutesFromAPI } from '../../../../app/api/server/ApiClass'; @@ -25,26 +30,49 @@ const abacEndpoints = API.v1 .post( 'abac/room/:rid/attributes', { authRequired: true, permissionsRequired: ['abac-management'], response: {}, license: ['abac'] }, - async function action() {}, + async function action() { + throw new Error('not-implemented'); + }, ) // edit a room attribute .put( 'abac/room/:rid/attributes/:key', { authRequired: true, permissionsRequired: ['abac-management'], response: {}, license: ['abac'] }, - async function action() {}, + async function action() { + throw new Error('not-implemented'); + }, ) // delete a room attribute .delete( 'abac/room/:rid/attributes/:key', { authRequired: true, permissionsRequired: ['abac-management'], response: {}, license: ['abac'] }, - async function action() {}, + async function action() { + throw new Error('not-implemented'); + }, ) // attribute endpoints // list attributes .get( 'abac/attributes', - { authRequired: true, permissionsRequired: ['abac-management'], response: {}, license: ['abac'] }, - async function action() {}, + { + authRequired: true, + permissionsRequired: ['abac-management'], + query: GETAbacAttributesQuerySchema, + response: { 200: GETAbacAttributesResponseSchema }, + license: ['abac'], + }, + async function action() { + const { key, values, offset, count } = this.queryParams; + + const attributes = await Abac.listAbacAttributes({ + key, + values, + offset, + count, + }); + + return API.v1.success(attributes); + }, ) // create attribute .post( @@ -53,7 +81,7 @@ const abacEndpoints = API.v1 authRequired: true, permissionsRequired: ['abac-management'], license: ['abac'], - validateParams: POSTAbacAttributeDefinitionSchema, + body: POSTAbacAttributeDefinitionSchema, response: { 200: GenericSuccessSchema }, }, async function action() { @@ -65,19 +93,25 @@ const abacEndpoints = API.v1 .put( 'abac/attributes/:key', { authRequired: true, permissionsRequired: ['abac-management'], response: {}, license: ['abac'] }, - async function action() {}, + async function action() { + throw new Error('not-implemented'); + }, ) // delete attribute (only if not in use) .delete( 'abac/attributes/:key', { authRequired: true, permissionsRequired: ['abac-management'], response: {}, license: ['abac'] }, - async function action() {}, + async function action() { + throw new Error('not-implemented'); + }, ) // check if attribute is in use .get( 'abac/attributes/:key/is-in-use', { authRequired: true, permissionsRequired: ['abac-management'], response: {}, license: ['abac'] }, - async function action() {}, + async function action() { + throw new Error('not-implemented'); + }, ); export type AbacEndpoints = ExtractRoutesFromAPI; diff --git a/apps/meteor/ee/server/api/abac/schemas.ts b/apps/meteor/ee/server/api/abac/schemas.ts index 9a195f755d08c..46feee6f1b080 100644 --- a/apps/meteor/ee/server/api/abac/schemas.ts +++ b/apps/meteor/ee/server/api/abac/schemas.ts @@ -1,9 +1,17 @@ +import type { IAbacAttribute, IAbacAttributeDefinition } from '@rocket.chat/core-typings'; import Ajv from 'ajv'; +import type { AbacEndpoints } from '.'; + const ajv = new Ajv({ coerceTypes: true, }); +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends AbacEndpoints {} +} + const GenericSuccess = { type: 'object', properties: { @@ -32,4 +40,65 @@ const AbacAttributeDefinition = { additionalProperties: false, }; -export const POSTAbacAttributeDefinitionSchema = ajv.compile(AbacAttributeDefinition); +export const POSTAbacAttributeDefinitionSchema = ajv.compile(AbacAttributeDefinition); + +const GetAbacAttributesQuery = { + type: 'object', + properties: { + key: { type: 'string', minLength: 1 }, + values: { + type: 'array', + items: { type: 'string', minLength: 1 }, + minItems: 1, + maxItems: 10, + uniqueItems: true, + }, + offset: { type: 'integer', minimum: 0, default: 0 }, + count: { type: 'integer', minimum: 1, maximum: 100, default: 25 }, + }, + additionalProperties: false, +}; + +export const GETAbacAttributesQuerySchema = ajv.compile<{ key: string; values: string[]; offset: number; count: number; total: number }>( + GetAbacAttributesQuery, +); + +const AbacAttributeRecord = { + type: 'object', + properties: { + _id: { type: 'string', minLength: 1 }, + key: { type: 'string', minLength: 1 }, + values: { + type: 'array', + items: { type: 'string', minLength: 1 }, + minItems: 1, + maxItems: 10, + uniqueItems: true, + }, + }, + required: ['_id', 'key', 'values'], + additionalProperties: false, +}; + +// Response schema for listing attributes with pagination metadata +const GetAbacAttributesResponse = { + type: 'object', + properties: { + attributes: { + type: 'array', + items: AbacAttributeRecord, + }, + offset: { type: 'integer', minimum: 0 }, + count: { type: 'integer', minimum: 0 }, + total: { type: 'integer', minimum: 0 }, + }, + required: ['attributes', 'offset', 'count', 'total'], + additionalProperties: false, +}; + +export const GETAbacAttributesResponseSchema = ajv.compile<{ + attributes: IAbacAttribute[]; + offset: number; + count: number; + total: number; +}>(GetAbacAttributesResponse); diff --git a/ee/packages/abac/package.json b/ee/packages/abac/package.json index 3042ee9fff636..b5aa912312490 100644 --- a/ee/packages/abac/package.json +++ b/ee/packages/abac/package.json @@ -23,7 +23,8 @@ "dependencies": { "@rocket.chat/core-services": "workspace:^", "@rocket.chat/core-typings": "workspace:^", - "@rocket.chat/models": "workspace:^" + "@rocket.chat/models": "workspace:^", + "mongodb": "6.10.0" }, "devDependencies": { "@rocket.chat/eslint-config": "workspace:^", diff --git a/ee/packages/abac/src/index.spec.ts b/ee/packages/abac/src/index.spec.ts index eb0006d56990b..e209b0e13c8f8 100644 --- a/ee/packages/abac/src/index.spec.ts +++ b/ee/packages/abac/src/index.spec.ts @@ -3,6 +3,7 @@ import { AbacService } from './index'; const mockFindOneByIdAndType = jest.fn(); const mockUpdateAbacConfigurationById = jest.fn(); const mockAbacInsertOne = jest.fn(); +const mockAbacFindPaginated = jest.fn(); jest.mock('@rocket.chat/models', () => ({ Rooms: { @@ -11,6 +12,7 @@ jest.mock('@rocket.chat/models', () => ({ }, AbacAttributes: { insertOne: (...args: any[]) => mockAbacInsertOne(...args), + findPaginated: (...args: any[]) => mockAbacFindPaginated(...args), }, })); @@ -115,6 +117,106 @@ describe('AbacService (unit)', () => { mockAbacInsertOne.mockRejectedValueOnce(new Error('network-failure')); await expect(service.addAbacAttribute(attribute)).rejects.toThrow('network-failure'); }); + + describe('listAbacAttributes', () => { + it('returns paginated attributes with defaults (no filters)', async () => { + const docs = [ + { _id: '1', key: 'k1', values: ['a', 'b'] }, + { _id: '2', key: 'k2', values: ['c'] }, + ]; + mockAbacFindPaginated.mockReturnValueOnce({ + cursor: { toArray: async () => docs }, + totalCount: Promise.resolve(docs.length), + }); + + const result = await service.listAbacAttributes(); + expect(mockAbacFindPaginated).toHaveBeenCalledWith({}, { projection: { key: 1, values: 1 }, skip: 0, limit: 25 }); + expect(result).toEqual({ + attributes: docs, + offset: 0, + count: docs.length, + total: docs.length, + }); + }); + + it('filters by key only', async () => { + const docs = [{ _id: '3', key: 'FilterKey', values: ['x'] }]; + mockAbacFindPaginated.mockReturnValueOnce({ + cursor: { toArray: async () => docs }, + totalCount: Promise.resolve(docs.length), + }); + + const result = await service.listAbacAttributes({ key: 'FilterKey' }); + expect(mockAbacFindPaginated).toHaveBeenCalledWith( + { key: 'FilterKey' }, + { projection: { key: 1, values: 1 }, skip: 0, limit: 25 }, + ); + expect(result).toEqual({ + attributes: docs, + offset: 0, + count: docs.length, + total: docs.length, + }); + }); + + it('filters by values only with custom pagination', async () => { + const docs = [ + { _id: '4', key: 'alpha', values: ['m', 'n'] }, + { _id: '5', key: 'beta', values: ['n', 'o'] }, + ]; + mockAbacFindPaginated.mockReturnValueOnce({ + cursor: { toArray: async () => docs }, + totalCount: Promise.resolve(10), + }); + + const result = await service.listAbacAttributes({ values: ['n', 'z'], offset: 5, count: 2 }); + expect(mockAbacFindPaginated).toHaveBeenCalledWith( + { values: { $in: ['n', 'z'] } }, + { projection: { key: 1, values: 1 }, skip: 5, limit: 2 }, + ); + expect(result).toEqual({ + attributes: docs, + offset: 5, + count: docs.length, + total: 10, + }); + }); + + it('filters by key and values', async () => { + const docs = [{ _id: '6', key: 'gamma', values: ['p', 'q'] }]; + mockAbacFindPaginated.mockReturnValueOnce({ + cursor: { toArray: async () => docs }, + totalCount: Promise.resolve(docs.length), + }); + + const result = await service.listAbacAttributes({ key: 'gamma', values: ['q'] }); + expect(mockAbacFindPaginated).toHaveBeenCalledWith( + { key: 'gamma', values: { $in: ['q'] } }, + { projection: { key: 1, values: 1 }, skip: 0, limit: 25 }, + ); + expect(result).toEqual({ + attributes: docs, + offset: 0, + count: docs.length, + total: docs.length, + }); + }); + + it('returns empty when no documents match', async () => { + mockAbacFindPaginated.mockReturnValueOnce({ + cursor: { toArray: async () => [] }, + totalCount: Promise.resolve(0), + }); + + const result = await service.listAbacAttributes({ key: 'nope', values: ['none'] }); + expect(result).toEqual({ + attributes: [], + offset: 0, + count: 0, + total: 0, + }); + }); + }); }); }); }); diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index db7a490633927..50602587167d3 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -1,7 +1,8 @@ import { ServiceClass } from '@rocket.chat/core-services'; import type { IAbacService } from '@rocket.chat/core-services'; -import type { IAbacAttributeDefinition } from '@rocket.chat/core-typings'; +import type { IAbacAttribute, IAbacAttributeDefinition } from '@rocket.chat/core-typings'; import { Rooms, AbacAttributes } from '@rocket.chat/models'; +import { Filter } from 'mongodb'; export class AbacService extends ServiceClass implements IAbacService { protected name = 'abac'; @@ -52,6 +53,44 @@ export class AbacService extends ServiceClass implements IAbacService { throw e; } } + + /** + * Lists ABAC attribute definitions with optional filtering and pagination. + * + * @param filters optional filtering and pagination parameters + */ + async listAbacAttributes(filters?: { key?: string; values?: string[]; offset?: number; count?: number }): Promise<{ + attributes: IAbacAttribute[]; + offset: number; + count: number; + total: number; + }> { + const query: Filter = {}; + if (filters?.key) { + query.key = filters.key; + } + if (filters?.values?.length) { + query.values = { $in: filters.values }; + } + + const offset = filters?.offset ?? 0; + const limit = filters?.count ?? 25; + + const { cursor, totalCount } = AbacAttributes.findPaginated(query, { + projection: { key: 1, values: 1 }, + skip: offset, + limit, + }); + + const attributes = await cursor.toArray(); + + return { + attributes, + offset, + count: attributes.length, + total: await totalCount, + }; + } } export default AbacService; diff --git a/packages/core-services/src/types/IAbacService.ts b/packages/core-services/src/types/IAbacService.ts index 04faa45b09271..8882527952d09 100644 --- a/packages/core-services/src/types/IAbacService.ts +++ b/packages/core-services/src/types/IAbacService.ts @@ -1,6 +1,12 @@ -import type { IAbacAttributeDefinition, IRoom } from '@rocket.chat/core-typings'; +import type { IAbacAttributeDefinition, IRoom, IAbacAttribute } from '@rocket.chat/core-typings'; export interface IAbacService { toggleAbacConfigurationForRoom(rid: IRoom['_id']): Promise; addAbacAttribute(attribute: IAbacAttributeDefinition): Promise; + listAbacAttributes(filters?: { + key?: string; + values?: string[]; + offset?: number; + count?: number; + }): Promise<{ attributes: IAbacAttribute[]; offset: number; count: number; total: number }>; } diff --git a/yarn.lock b/yarn.lock index fb8f2506b671a..c5bea5684d15b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8088,6 +8088,7 @@ __metadata: "@types/node": "npm:~22.16.1" eslint: "npm:~8.45.0" jest: "npm:~30.0.5" + mongodb: "npm:6.10.0" typescript: "npm:~5.9.2" languageName: unknown linkType: soft From 5050a1ba9f15f747af142c12fcee1c7249b73b2e Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 1 Oct 2025 11:57:09 -0600 Subject: [PATCH 016/125] update abac attributes --- ee/packages/abac/src/index.spec.ts | 103 ++++++++++++++++++ ee/packages/abac/src/index.ts | 87 ++++++++++++++- .../core-services/src/types/IAbacService.ts | 1 + packages/core-typings/src/IRoom.ts | 2 +- packages/models/src/models/Rooms.ts | 4 + 5 files changed, 191 insertions(+), 6 deletions(-) diff --git a/ee/packages/abac/src/index.spec.ts b/ee/packages/abac/src/index.spec.ts index e209b0e13c8f8..f356f774dac39 100644 --- a/ee/packages/abac/src/index.spec.ts +++ b/ee/packages/abac/src/index.spec.ts @@ -4,15 +4,21 @@ const mockFindOneByIdAndType = jest.fn(); const mockUpdateAbacConfigurationById = jest.fn(); const mockAbacInsertOne = jest.fn(); const mockAbacFindPaginated = jest.fn(); +const mockAbacFindOne = jest.fn(); +const mockAbacUpdateOne = jest.fn(); +const mockRoomsFindOne = jest.fn(); jest.mock('@rocket.chat/models', () => ({ Rooms: { findOneByIdAndType: (...args: any[]) => mockFindOneByIdAndType(...args), updateAbacConfigurationById: (...args: any[]) => mockUpdateAbacConfigurationById(...args), + findOne: (...args: any[]) => mockRoomsFindOne(...args), }, AbacAttributes: { insertOne: (...args: any[]) => mockAbacInsertOne(...args), findPaginated: (...args: any[]) => mockAbacFindPaginated(...args), + findOne: (...args: any[]) => mockAbacFindOne(...args), + updateOne: (...args: any[]) => mockAbacUpdateOne(...args), }, })); @@ -219,4 +225,101 @@ describe('AbacService (unit)', () => { }); }); }); + + describe('updateAbacAttributeById', () => { + it('returns early (no-op) when neither key nor values provided', async () => { + await service.updateAbacAttributeById('id1', {} as any); + expect(mockAbacFindOne).not.toHaveBeenCalled(); + expect(mockAbacUpdateOne).not.toHaveBeenCalled(); + expect(mockRoomsFindOne).not.toHaveBeenCalled(); + }); + + it('throws error-attribute-not-found when attribute does not exist', async () => { + mockAbacFindOne.mockResolvedValueOnce(null); + await expect(service.updateAbacAttributeById('idMissing', { key: 'newKey' })).rejects.toThrow('error-attribute-not-found'); + expect(mockAbacFindOne).toHaveBeenCalledWith({ _id: 'idMissing' }, { projection: { key: 1, values: 1 } }); + }); + + it('throws error-invalid-attribute-key for invalid new key', async () => { + mockAbacFindOne.mockResolvedValueOnce({ _id: 'id2', key: 'OldKey', values: ['a'] }); + await expect(service.updateAbacAttributeById('id2', { key: 'Invalid Key!' })).rejects.toThrow('error-invalid-attribute-key'); + expect(mockAbacUpdateOne).not.toHaveBeenCalled(); + }); + + it('throws error-invalid-attribute-values for empty values array', async () => { + mockAbacFindOne.mockResolvedValueOnce({ _id: 'id3', key: 'Key3', values: ['x'] }); + await expect(service.updateAbacAttributeById('id3', { values: [] })).rejects.toThrow('error-invalid-attribute-values'); + expect(mockAbacUpdateOne).not.toHaveBeenCalled(); + }); + + it('throws error-attribute-in-use when key changes and old definition is in use', async () => { + mockAbacFindOne.mockResolvedValueOnce({ _id: 'id4', key: 'Old', values: ['v1', 'v2'] }); + mockRoomsFindOne.mockResolvedValueOnce({ _id: 'roomUsing' }); + await expect(service.updateAbacAttributeById('id4', { key: 'New' })).rejects.toThrow('error-attribute-in-use'); + expect(mockRoomsFindOne).toHaveBeenCalledWith( + { + abacAttributes: { + $elemMatch: { key: 'Old', values: { $in: ['v1', 'v2'] } }, + }, + }, + { projection: { _id: 1 } }, + ); + expect(mockAbacUpdateOne).not.toHaveBeenCalled(); + }); + + it('updates key when changed and not in use', async () => { + mockAbacFindOne.mockResolvedValueOnce({ _id: 'id5', key: 'Old', values: ['a'] }); + mockRoomsFindOne.mockResolvedValueOnce(null); + mockAbacUpdateOne.mockResolvedValueOnce({ modifiedCount: 1 }); + await service.updateAbacAttributeById('id5', { key: 'NewKey' }); + expect(mockAbacUpdateOne).toHaveBeenCalledWith({ _id: 'id5' }, { $set: { key: 'NewKey' } }); + }); + + it('throws error-attribute-in-use when removing a value that is in use', async () => { + mockAbacFindOne.mockResolvedValueOnce({ _id: 'id6', key: 'Attr', values: ['a', 'b', 'c'] }); + // removedValues => ['b'] + mockRoomsFindOne.mockResolvedValueOnce({ _id: 'roomUsesRemoved' }); + await expect(service.updateAbacAttributeById('id6', { values: ['a', 'c'] })).rejects.toThrow('error-attribute-in-use'); + expect(mockRoomsFindOne).toHaveBeenCalledWith( + { + abacAttributes: { + $elemMatch: { key: 'Attr', values: { $in: ['b'] } }, + }, + }, + { projection: { _id: 1 } }, + ); + expect(mockAbacUpdateOne).not.toHaveBeenCalled(); + }); + + it('updates values when removing some that are not in use', async () => { + mockAbacFindOne.mockResolvedValueOnce({ _id: 'id7', key: 'Attr', values: ['a', 'b', 'c'] }); + mockRoomsFindOne.mockResolvedValueOnce(null); // removal safe + mockAbacUpdateOne.mockResolvedValueOnce({ modifiedCount: 1 }); + await service.updateAbacAttributeById('id7', { values: ['a', 'c'] }); + expect(mockAbacUpdateOne).toHaveBeenCalledWith({ _id: 'id7' }, { $set: { values: ['a', 'c'] } }); + }); + + it('updates values when only adding (no removal) without in-use check', async () => { + mockAbacFindOne.mockResolvedValueOnce({ _id: 'id8', key: 'Attr', values: ['a'] }); + // newValues = ['a','b'] => removedValues = [] + mockAbacUpdateOne.mockResolvedValueOnce({ modifiedCount: 1 }); + await service.updateAbacAttributeById('id8', { values: ['a', 'b'] }); + expect(mockRoomsFindOne).not.toHaveBeenCalled(); + expect(mockAbacUpdateOne).toHaveBeenCalledWith({ _id: 'id8' }, { $set: { values: ['a', 'b'] } }); + }); + + it('throws error-duplicate-attribute-key on duplicate key error', async () => { + mockAbacFindOne.mockResolvedValueOnce({ _id: 'id9', key: 'Old', values: ['v'] }); + mockRoomsFindOne.mockResolvedValueOnce(null); + mockAbacUpdateOne.mockRejectedValueOnce(new Error('E11000 duplicate key error collection')); + await expect(service.updateAbacAttributeById('id9', { key: 'NewKey' })).rejects.toThrow('error-duplicate-attribute-key'); + }); + + it('propagates unexpected update errors', async () => { + mockAbacFindOne.mockResolvedValueOnce({ _id: 'id10', key: 'Old', values: ['v'] }); + mockRoomsFindOne.mockResolvedValueOnce(null); + mockAbacUpdateOne.mockRejectedValueOnce(new Error('write-failed')); + await expect(service.updateAbacAttributeById('id10', { key: 'Another' })).rejects.toThrow('write-failed'); + }); + }); }); diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index 50602587167d3..a12a87c4a62ab 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -2,7 +2,7 @@ import { ServiceClass } from '@rocket.chat/core-services'; import type { IAbacService } from '@rocket.chat/core-services'; import type { IAbacAttribute, IAbacAttributeDefinition } from '@rocket.chat/core-typings'; import { Rooms, AbacAttributes } from '@rocket.chat/models'; -import { Filter } from 'mongodb'; +import type { Filter, UpdateFilter } from 'mongodb'; export class AbacService extends ServiceClass implements IAbacService { protected name = 'abac'; @@ -10,9 +10,9 @@ export class AbacService extends ServiceClass implements IAbacService { /** * Toggles the ABAC flag for a private room. * Only rooms of type 'p' (private channels or teams) are currently eligible. + * For now, this doenst remove the attributes associated with the room * * @param rid Room ID - * @throws Error('error-invalid-room') if the room does not exist or is not a private room */ async toggleAbacConfigurationForRoom(rid: string): Promise { const room = await Rooms.findOneByIdAndType(rid, 'p', { projection: { abac: 1 } }); @@ -30,9 +30,6 @@ export class AbacService extends ServiceClass implements IAbacService { * @param rid Room ID * @param attribute Attribute definition payload * - * @throws Error('error-invalid-room') if the room does not exist or is not private - * @throws Error('error-invalid-attribute-key') if key fails validation - * @throws Error('error-invalid-attribute-values') if values list is empty after normalization */ async addAbacAttribute(attribute: IAbacAttributeDefinition): Promise { const keyPattern = /^[A-Za-z0-9_-]+$/; @@ -91,6 +88,86 @@ export class AbacService extends ServiceClass implements IAbacService { total: await totalCount, }; } + + /** + * Updates an ABAC attribute definition by its _id. + * + * Validation & behavior: + * - Attribute must exist + * - key (if provided) must match /^[A-Za-z0-9_-]+$/ + * - values (if provided) must be a non-empty array + * - Duplicate key conflict surfaces as error-duplicate-attribute-key + * - If the key changes OR any existing value is removed, verify none of the removed identity (old key + removed values) + * is currently in use by any room. + * + * + */ + async updateAbacAttributeById(_id: string, update: { key?: string; values?: string[] }): Promise { + if (!update.key && !update.values) { + return; + } + + const existing = await AbacAttributes.findOne({ _id }, { projection: { key: 1, values: 1 } }); + if (!existing) { + throw new Error('error-attribute-not-found'); + } + + const keyPattern = /^[A-Za-z0-9_-]+$/; + if (update.key && !keyPattern.test(update.key)) { + throw new Error('error-invalid-attribute-key'); + } + if (update.values && !update.values.length) { + throw new Error('error-invalid-attribute-values'); + } + + const newKey = update.key ?? existing.key; + const newValues = update.values ?? existing.values; + + const removedValues = existing.values.filter((v) => !newValues.includes(v)); + const keyChanged = newKey !== existing.key; + + // If key changed, all old values are considered removed under the old key context + const valuesToCheck = keyChanged ? existing.values : removedValues; + + if (keyChanged || valuesToCheck.length) { + const inUse = await Rooms.findOne( + { + abacAttributes: { + $elemMatch: { + key: existing.key, + values: { $in: valuesToCheck.length ? valuesToCheck : existing.values }, + }, + }, + }, + { projection: { _id: 1 } }, + ); + + if (inUse) { + throw new Error('error-attribute-in-use'); + } + } + + const modifier: UpdateFilter = {}; + if (update.key) { + modifier.key = update.key; + } + if (update.values) { + modifier.values = update.values; + } + + if (!Object.keys(modifier).length) { + return; + } + + try { + await AbacAttributes.updateOne({ _id }, { $set: modifier }); + } catch (e) { + if (e instanceof Error && e.message.includes('E11000')) { + throw new Error('error-duplicate-attribute-key'); + } + throw e; + } + } } export default AbacService; diff --git a/packages/core-services/src/types/IAbacService.ts b/packages/core-services/src/types/IAbacService.ts index 8882527952d09..655aae4a5a996 100644 --- a/packages/core-services/src/types/IAbacService.ts +++ b/packages/core-services/src/types/IAbacService.ts @@ -9,4 +9,5 @@ export interface IAbacService { offset?: number; count?: number; }): Promise<{ attributes: IAbacAttribute[]; offset: number; count: number; total: number }>; + updateAbacAttributeById(_id: string, update: { key?: string; values?: string[] }): Promise; } diff --git a/packages/core-typings/src/IRoom.ts b/packages/core-typings/src/IRoom.ts index 6014e7d3f3d4d..e855684082526 100644 --- a/packages/core-typings/src/IRoom.ts +++ b/packages/core-typings/src/IRoom.ts @@ -34,7 +34,7 @@ export interface IRoom extends IRocketChatRecord { style?: string; }; encrypted?: boolean; - abac?: boolean; + // The existence of an abac attribute definition indicates that ABAC is enabled for the room abacAttributes?: IAbacAttributeDefinition[]; topic?: string; diff --git a/packages/models/src/models/Rooms.ts b/packages/models/src/models/Rooms.ts index 9147ea878e8b3..9ea4d7a8386c9 100644 --- a/packages/models/src/models/Rooms.ts +++ b/packages/models/src/models/Rooms.ts @@ -110,6 +110,10 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { }, }, }, + { + key: { 'attributes.key': 1, 'attributes.values': 1 }, + partialFilterExpression: { attributes: { $exists: true } }, + }, ]; } From d784d930493f47b19a877dda10728c23fdfd43f4 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 1 Oct 2025 12:08:16 -0600 Subject: [PATCH 017/125] remove abac toggle endpoint --- apps/meteor/ee/server/api/abac/index.ts | 16 -- ee/packages/abac/src/index.spec.ts | 261 +++++++----------- ee/packages/abac/src/index.ts | 17 -- .../core-services/src/types/IAbacService.ts | 3 +- 4 files changed, 101 insertions(+), 196 deletions(-) diff --git a/apps/meteor/ee/server/api/abac/index.ts b/apps/meteor/ee/server/api/abac/index.ts index aaab450656dac..d8c98f81614e9 100644 --- a/apps/meteor/ee/server/api/abac/index.ts +++ b/apps/meteor/ee/server/api/abac/index.ts @@ -10,22 +10,6 @@ import { API } from '../../../../app/api/server'; import type { ExtractRoutesFromAPI } from '../../../../app/api/server/ApiClass'; const abacEndpoints = API.v1 - // enable/disable for room - .post( - 'abac/rooms/:rid', - { - authRequired: true, - permissionsRequired: ['abac-management'], - response: { 200: GenericSuccessSchema }, - license: ['abac'], - }, - async function action() { - const { rid } = this.urlParams; - await Abac.toggleAbacConfigurationForRoom(rid); - - return API.v1.success(); - }, - ) // add attributes for a room (bulk) .post( 'abac/room/:rid/attributes', diff --git a/ee/packages/abac/src/index.spec.ts b/ee/packages/abac/src/index.spec.ts index f356f774dac39..044de0437aa85 100644 --- a/ee/packages/abac/src/index.spec.ts +++ b/ee/packages/abac/src/index.spec.ts @@ -35,192 +35,131 @@ describe('AbacService (unit)', () => { jest.clearAllMocks(); }); - describe('toggleAbacConfigurationForRoom', () => { - it('enables ABAC when room.abac is undefined', async () => { - mockFindOneByIdAndType.mockResolvedValueOnce({ - _id: 'room1', - t: 'p', - abac: undefined, - }); - - await service.toggleAbacConfigurationForRoom('room1'); - - expect(mockFindOneByIdAndType).toHaveBeenCalledWith('room1', 'p', { projection: { abac: 1 } }); - expect(mockUpdateAbacConfigurationById).toHaveBeenCalledWith('room1', true); + describe('addAbacAttribute', () => { + it('inserts attribute when valid', async () => { + const attribute = { key: 'Valid_Key-1', values: ['v1', 'v2'] }; + await service.addAbacAttribute(attribute); + expect(mockAbacInsertOne).toHaveBeenCalledTimes(1); + expect(mockAbacInsertOne).toHaveBeenCalledWith(attribute); }); - it('enables ABAC when room.abac is false', async () => { - mockFindOneByIdAndType.mockResolvedValueOnce({ - _id: 'room2', - t: 'p', - abac: false, - }); - - await service.toggleAbacConfigurationForRoom('room2'); - - expect(mockFindOneByIdAndType).toHaveBeenCalledWith('room2', 'p', { projection: { abac: 1 } }); - expect(mockUpdateAbacConfigurationById).toHaveBeenCalledWith('room2', true); + it('throws error-invalid-attribute-key for invalid key', async () => { + const attribute = { key: 'Invalid Key!', values: ['v1'] }; + await expect(service.addAbacAttribute(attribute as any)).rejects.toThrow('error-invalid-attribute-key'); + expect(mockAbacInsertOne).not.toHaveBeenCalled(); }); - it('disables ABAC when room.abac is true', async () => { - mockFindOneByIdAndType.mockResolvedValueOnce({ - _id: 'room3', - t: 'p', - abac: true, - }); - - await service.toggleAbacConfigurationForRoom('room3'); - - expect(mockFindOneByIdAndType).toHaveBeenCalledWith('room3', 'p', { projection: { abac: 1 } }); - expect(mockUpdateAbacConfigurationById).toHaveBeenCalledWith('room3', false); + it('throws error-invalid-attribute-values for empty values array', async () => { + const attribute = { key: 'ValidKey', values: [] as string[] }; + await expect(service.addAbacAttribute(attribute)).rejects.toThrow('error-invalid-attribute-values'); + expect(mockAbacInsertOne).not.toHaveBeenCalled(); }); - it('throws error-invalid-room when room is not found', async () => { - mockFindOneByIdAndType.mockResolvedValueOnce(null); - - await expect(service.toggleAbacConfigurationForRoom('missing')).rejects.toThrow('error-invalid-room'); - - expect(mockFindOneByIdAndType).toHaveBeenCalledWith('missing', 'p', { projection: { abac: 1 } }); - expect(mockUpdateAbacConfigurationById).not.toHaveBeenCalled(); + it('throws error-duplicate-attribute-key when duplicate index error occurs', async () => { + const attribute = { key: 'DupKey', values: ['a'] }; + mockAbacInsertOne.mockRejectedValueOnce(new Error('E11000 duplicate key error collection: abac_attributes')); + await expect(service.addAbacAttribute(attribute)).rejects.toThrow('error-duplicate-attribute-key'); }); - it('propagates underlying model errors', async () => { - const err = new Error('database-failure'); - mockFindOneByIdAndType.mockRejectedValueOnce(err); - - await expect(service.toggleAbacConfigurationForRoom('roomX')).rejects.toThrow('database-failure'); - expect(mockUpdateAbacConfigurationById).not.toHaveBeenCalled(); + it('propagates unexpected insert errors', async () => { + const attribute = { key: 'OtherKey', values: ['x'] }; + mockAbacInsertOne.mockRejectedValueOnce(new Error('network-failure')); + await expect(service.addAbacAttribute(attribute)).rejects.toThrow('network-failure'); }); - describe('addAbacAttribute', () => { - it('inserts attribute when valid', async () => { - const attribute = { key: 'Valid_Key-1', values: ['v1', 'v2'] }; - await service.addAbacAttribute(attribute); - expect(mockAbacInsertOne).toHaveBeenCalledTimes(1); - expect(mockAbacInsertOne).toHaveBeenCalledWith(attribute); - }); - - it('throws error-invalid-attribute-key for invalid key', async () => { - const attribute = { key: 'Invalid Key!', values: ['v1'] }; - await expect(service.addAbacAttribute(attribute as any)).rejects.toThrow('error-invalid-attribute-key'); - expect(mockAbacInsertOne).not.toHaveBeenCalled(); - }); + describe('listAbacAttributes', () => { + it('returns paginated attributes with defaults (no filters)', async () => { + const docs = [ + { _id: '1', key: 'k1', values: ['a', 'b'] }, + { _id: '2', key: 'k2', values: ['c'] }, + ]; + mockAbacFindPaginated.mockReturnValueOnce({ + cursor: { toArray: async () => docs }, + totalCount: Promise.resolve(docs.length), + }); - it('throws error-invalid-attribute-values for empty values array', async () => { - const attribute = { key: 'ValidKey', values: [] as string[] }; - await expect(service.addAbacAttribute(attribute)).rejects.toThrow('error-invalid-attribute-values'); - expect(mockAbacInsertOne).not.toHaveBeenCalled(); + const result = await service.listAbacAttributes(); + expect(mockAbacFindPaginated).toHaveBeenCalledWith({}, { projection: { key: 1, values: 1 }, skip: 0, limit: 25 }); + expect(result).toEqual({ + attributes: docs, + offset: 0, + count: docs.length, + total: docs.length, + }); }); - it('throws error-duplicate-attribute-key when duplicate index error occurs', async () => { - const attribute = { key: 'DupKey', values: ['a'] }; - mockAbacInsertOne.mockRejectedValueOnce(new Error('E11000 duplicate key error collection: abac_attributes')); - await expect(service.addAbacAttribute(attribute)).rejects.toThrow('error-duplicate-attribute-key'); - }); + it('filters by key only', async () => { + const docs = [{ _id: '3', key: 'FilterKey', values: ['x'] }]; + mockAbacFindPaginated.mockReturnValueOnce({ + cursor: { toArray: async () => docs }, + totalCount: Promise.resolve(docs.length), + }); - it('propagates unexpected insert errors', async () => { - const attribute = { key: 'OtherKey', values: ['x'] }; - mockAbacInsertOne.mockRejectedValueOnce(new Error('network-failure')); - await expect(service.addAbacAttribute(attribute)).rejects.toThrow('network-failure'); + const result = await service.listAbacAttributes({ key: 'FilterKey' }); + expect(mockAbacFindPaginated).toHaveBeenCalledWith({ key: 'FilterKey' }, { projection: { key: 1, values: 1 }, skip: 0, limit: 25 }); + expect(result).toEqual({ + attributes: docs, + offset: 0, + count: docs.length, + total: docs.length, + }); }); - describe('listAbacAttributes', () => { - it('returns paginated attributes with defaults (no filters)', async () => { - const docs = [ - { _id: '1', key: 'k1', values: ['a', 'b'] }, - { _id: '2', key: 'k2', values: ['c'] }, - ]; - mockAbacFindPaginated.mockReturnValueOnce({ - cursor: { toArray: async () => docs }, - totalCount: Promise.resolve(docs.length), - }); - - const result = await service.listAbacAttributes(); - expect(mockAbacFindPaginated).toHaveBeenCalledWith({}, { projection: { key: 1, values: 1 }, skip: 0, limit: 25 }); - expect(result).toEqual({ - attributes: docs, - offset: 0, - count: docs.length, - total: docs.length, - }); + it('filters by values only with custom pagination', async () => { + const docs = [ + { _id: '4', key: 'alpha', values: ['m', 'n'] }, + { _id: '5', key: 'beta', values: ['n', 'o'] }, + ]; + mockAbacFindPaginated.mockReturnValueOnce({ + cursor: { toArray: async () => docs }, + totalCount: Promise.resolve(10), }); - it('filters by key only', async () => { - const docs = [{ _id: '3', key: 'FilterKey', values: ['x'] }]; - mockAbacFindPaginated.mockReturnValueOnce({ - cursor: { toArray: async () => docs }, - totalCount: Promise.resolve(docs.length), - }); - - const result = await service.listAbacAttributes({ key: 'FilterKey' }); - expect(mockAbacFindPaginated).toHaveBeenCalledWith( - { key: 'FilterKey' }, - { projection: { key: 1, values: 1 }, skip: 0, limit: 25 }, - ); - expect(result).toEqual({ - attributes: docs, - offset: 0, - count: docs.length, - total: docs.length, - }); + const result = await service.listAbacAttributes({ values: ['n', 'z'], offset: 5, count: 2 }); + expect(mockAbacFindPaginated).toHaveBeenCalledWith( + { values: { $in: ['n', 'z'] } }, + { projection: { key: 1, values: 1 }, skip: 5, limit: 2 }, + ); + expect(result).toEqual({ + attributes: docs, + offset: 5, + count: docs.length, + total: 10, }); + }); - it('filters by values only with custom pagination', async () => { - const docs = [ - { _id: '4', key: 'alpha', values: ['m', 'n'] }, - { _id: '5', key: 'beta', values: ['n', 'o'] }, - ]; - mockAbacFindPaginated.mockReturnValueOnce({ - cursor: { toArray: async () => docs }, - totalCount: Promise.resolve(10), - }); - - const result = await service.listAbacAttributes({ values: ['n', 'z'], offset: 5, count: 2 }); - expect(mockAbacFindPaginated).toHaveBeenCalledWith( - { values: { $in: ['n', 'z'] } }, - { projection: { key: 1, values: 1 }, skip: 5, limit: 2 }, - ); - expect(result).toEqual({ - attributes: docs, - offset: 5, - count: docs.length, - total: 10, - }); + it('filters by key and values', async () => { + const docs = [{ _id: '6', key: 'gamma', values: ['p', 'q'] }]; + mockAbacFindPaginated.mockReturnValueOnce({ + cursor: { toArray: async () => docs }, + totalCount: Promise.resolve(docs.length), }); - it('filters by key and values', async () => { - const docs = [{ _id: '6', key: 'gamma', values: ['p', 'q'] }]; - mockAbacFindPaginated.mockReturnValueOnce({ - cursor: { toArray: async () => docs }, - totalCount: Promise.resolve(docs.length), - }); - - const result = await service.listAbacAttributes({ key: 'gamma', values: ['q'] }); - expect(mockAbacFindPaginated).toHaveBeenCalledWith( - { key: 'gamma', values: { $in: ['q'] } }, - { projection: { key: 1, values: 1 }, skip: 0, limit: 25 }, - ); - expect(result).toEqual({ - attributes: docs, - offset: 0, - count: docs.length, - total: docs.length, - }); + const result = await service.listAbacAttributes({ key: 'gamma', values: ['q'] }); + expect(mockAbacFindPaginated).toHaveBeenCalledWith( + { key: 'gamma', values: { $in: ['q'] } }, + { projection: { key: 1, values: 1 }, skip: 0, limit: 25 }, + ); + expect(result).toEqual({ + attributes: docs, + offset: 0, + count: docs.length, + total: docs.length, }); + }); - it('returns empty when no documents match', async () => { - mockAbacFindPaginated.mockReturnValueOnce({ - cursor: { toArray: async () => [] }, - totalCount: Promise.resolve(0), - }); + it('returns empty when no documents match', async () => { + mockAbacFindPaginated.mockReturnValueOnce({ + cursor: { toArray: async () => [] }, + totalCount: Promise.resolve(0), + }); - const result = await service.listAbacAttributes({ key: 'nope', values: ['none'] }); - expect(result).toEqual({ - attributes: [], - offset: 0, - count: 0, - total: 0, - }); + const result = await service.listAbacAttributes({ key: 'nope', values: ['none'] }); + expect(result).toEqual({ + attributes: [], + offset: 0, + count: 0, + total: 0, }); }); }); diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index a12a87c4a62ab..017a247574c41 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -7,23 +7,6 @@ import type { Filter, UpdateFilter } from 'mongodb'; export class AbacService extends ServiceClass implements IAbacService { protected name = 'abac'; - /** - * Toggles the ABAC flag for a private room. - * Only rooms of type 'p' (private channels or teams) are currently eligible. - * For now, this doenst remove the attributes associated with the room - * - * @param rid Room ID - */ - async toggleAbacConfigurationForRoom(rid: string): Promise { - const room = await Rooms.findOneByIdAndType(rid, 'p', { projection: { abac: 1 } }); - - if (!room) { - throw new Error('error-invalid-room'); - } - - await Rooms.updateAbacConfigurationById(rid, !room.abac); - } - /** * Adds a new ABAC attribute definition entry for a given private room. * diff --git a/packages/core-services/src/types/IAbacService.ts b/packages/core-services/src/types/IAbacService.ts index 655aae4a5a996..00e35d1e8fdeb 100644 --- a/packages/core-services/src/types/IAbacService.ts +++ b/packages/core-services/src/types/IAbacService.ts @@ -1,7 +1,6 @@ -import type { IAbacAttributeDefinition, IRoom, IAbacAttribute } from '@rocket.chat/core-typings'; +import type { IAbacAttributeDefinition, IAbacAttribute } from '@rocket.chat/core-typings'; export interface IAbacService { - toggleAbacConfigurationForRoom(rid: IRoom['_id']): Promise; addAbacAttribute(attribute: IAbacAttributeDefinition): Promise; listAbacAttributes(filters?: { key?: string; From 25ae96998ada9453c0d91b2660de0e15dad4c325 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 1 Oct 2025 12:36:00 -0600 Subject: [PATCH 018/125] update abac attribute or values --- apps/meteor/ee/server/api/abac/index.ts | 19 ++++++++++++++----- apps/meteor/ee/server/api/abac/schemas.ts | 18 ++++++++++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/apps/meteor/ee/server/api/abac/index.ts b/apps/meteor/ee/server/api/abac/index.ts index d8c98f81614e9..058580a8d017f 100644 --- a/apps/meteor/ee/server/api/abac/index.ts +++ b/apps/meteor/ee/server/api/abac/index.ts @@ -2,9 +2,10 @@ import { Abac } from '@rocket.chat/core-services'; import { GenericSuccessSchema, - POSTAbacAttributeDefinitionSchema, + PUTAbacAttributeUpdateBodySchema, GETAbacAttributesQuerySchema, GETAbacAttributesResponseSchema, + POSTAbacAttributeDefinitionSchema, } from './schemas'; import { API } from '../../../../app/api/server'; import type { ExtractRoutesFromAPI } from '../../../../app/api/server/ApiClass'; @@ -73,12 +74,20 @@ const abacEndpoints = API.v1 return API.v1.success(); }, ) - // edit attribute and values (do not allow to modify attribute value if in use) + // update attribute definition (key and/or values) .put( - 'abac/attributes/:key', - { authRequired: true, permissionsRequired: ['abac-management'], response: {}, license: ['abac'] }, + 'abac/attributes/:_id', + { + authRequired: true, + permissionsRequired: ['abac-management'], + license: ['abac'], + body: PUTAbacAttributeUpdateBodySchema, + response: { 200: GenericSuccessSchema }, + }, async function action() { - throw new Error('not-implemented'); + const { _id } = this.urlParams; + await Abac.updateAbacAttributeById(_id, this.bodyParams); + return API.v1.success(); }, ) // delete attribute (only if not in use) diff --git a/apps/meteor/ee/server/api/abac/schemas.ts b/apps/meteor/ee/server/api/abac/schemas.ts index 46feee6f1b080..81f195f47efc0 100644 --- a/apps/meteor/ee/server/api/abac/schemas.ts +++ b/apps/meteor/ee/server/api/abac/schemas.ts @@ -22,6 +22,24 @@ const GenericSuccess = { export const GenericSuccessSchema = ajv.compile(GenericSuccess); +// Update ABAC attribute (request body) +const UpdateAbacAttributeBody = { + type: 'object', + properties: { + key: { type: 'string', minLength: 1, pattern: '^[A-Za-z0-9_-]+$' }, + values: { + type: 'array', + items: { type: 'string', minLength: 1 }, + minItems: 1, + maxItems: 10, + uniqueItems: true, + }, + }, + additionalProperties: false, + anyOf: [{ required: ['key'] }, { required: ['values'] }], +}; + +export const PUTAbacAttributeUpdateBodySchema = ajv.compile(UpdateAbacAttributeBody); // Create an abac attribute using the IAbacAttributeDefintion type, create the ajv schemas const AbacAttributeDefinition = { From 6f35def83d28c691eddbfd81e3ba5381007dac3b Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 1 Oct 2025 12:48:55 -0600 Subject: [PATCH 019/125] delete --- ee/packages/abac/src/index.spec.ts | 23 ++++++++++ ee/packages/abac/src/index.ts | 42 ++++++++++++++----- .../core-services/src/types/IAbacService.ts | 1 + 3 files changed, 56 insertions(+), 10 deletions(-) diff --git a/ee/packages/abac/src/index.spec.ts b/ee/packages/abac/src/index.spec.ts index 044de0437aa85..95e02d9f98e29 100644 --- a/ee/packages/abac/src/index.spec.ts +++ b/ee/packages/abac/src/index.spec.ts @@ -6,6 +6,7 @@ const mockAbacInsertOne = jest.fn(); const mockAbacFindPaginated = jest.fn(); const mockAbacFindOne = jest.fn(); const mockAbacUpdateOne = jest.fn(); +const mockAbacDeleteOne = jest.fn(); const mockRoomsFindOne = jest.fn(); jest.mock('@rocket.chat/models', () => ({ @@ -19,6 +20,7 @@ jest.mock('@rocket.chat/models', () => ({ findPaginated: (...args: any[]) => mockAbacFindPaginated(...args), findOne: (...args: any[]) => mockAbacFindOne(...args), updateOne: (...args: any[]) => mockAbacUpdateOne(...args), + deleteOne: (...args: any[]) => mockAbacDeleteOne(...args), }, })); @@ -260,5 +262,26 @@ describe('AbacService (unit)', () => { mockAbacUpdateOne.mockRejectedValueOnce(new Error('write-failed')); await expect(service.updateAbacAttributeById('id10', { key: 'Another' })).rejects.toThrow('write-failed'); }); + describe('deleteAbacAttributeById', () => { + it('throws error-attribute-not-found when attribute does not exist', async () => { + mockAbacFindOne.mockResolvedValueOnce(null); + await expect(service.deleteAbacAttributeById('missing')).rejects.toThrow('error-attribute-not-found'); + }); + + it('throws error-attribute-in-use when attribute is referenced by a room', async () => { + mockAbacFindOne.mockResolvedValueOnce({ _id: 'id11', key: 'KeyInUse', values: ['a', 'b'] }); + mockRoomsFindOne.mockResolvedValueOnce({ _id: 'roomUsingAttr' }); + await expect(service.deleteAbacAttributeById('id11')).rejects.toThrow('error-attribute-in-use'); + expect(mockAbacDeleteOne).not.toHaveBeenCalled(); + }); + + it('deletes attribute when not in use', async () => { + mockAbacFindOne.mockResolvedValueOnce({ _id: 'id12', key: 'FreeKey', values: ['x'] }); + mockRoomsFindOne.mockResolvedValueOnce(null); + mockAbacDeleteOne.mockResolvedValueOnce({ deletedCount: 1 }); + await service.deleteAbacAttributeById('id12'); + expect(mockAbacDeleteOne).toHaveBeenCalledWith({ _id: 'id12' }); + }); + }); }); }); diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index 017a247574c41..55be6dca3422a 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -74,16 +74,6 @@ export class AbacService extends ServiceClass implements IAbacService { /** * Updates an ABAC attribute definition by its _id. - * - * Validation & behavior: - * - Attribute must exist - * - key (if provided) must match /^[A-Za-z0-9_-]+$/ - * - values (if provided) must be a non-empty array - * - Duplicate key conflict surfaces as error-duplicate-attribute-key - * - If the key changes OR any existing value is removed, verify none of the removed identity (old key + removed values) - * is currently in use by any room. - * - * */ async updateAbacAttributeById(_id: string, update: { key?: string; values?: string[] }): Promise { if (!update.key && !update.values) { @@ -151,6 +141,38 @@ export class AbacService extends ServiceClass implements IAbacService { throw e; } } + + /** + * Deletes an ABAC attribute definition by its _id. + * It first checks whether any room is currently using the attribute (any of its values). + * If in use, throws error-attribute-in-use, otherwise removes the definition. + * + * @param _id Attribute document id + */ + async deleteAbacAttributeById(_id: string): Promise { + const existing = await AbacAttributes.findOne({ _id }, { projection: { key: 1, values: 1 } }); + if (!existing) { + throw new Error('error-attribute-not-found'); + } + + const inUse = await Rooms.findOne( + { + abacAttributes: { + $elemMatch: { + key: existing.key, + values: { $in: existing.values }, + }, + }, + }, + { projection: { _id: 1 } }, + ); + + if (inUse) { + throw new Error('error-attribute-in-use'); + } + + await AbacAttributes.deleteOne({ _id }); + } } export default AbacService; diff --git a/packages/core-services/src/types/IAbacService.ts b/packages/core-services/src/types/IAbacService.ts index 00e35d1e8fdeb..7cce3bc9ecadd 100644 --- a/packages/core-services/src/types/IAbacService.ts +++ b/packages/core-services/src/types/IAbacService.ts @@ -9,4 +9,5 @@ export interface IAbacService { count?: number; }): Promise<{ attributes: IAbacAttribute[]; offset: number; count: number; total: number }>; updateAbacAttributeById(_id: string, update: { key?: string; values?: string[] }): Promise; + deleteAbacAttributeById(_id: string): Promise; } From c472c4dabc00f4d9eb05ff0574b37b5070cd5f3a Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 1 Oct 2025 13:12:31 -0600 Subject: [PATCH 020/125] move check to model --- ee/packages/abac/src/index.spec.ts | 44 +++++++------------ ee/packages/abac/src/index.ts | 27 ++---------- .../model-typings/src/models/IRoomsModel.ts | 1 + packages/models/src/models/Rooms.ts | 18 ++++++++ 4 files changed, 38 insertions(+), 52 deletions(-) diff --git a/ee/packages/abac/src/index.spec.ts b/ee/packages/abac/src/index.spec.ts index 95e02d9f98e29..e84c618ca49fd 100644 --- a/ee/packages/abac/src/index.spec.ts +++ b/ee/packages/abac/src/index.spec.ts @@ -7,13 +7,13 @@ const mockAbacFindPaginated = jest.fn(); const mockAbacFindOne = jest.fn(); const mockAbacUpdateOne = jest.fn(); const mockAbacDeleteOne = jest.fn(); -const mockRoomsFindOne = jest.fn(); +const mockRoomsIsAbacAttributeInUse = jest.fn(); jest.mock('@rocket.chat/models', () => ({ Rooms: { findOneByIdAndType: (...args: any[]) => mockFindOneByIdAndType(...args), updateAbacConfigurationById: (...args: any[]) => mockUpdateAbacConfigurationById(...args), - findOne: (...args: any[]) => mockRoomsFindOne(...args), + isAbacAttributeInUse: (...args: any[]) => mockRoomsIsAbacAttributeInUse(...args), }, AbacAttributes: { insertOne: (...args: any[]) => mockAbacInsertOne(...args), @@ -172,7 +172,7 @@ describe('AbacService (unit)', () => { await service.updateAbacAttributeById('id1', {} as any); expect(mockAbacFindOne).not.toHaveBeenCalled(); expect(mockAbacUpdateOne).not.toHaveBeenCalled(); - expect(mockRoomsFindOne).not.toHaveBeenCalled(); + expect(mockRoomsIsAbacAttributeInUse).not.toHaveBeenCalled(); }); it('throws error-attribute-not-found when attribute does not exist', async () => { @@ -195,22 +195,16 @@ describe('AbacService (unit)', () => { it('throws error-attribute-in-use when key changes and old definition is in use', async () => { mockAbacFindOne.mockResolvedValueOnce({ _id: 'id4', key: 'Old', values: ['v1', 'v2'] }); - mockRoomsFindOne.mockResolvedValueOnce({ _id: 'roomUsing' }); + mockRoomsIsAbacAttributeInUse.mockResolvedValueOnce(true); await expect(service.updateAbacAttributeById('id4', { key: 'New' })).rejects.toThrow('error-attribute-in-use'); - expect(mockRoomsFindOne).toHaveBeenCalledWith( - { - abacAttributes: { - $elemMatch: { key: 'Old', values: { $in: ['v1', 'v2'] } }, - }, - }, - { projection: { _id: 1 } }, - ); + expect(mockRoomsIsAbacAttributeInUse).toHaveBeenCalledWith('Old', ['v1', 'v2']); + expect(mockAbacUpdateOne).not.toHaveBeenCalled(); }); it('updates key when changed and not in use', async () => { mockAbacFindOne.mockResolvedValueOnce({ _id: 'id5', key: 'Old', values: ['a'] }); - mockRoomsFindOne.mockResolvedValueOnce(null); + mockRoomsIsAbacAttributeInUse.mockResolvedValueOnce(false); mockAbacUpdateOne.mockResolvedValueOnce({ modifiedCount: 1 }); await service.updateAbacAttributeById('id5', { key: 'NewKey' }); expect(mockAbacUpdateOne).toHaveBeenCalledWith({ _id: 'id5' }, { $set: { key: 'NewKey' } }); @@ -219,22 +213,16 @@ describe('AbacService (unit)', () => { it('throws error-attribute-in-use when removing a value that is in use', async () => { mockAbacFindOne.mockResolvedValueOnce({ _id: 'id6', key: 'Attr', values: ['a', 'b', 'c'] }); // removedValues => ['b'] - mockRoomsFindOne.mockResolvedValueOnce({ _id: 'roomUsesRemoved' }); + mockRoomsIsAbacAttributeInUse.mockResolvedValueOnce(true); await expect(service.updateAbacAttributeById('id6', { values: ['a', 'c'] })).rejects.toThrow('error-attribute-in-use'); - expect(mockRoomsFindOne).toHaveBeenCalledWith( - { - abacAttributes: { - $elemMatch: { key: 'Attr', values: { $in: ['b'] } }, - }, - }, - { projection: { _id: 1 } }, - ); + expect(mockRoomsIsAbacAttributeInUse).toHaveBeenCalledWith('Attr', ['b']); + expect(mockAbacUpdateOne).not.toHaveBeenCalled(); }); it('updates values when removing some that are not in use', async () => { mockAbacFindOne.mockResolvedValueOnce({ _id: 'id7', key: 'Attr', values: ['a', 'b', 'c'] }); - mockRoomsFindOne.mockResolvedValueOnce(null); // removal safe + mockRoomsIsAbacAttributeInUse.mockResolvedValueOnce(false); // removal safe mockAbacUpdateOne.mockResolvedValueOnce({ modifiedCount: 1 }); await service.updateAbacAttributeById('id7', { values: ['a', 'c'] }); expect(mockAbacUpdateOne).toHaveBeenCalledWith({ _id: 'id7' }, { $set: { values: ['a', 'c'] } }); @@ -245,20 +233,20 @@ describe('AbacService (unit)', () => { // newValues = ['a','b'] => removedValues = [] mockAbacUpdateOne.mockResolvedValueOnce({ modifiedCount: 1 }); await service.updateAbacAttributeById('id8', { values: ['a', 'b'] }); - expect(mockRoomsFindOne).not.toHaveBeenCalled(); + expect(mockRoomsIsAbacAttributeInUse).not.toHaveBeenCalled(); expect(mockAbacUpdateOne).toHaveBeenCalledWith({ _id: 'id8' }, { $set: { values: ['a', 'b'] } }); }); it('throws error-duplicate-attribute-key on duplicate key error', async () => { mockAbacFindOne.mockResolvedValueOnce({ _id: 'id9', key: 'Old', values: ['v'] }); - mockRoomsFindOne.mockResolvedValueOnce(null); + mockRoomsIsAbacAttributeInUse.mockResolvedValueOnce(false); mockAbacUpdateOne.mockRejectedValueOnce(new Error('E11000 duplicate key error collection')); await expect(service.updateAbacAttributeById('id9', { key: 'NewKey' })).rejects.toThrow('error-duplicate-attribute-key'); }); it('propagates unexpected update errors', async () => { mockAbacFindOne.mockResolvedValueOnce({ _id: 'id10', key: 'Old', values: ['v'] }); - mockRoomsFindOne.mockResolvedValueOnce(null); + mockRoomsIsAbacAttributeInUse.mockResolvedValueOnce(false); mockAbacUpdateOne.mockRejectedValueOnce(new Error('write-failed')); await expect(service.updateAbacAttributeById('id10', { key: 'Another' })).rejects.toThrow('write-failed'); }); @@ -270,14 +258,14 @@ describe('AbacService (unit)', () => { it('throws error-attribute-in-use when attribute is referenced by a room', async () => { mockAbacFindOne.mockResolvedValueOnce({ _id: 'id11', key: 'KeyInUse', values: ['a', 'b'] }); - mockRoomsFindOne.mockResolvedValueOnce({ _id: 'roomUsingAttr' }); + mockRoomsIsAbacAttributeInUse.mockResolvedValueOnce(true); await expect(service.deleteAbacAttributeById('id11')).rejects.toThrow('error-attribute-in-use'); expect(mockAbacDeleteOne).not.toHaveBeenCalled(); }); it('deletes attribute when not in use', async () => { mockAbacFindOne.mockResolvedValueOnce({ _id: 'id12', key: 'FreeKey', values: ['x'] }); - mockRoomsFindOne.mockResolvedValueOnce(null); + mockRoomsIsAbacAttributeInUse.mockResolvedValueOnce(false); mockAbacDeleteOne.mockResolvedValueOnce({ deletedCount: 1 }); await service.deleteAbacAttributeById('id12'); expect(mockAbacDeleteOne).toHaveBeenCalledWith({ _id: 'id12' }); diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index 55be6dca3422a..b02da53ed460a 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -103,18 +103,8 @@ export class AbacService extends ServiceClass implements IAbacService { const valuesToCheck = keyChanged ? existing.values : removedValues; if (keyChanged || valuesToCheck.length) { - const inUse = await Rooms.findOne( - { - abacAttributes: { - $elemMatch: { - key: existing.key, - values: { $in: valuesToCheck.length ? valuesToCheck : existing.values }, - }, - }, - }, - { projection: { _id: 1 } }, - ); - + // Delegate usage detection to model helper to avoid duplicating query logic + const inUse = await Rooms.isAbacAttributeInUse(existing.key, valuesToCheck.length ? valuesToCheck : existing.values); if (inUse) { throw new Error('error-attribute-in-use'); } @@ -155,18 +145,7 @@ export class AbacService extends ServiceClass implements IAbacService { throw new Error('error-attribute-not-found'); } - const inUse = await Rooms.findOne( - { - abacAttributes: { - $elemMatch: { - key: existing.key, - values: { $in: existing.values }, - }, - }, - }, - { projection: { _id: 1 } }, - ); - + const inUse = await Rooms.isAbacAttributeInUse(existing.key, existing.values); if (inUse) { throw new Error('error-attribute-in-use'); } diff --git a/packages/model-typings/src/models/IRoomsModel.ts b/packages/model-typings/src/models/IRoomsModel.ts index 74546be2d2277..08f4bb5eba49c 100644 --- a/packages/model-typings/src/models/IRoomsModel.ts +++ b/packages/model-typings/src/models/IRoomsModel.ts @@ -29,6 +29,7 @@ export interface IChannelsWithNumberOfMessagesBetweenDate { } export interface IRoomsModel extends IBaseModel { + isAbacAttributeInUse(key: string, values: string[]): Promise; findOneByRoomIdAndUserId(rid: IRoom['_id'], uid: IUser['_id'], options?: FindOptions): Promise; findManyByRoomIds(roomIds: Array, options?: FindOptions): FindCursor; diff --git a/packages/models/src/models/Rooms.ts b/packages/models/src/models/Rooms.ts index 9ea4d7a8386c9..52d274cb109fd 100644 --- a/packages/models/src/models/Rooms.ts +++ b/packages/models/src/models/Rooms.ts @@ -117,6 +117,24 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { ]; } + async isAbacAttributeInUse(key: string, values: string[]): Promise { + if (!values.length) { + return false; + } + const room = await this.findOne( + { + abacAttributes: { + $elemMatch: { + key, + values: { $in: values }, + }, + }, + }, + { projection: { _id: 1 } }, + ); + return !!room; + } + findOneByRoomIdAndUserId(rid: IRoom['_id'], uid: IUser['_id'], options: FindOptions = {}): Promise { const query: Filter = { '_id': rid, From eb6c45dd53326a015b672d618267034a9a18d800 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 1 Oct 2025 13:34:57 -0600 Subject: [PATCH 021/125] check setting --- apps/meteor/ee/server/api/abac/index.ts | 20 ++++++++++++++++++++ ee/packages/abac/src/index.ts | 4 ---- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/apps/meteor/ee/server/api/abac/index.ts b/apps/meteor/ee/server/api/abac/index.ts index 058580a8d017f..29dda2406e854 100644 --- a/apps/meteor/ee/server/api/abac/index.ts +++ b/apps/meteor/ee/server/api/abac/index.ts @@ -9,6 +9,7 @@ import { } from './schemas'; import { API } from '../../../../app/api/server'; import type { ExtractRoutesFromAPI } from '../../../../app/api/server/ApiClass'; +import { settings } from '../../../../app/settings/server'; const abacEndpoints = API.v1 // add attributes for a room (bulk) @@ -49,6 +50,10 @@ const abacEndpoints = API.v1 async function action() { const { key, values, offset, count } = this.queryParams; + if (!settings.get('ABAC_Enabled')) { + throw new Error('error-abac-not-enabled'); + } + const attributes = await Abac.listAbacAttributes({ key, values, @@ -70,6 +75,10 @@ const abacEndpoints = API.v1 response: { 200: GenericSuccessSchema }, }, async function action() { + if (!settings.get('ABAC_Enabled')) { + throw new Error('error-abac-not-enabled'); + } + await Abac.addAbacAttribute(this.bodyParams); return API.v1.success(); }, @@ -86,6 +95,10 @@ const abacEndpoints = API.v1 }, async function action() { const { _id } = this.urlParams; + if (!settings.get('ABAC_Enabled')) { + throw new Error('error-abac-not-enabled'); + } + await Abac.updateAbacAttributeById(_id, this.bodyParams); return API.v1.success(); }, @@ -95,6 +108,10 @@ const abacEndpoints = API.v1 'abac/attributes/:key', { authRequired: true, permissionsRequired: ['abac-management'], response: {}, license: ['abac'] }, async function action() { + if (!settings.get('ABAC_Enabled')) { + throw new Error('error-abac-not-enabled'); + } + throw new Error('not-implemented'); }, ) @@ -103,6 +120,9 @@ const abacEndpoints = API.v1 'abac/attributes/:key/is-in-use', { authRequired: true, permissionsRequired: ['abac-management'], response: {}, license: ['abac'] }, async function action() { + if (!settings.get('ABAC_Enabled')) { + throw new Error('error-abac-not-enabled'); + } throw new Error('not-implemented'); }, ); diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index b02da53ed460a..6366ddc88a1a3 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -134,10 +134,6 @@ export class AbacService extends ServiceClass implements IAbacService { /** * Deletes an ABAC attribute definition by its _id. - * It first checks whether any room is currently using the attribute (any of its values). - * If in use, throws error-attribute-in-use, otherwise removes the definition. - * - * @param _id Attribute document id */ async deleteAbacAttributeById(_id: string): Promise { const existing = await AbacAttributes.findOne({ _id }, { projection: { key: 1, values: 1 } }); From 754a7acf76ec5c65176c1f3093427063a8445cbe Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 1 Oct 2025 13:38:44 -0600 Subject: [PATCH 022/125] delete --- apps/meteor/ee/server/api/abac/index.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/apps/meteor/ee/server/api/abac/index.ts b/apps/meteor/ee/server/api/abac/index.ts index 29dda2406e854..c1aa5ae206c9e 100644 --- a/apps/meteor/ee/server/api/abac/index.ts +++ b/apps/meteor/ee/server/api/abac/index.ts @@ -105,14 +105,20 @@ const abacEndpoints = API.v1 ) // delete attribute (only if not in use) .delete( - 'abac/attributes/:key', - { authRequired: true, permissionsRequired: ['abac-management'], response: {}, license: ['abac'] }, + 'abac/attributes/:_id', + { + authRequired: true, + permissionsRequired: ['abac-management'], + response: { 200: GenericSuccessSchema }, + license: ['abac'], + }, async function action() { + const { _id } = this.urlParams; if (!settings.get('ABAC_Enabled')) { throw new Error('error-abac-not-enabled'); } - - throw new Error('not-implemented'); + await Abac.deleteAbacAttributeById(_id); + return API.v1.success(); }, ) // check if attribute is in use From 114e6a08c195ac18bc199cdc5556bb219a601ded Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 1 Oct 2025 14:36:22 -0600 Subject: [PATCH 023/125] attribute by id --- apps/meteor/ee/server/api/abac/index.ts | 8 ++++++ ee/packages/abac/src/index.spec.ts | 27 +++++++++++++++++++ ee/packages/abac/src/index.ts | 21 +++++++++++++++ .../core-services/src/types/IAbacService.ts | 2 ++ 4 files changed, 58 insertions(+) diff --git a/apps/meteor/ee/server/api/abac/index.ts b/apps/meteor/ee/server/api/abac/index.ts index c1aa5ae206c9e..0bee87e831304 100644 --- a/apps/meteor/ee/server/api/abac/index.ts +++ b/apps/meteor/ee/server/api/abac/index.ts @@ -64,6 +64,14 @@ const abacEndpoints = API.v1 return API.v1.success(attributes); }, ) + // get an attribute by id + .get( + 'abac/attributes/:_id', + { authRequired: true, permissionsRequired: ['abac-management'], response: {}, license: ['abac'] }, + async function action() { + throw new Error('not-implemented'); + }, + ) // create attribute .post( 'abac/attributes', diff --git a/ee/packages/abac/src/index.spec.ts b/ee/packages/abac/src/index.spec.ts index e84c618ca49fd..52619150935ec 100644 --- a/ee/packages/abac/src/index.spec.ts +++ b/ee/packages/abac/src/index.spec.ts @@ -272,4 +272,31 @@ describe('AbacService (unit)', () => { }); }); }); + describe('getAbacAttributeById', () => { + it('throws error-attribute-not-found when attribute does not exist', async () => { + mockAbacFindOne.mockResolvedValueOnce(null); + await expect(service.getAbacAttributeById('missingAttr')).rejects.toThrow('error-attribute-not-found'); + expect(mockRoomsIsAbacAttributeInUse).not.toHaveBeenCalled(); + }); + + it('returns attribute with per-value usage map', async () => { + mockAbacFindOne.mockResolvedValueOnce({ _id: 'id13', key: 'Attr', values: ['a', 'b', 'c'] }); + // Usage results for each value + mockRoomsIsAbacAttributeInUse + .mockResolvedValueOnce(true) // a + .mockResolvedValueOnce(false) // b + .mockResolvedValueOnce(true); // c + + const result = await service.getAbacAttributeById('id13'); + expect(mockAbacFindOne).toHaveBeenCalledWith({ _id: 'id13' }, { projection: { key: 1, values: 1 } }); + expect(mockRoomsIsAbacAttributeInUse).toHaveBeenNthCalledWith(1, 'Attr', ['a']); + expect(mockRoomsIsAbacAttributeInUse).toHaveBeenNthCalledWith(2, 'Attr', ['b']); + expect(mockRoomsIsAbacAttributeInUse).toHaveBeenNthCalledWith(3, 'Attr', ['c']); + + expect(result).toEqual({ + attribute: { _id: 'id13', key: 'Attr', values: ['a', 'b', 'c'] }, + usage: { a: true, b: false, c: true }, + }); + }); + }); }); diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index 6366ddc88a1a3..15c39e7442843 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -148,6 +148,27 @@ export class AbacService extends ServiceClass implements IAbacService { await AbacAttributes.deleteOne({ _id }); } + + async getAbacAttributeById(_id: string): Promise<{ attribute: IAbacAttribute; usage: Record }> { + const attribute = await AbacAttributes.findOne({ _id }, { projection: { key: 1, values: 1 } }); + if (!attribute) { + throw new Error('error-attribute-not-found'); + } + + const usageEntries = await Promise.all( + (attribute.values || []).map(async (value) => { + const used = await Rooms.isAbacAttributeInUse(attribute.key, [value]); + return [value, used] as const; + }), + ); + + const usage: Record = Object.fromEntries(usageEntries); + + return { + ...attribute, + usage, + }; + } } export default AbacService; diff --git a/packages/core-services/src/types/IAbacService.ts b/packages/core-services/src/types/IAbacService.ts index 7cce3bc9ecadd..0e0ae91938ee1 100644 --- a/packages/core-services/src/types/IAbacService.ts +++ b/packages/core-services/src/types/IAbacService.ts @@ -10,4 +10,6 @@ export interface IAbacService { }): Promise<{ attributes: IAbacAttribute[]; offset: number; count: number; total: number }>; updateAbacAttributeById(_id: string, update: { key?: string; values?: string[] }): Promise; deleteAbacAttributeById(_id: string): Promise; + // Usage represents if the attribute values are in use or not. If no values are in use, the attribute is not in use. + getAbacAttributeById(_id: string): Promise<{ key: string; values: string[]; usage: Record }>; } From 6eaf317cc166bbe5116f468611203b74f308020a Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Thu, 2 Oct 2025 09:43:49 -0600 Subject: [PATCH 024/125] check if attribute is in use by key --- apps/meteor/ee/server/api/abac/index.ts | 55 +++++++++++++------ apps/meteor/ee/server/api/abac/schemas.ts | 44 +++++++++++++-- ee/packages/abac/src/index.spec.ts | 37 ++++++++++++- ee/packages/abac/src/index.ts | 13 ++++- .../core-services/src/types/IAbacService.ts | 1 + 5 files changed, 125 insertions(+), 25 deletions(-) diff --git a/apps/meteor/ee/server/api/abac/index.ts b/apps/meteor/ee/server/api/abac/index.ts index 0bee87e831304..73ea2042d7bd8 100644 --- a/apps/meteor/ee/server/api/abac/index.ts +++ b/apps/meteor/ee/server/api/abac/index.ts @@ -5,7 +5,9 @@ import { PUTAbacAttributeUpdateBodySchema, GETAbacAttributesQuerySchema, GETAbacAttributesResponseSchema, + GETAbacAttributeByIdResponseSchema, POSTAbacAttributeDefinitionSchema, + GETAbacAttributeIsInUseResponseSchema, } from './schemas'; import { API } from '../../../../app/api/server'; import type { ExtractRoutesFromAPI } from '../../../../app/api/server/ApiClass'; @@ -54,22 +56,14 @@ const abacEndpoints = API.v1 throw new Error('error-abac-not-enabled'); } - const attributes = await Abac.listAbacAttributes({ - key, - values, - offset, - count, - }); - - return API.v1.success(attributes); - }, - ) - // get an attribute by id - .get( - 'abac/attributes/:_id', - { authRequired: true, permissionsRequired: ['abac-management'], response: {}, license: ['abac'] }, - async function action() { - throw new Error('not-implemented'); + return API.v1.success( + await Abac.listAbacAttributes({ + key, + values, + offset, + count, + }), + ); }, ) // create attribute @@ -111,6 +105,24 @@ const abacEndpoints = API.v1 return API.v1.success(); }, ) + // get single attribute with usage + .get( + 'abac/attributes/:_id', + { + authRequired: true, + permissionsRequired: ['abac-management'], + response: { 200: GETAbacAttributeByIdResponseSchema }, + license: ['abac'], + }, + async function action() { + const { _id } = this.urlParams; + if (!settings.get('ABAC_Enabled')) { + throw new Error('error-abac-not-enabled'); + } + const result = await Abac.getAbacAttributeById(_id); + return API.v1.success(result); + }, + ) // delete attribute (only if not in use) .delete( 'abac/attributes/:_id', @@ -132,12 +144,19 @@ const abacEndpoints = API.v1 // check if attribute is in use .get( 'abac/attributes/:key/is-in-use', - { authRequired: true, permissionsRequired: ['abac-management'], response: {}, license: ['abac'] }, + { + authRequired: true, + permissionsRequired: ['abac-management'], + response: { 200: GETAbacAttributeIsInUseResponseSchema }, + license: ['abac'], + }, async function action() { + const { key } = this.urlParams; if (!settings.get('ABAC_Enabled')) { throw new Error('error-abac-not-enabled'); } - throw new Error('not-implemented'); + const inUse = await Abac.isAbacAttributeInUseByKey(key); + return API.v1.success({ inUse }); }, ); diff --git a/apps/meteor/ee/server/api/abac/schemas.ts b/apps/meteor/ee/server/api/abac/schemas.ts index 81f195f47efc0..ce4d43785ab1d 100644 --- a/apps/meteor/ee/server/api/abac/schemas.ts +++ b/apps/meteor/ee/server/api/abac/schemas.ts @@ -1,16 +1,13 @@ import type { IAbacAttribute, IAbacAttributeDefinition } from '@rocket.chat/core-typings'; import Ajv from 'ajv'; -import type { AbacEndpoints } from '.'; +// Removed AbacEndpoints import to avoid circular type reference (endpoints import these schemas) const ajv = new Ajv({ coerceTypes: true, }); -declare module '@rocket.chat/rest-typings' { - // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface - interface Endpoints extends AbacEndpoints {} -} +// Omitted module augmentation to prevent circular reference with endpoint definitions const GenericSuccess = { type: 'object', @@ -120,3 +117,40 @@ export const GETAbacAttributesResponseSchema = ajv.compile<{ count: number; total: number; }>(GetAbacAttributesResponse); + +const GetAbacAttributeByIdResponse = { + type: 'object', + properties: { + key: { type: 'string', minLength: 1 }, + values: { + type: 'array', + items: { type: 'string', minLength: 1 }, + minItems: 1, + maxItems: 10, + uniqueItems: true, + }, + usage: { + type: 'object', + additionalProperties: { type: 'boolean' }, + }, + }, + required: ['attribute', 'usage'], + additionalProperties: false, +}; + +export const GETAbacAttributeByIdResponseSchema = ajv.compile<{ + key: string; + values: string[]; + usage: Record; +}>(GetAbacAttributeByIdResponse); + +const GetAbacAttributeIsInUseResponse = { + type: 'object', + properties: { + inUse: { type: 'boolean' }, + }, + required: ['inUse'], + additionalProperties: false, +}; + +export const GETAbacAttributeIsInUseResponseSchema = ajv.compile<{ inUse: boolean }>(GetAbacAttributeIsInUseResponse); diff --git a/ee/packages/abac/src/index.spec.ts b/ee/packages/abac/src/index.spec.ts index 52619150935ec..3004b02cf1f9a 100644 --- a/ee/packages/abac/src/index.spec.ts +++ b/ee/packages/abac/src/index.spec.ts @@ -294,9 +294,44 @@ describe('AbacService (unit)', () => { expect(mockRoomsIsAbacAttributeInUse).toHaveBeenNthCalledWith(3, 'Attr', ['c']); expect(result).toEqual({ - attribute: { _id: 'id13', key: 'Attr', values: ['a', 'b', 'c'] }, + _id: 'id13', + key: 'Attr', + values: ['a', 'b', 'c'], usage: { a: true, b: false, c: true }, }); }); }); + + describe('isAbacAttributeInUseByKey', () => { + it('returns false when attribute does not exist', async () => { + mockAbacFindOne.mockResolvedValueOnce(null); + const result = await service.isAbacAttributeInUseByKey('missing'); + expect(result).toBe(false); + expect(mockRoomsIsAbacAttributeInUse).not.toHaveBeenCalled(); + }); + + it('returns false when attribute exists but has no values', async () => { + mockAbacFindOne.mockResolvedValueOnce({ _id: 'id14', key: 'Empty', values: [] }); + mockRoomsIsAbacAttributeInUse.mockResolvedValueOnce(false); + const result = await service.isAbacAttributeInUseByKey('Empty'); + expect(result).toBe(false); + expect(mockRoomsIsAbacAttributeInUse).toHaveBeenCalledWith('Empty', []); + }); + + it('returns true when any value is in use', async () => { + mockAbacFindOne.mockResolvedValueOnce({ _id: 'id15', key: 'Attr2', values: ['x', 'y'] }); + mockRoomsIsAbacAttributeInUse.mockResolvedValueOnce(true); + const result = await service.isAbacAttributeInUseByKey('Attr2'); + expect(result).toBe(true); + expect(mockRoomsIsAbacAttributeInUse).toHaveBeenCalledWith('Attr2', ['x', 'y']); + }); + + it('returns false when no values are in use', async () => { + mockAbacFindOne.mockResolvedValueOnce({ _id: 'id16', key: 'Attr3', values: ['m', 'n'] }); + mockRoomsIsAbacAttributeInUse.mockResolvedValueOnce(false); + const result = await service.isAbacAttributeInUseByKey('Attr3'); + expect(result).toBe(false); + expect(mockRoomsIsAbacAttributeInUse).toHaveBeenCalledWith('Attr3', ['m', 'n']); + }); + }); }); diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index 15c39e7442843..f4bd6998f1aa4 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -149,7 +149,7 @@ export class AbacService extends ServiceClass implements IAbacService { await AbacAttributes.deleteOne({ _id }); } - async getAbacAttributeById(_id: string): Promise<{ attribute: IAbacAttribute; usage: Record }> { + async getAbacAttributeById(_id: string): Promise<{ key: string; values: string[]; usage: Record }> { const attribute = await AbacAttributes.findOne({ _id }, { projection: { key: 1, values: 1 } }); if (!attribute) { throw new Error('error-attribute-not-found'); @@ -169,6 +169,17 @@ export class AbacService extends ServiceClass implements IAbacService { usage, }; } + + async isAbacAttributeInUseByKey(key: string): Promise { + // Fetch the attribute definition by key to obtain its values + const attribute = await AbacAttributes.findOne({ key }, { projection: { values: 1 } }); + if (!attribute) { + // If it doesn't exist, it cannot be in use + return false; + } + // If any of its values is in use in any room, the attribute is considered in use + return Rooms.isAbacAttributeInUse(key, attribute.values || []); + } } export default AbacService; diff --git a/packages/core-services/src/types/IAbacService.ts b/packages/core-services/src/types/IAbacService.ts index 0e0ae91938ee1..7f410650eeb0d 100644 --- a/packages/core-services/src/types/IAbacService.ts +++ b/packages/core-services/src/types/IAbacService.ts @@ -12,4 +12,5 @@ export interface IAbacService { deleteAbacAttributeById(_id: string): Promise; // Usage represents if the attribute values are in use or not. If no values are in use, the attribute is not in use. getAbacAttributeById(_id: string): Promise<{ key: string; values: string[]; usage: Record }>; + isAbacAttributeInUseByKey(key: string): Promise; } From 754bed26dc532ba9dc40808c185dc128a99dd4e7 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Thu, 2 Oct 2025 12:50:01 -0600 Subject: [PATCH 025/125] abac set attributes --- apps/meteor/ee/server/api/abac/index.ts | 24 +++- apps/meteor/ee/server/api/abac/schemas.ts | 18 +++ ee/packages/abac/src/index.spec.ts | 92 ++++++++++++++ ee/packages/abac/src/index.ts | 118 ++++++++++++++++-- .../core-services/src/types/IAbacService.ts | 1 + .../model-typings/src/models/IRoomsModel.ts | 1 + packages/models/src/models/Rooms.ts | 12 ++ 7 files changed, 255 insertions(+), 11 deletions(-) diff --git a/apps/meteor/ee/server/api/abac/index.ts b/apps/meteor/ee/server/api/abac/index.ts index 73ea2042d7bd8..979044d21ea90 100644 --- a/apps/meteor/ee/server/api/abac/index.ts +++ b/apps/meteor/ee/server/api/abac/index.ts @@ -8,18 +8,34 @@ import { GETAbacAttributeByIdResponseSchema, POSTAbacAttributeDefinitionSchema, GETAbacAttributeIsInUseResponseSchema, + POSTRoomAbacAttributesBodySchema, } from './schemas'; import { API } from '../../../../app/api/server'; import type { ExtractRoutesFromAPI } from '../../../../app/api/server/ApiClass'; import { settings } from '../../../../app/settings/server'; const abacEndpoints = API.v1 - // add attributes for a room (bulk) .post( 'abac/room/:rid/attributes', - { authRequired: true, permissionsRequired: ['abac-management'], response: {}, license: ['abac'] }, + { + authRequired: true, + permissionsRequired: ['abac-management'], + body: POSTRoomAbacAttributesBodySchema, + response: { 200: GenericSuccessSchema }, + license: ['abac'], + }, async function action() { - throw new Error('not-implemented'); + const { rid } = this.urlParams; + if (!settings.get('ABAC_Enabled')) { + throw new Error('error-abac-not-enabled'); + } + + const { attributes } = this.bodyParams; + const attributeRecord = Object.fromEntries( + (attributes || []).map(({ key, values }: { key: string; values: string[] }) => [key, values]), + ); + await Abac.setRoomAbacAttributes(rid, attributeRecord); + return API.v1.success(); }, ) // edit a room attribute @@ -33,7 +49,7 @@ const abacEndpoints = API.v1 // delete a room attribute .delete( 'abac/room/:rid/attributes/:key', - { authRequired: true, permissionsRequired: ['abac-management'], response: {}, license: ['abac'] }, + { authRequired: true, permissionsRequired: ['abac-management'], response: {} }, async function action() { throw new Error('not-implemented'); }, diff --git a/apps/meteor/ee/server/api/abac/schemas.ts b/apps/meteor/ee/server/api/abac/schemas.ts index ce4d43785ab1d..5853bc2f1645f 100644 --- a/apps/meteor/ee/server/api/abac/schemas.ts +++ b/apps/meteor/ee/server/api/abac/schemas.ts @@ -154,3 +154,21 @@ const GetAbacAttributeIsInUseResponse = { }; export const GETAbacAttributeIsInUseResponseSchema = ajv.compile<{ inUse: boolean }>(GetAbacAttributeIsInUseResponse); + +// Bulk set/merge room ABAC attributes body schema +const PostRoomAbacAttributesBody = { + type: 'object', + properties: { + attributes: { + type: 'array', + items: AbacAttributeDefinition, + minItems: 1, + maxItems: 50, + uniqueItems: true, + }, + }, + required: ['attributes'], + additionalProperties: false, +}; + +export const POSTRoomAbacAttributesBodySchema = ajv.compile<{ attributes: IAbacAttributeDefinition[] }>(PostRoomAbacAttributesBody); diff --git a/ee/packages/abac/src/index.spec.ts b/ee/packages/abac/src/index.spec.ts index 3004b02cf1f9a..a476a79d33e25 100644 --- a/ee/packages/abac/src/index.spec.ts +++ b/ee/packages/abac/src/index.spec.ts @@ -8,12 +8,15 @@ const mockAbacFindOne = jest.fn(); const mockAbacUpdateOne = jest.fn(); const mockAbacDeleteOne = jest.fn(); const mockRoomsIsAbacAttributeInUse = jest.fn(); +const mockSetAbacAttributesById = jest.fn(); +const mockAbacFind = jest.fn(); jest.mock('@rocket.chat/models', () => ({ Rooms: { findOneByIdAndType: (...args: any[]) => mockFindOneByIdAndType(...args), updateAbacConfigurationById: (...args: any[]) => mockUpdateAbacConfigurationById(...args), isAbacAttributeInUse: (...args: any[]) => mockRoomsIsAbacAttributeInUse(...args), + setAbacAttributesById: (...args: any[]) => mockSetAbacAttributesById(...args), }, AbacAttributes: { insertOne: (...args: any[]) => mockAbacInsertOne(...args), @@ -21,6 +24,7 @@ jest.mock('@rocket.chat/models', () => ({ findOne: (...args: any[]) => mockAbacFindOne(...args), updateOne: (...args: any[]) => mockAbacUpdateOne(...args), deleteOne: (...args: any[]) => mockAbacDeleteOne(...args), + find: (...args: any[]) => mockAbacFind(...args), }, })); @@ -300,6 +304,94 @@ describe('AbacService (unit)', () => { usage: { a: true, b: false, c: true }, }); }); + + describe('setRoomAbacAttributes', () => { + // Using top-level mocks (mockSetAbacAttributesById, mockAbacFind) defined in jest.mock factory above + + beforeEach(() => { + mockSetAbacAttributesById.mockReset(); + mockAbacFind.mockReset(); + mockFindOneByIdAndType.mockReset(); + mockRoomsIsAbacAttributeInUse.mockReset(); + // Provide a default empty cursor so AbacAttributes.find always returns an object with toArray + mockAbacFind.mockReturnValue({ toArray: async () => [] }); + // Prevent the protected hook from throwing + (service as any).onRoomAttributesChanged = jest.fn().mockResolvedValue(undefined); + }); + + it('throws error-room-not-found when room does not exist', async () => { + mockFindOneByIdAndType.mockResolvedValueOnce(null); + await expect(service.setRoomAbacAttributes('missing', { dept: ['eng'] })).rejects.toThrow('error-room-not-found'); + expect(mockSetAbacAttributesById).not.toHaveBeenCalled(); + }); + + it('throws error-invalid-attribute-key for invalid key format', async () => { + mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [] }); + await expect(service.setRoomAbacAttributes('r1', { 'bad key': ['v'] } as any)).rejects.toThrow('error-invalid-attribute-key'); + expect(mockSetAbacAttributesById).not.toHaveBeenCalled(); + }); + + it('throws error-invalid-attribute-values for empty value array', async () => { + mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [] }); + await expect(service.setRoomAbacAttributes('r1', { dept: [] as any })).rejects.toThrow('error-invalid-attribute-values'); + expect(mockSetAbacAttributesById).not.toHaveBeenCalled(); + }); + + it('throws error-attribute-definition-not-found when definition for key missing', async () => { + mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [] }); + // Return empty list so size mismatch triggers not-found + mockAbacFind.mockReturnValueOnce({ toArray: async () => [] }); + await expect(service.setRoomAbacAttributes('r1', { dept: ['eng'] })).rejects.toThrow('error-attribute-definition-not-found'); + expect(mockSetAbacAttributesById).not.toHaveBeenCalled(); + }); + + it('throws error-invalid-attribute-values when a provided value not in definition', async () => { + mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [] }); + mockAbacFind.mockReturnValueOnce({ + toArray: async () => [{ key: 'dept', values: ['eng'] }], // 'sales' not allowed + }); + await expect(service.setRoomAbacAttributes('r1', { dept: ['eng', 'sales'] })).rejects.toThrow('error-invalid-attribute-values'); + expect(mockSetAbacAttributesById).not.toHaveBeenCalled(); + }); + + it('normalizes (deduplicates) values and sets attributes', async () => { + mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [] }); + mockAbacFind.mockReturnValueOnce({ + toArray: async () => [{ key: 'dept', values: ['eng', 'sales'] }], + }); + + await service.setRoomAbacAttributes('r1', { dept: ['eng', 'eng', 'sales'] }); + + expect(mockSetAbacAttributesById).toHaveBeenCalledWith('r1', [{ key: 'dept', values: ['eng', 'sales'] }]); + expect((service as any).onRoomAttributesChanged).not.toHaveBeenCalled(); + }); + + it('calls onRoomAttributesChanged when an existing value is removed', async () => { + const existing = [{ key: 'dept', values: ['eng', 'sales'] }]; + mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: existing }); + mockAbacFind.mockReturnValueOnce({ + toArray: async () => [{ key: 'dept', values: ['eng', 'sales'] }], + }); + + await service.setRoomAbacAttributes('r1', { dept: ['eng'] }); // removing 'sales' + + expect((service as any).onRoomAttributesChanged).toHaveBeenCalledWith('r1', [{ key: 'dept', values: ['eng'] }]); + expect(mockSetAbacAttributesById).toHaveBeenCalledWith('r1', [{ key: 'dept', values: ['eng'] }]); + }); + + it('does not call onRoomAttributesChanged when only adding values', async () => { + const existing = [{ key: 'dept', values: ['eng'] }]; + mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: existing }); + mockAbacFind.mockReturnValueOnce({ + toArray: async () => [{ key: 'dept', values: ['eng', 'sales'] }], + }); + + await service.setRoomAbacAttributes('r1', { dept: ['eng', 'sales'] }); // adding sales + + expect((service as any).onRoomAttributesChanged).not.toHaveBeenCalled(); + expect(mockSetAbacAttributesById).toHaveBeenCalledWith('r1', [{ key: 'dept', values: ['eng', 'sales'] }]); + }); + }); }); describe('isAbacAttributeInUseByKey', () => { diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index f4bd6998f1aa4..28dc8000e0b0e 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -36,8 +36,6 @@ export class AbacService extends ServiceClass implements IAbacService { /** * Lists ABAC attribute definitions with optional filtering and pagination. - * - * @param filters optional filtering and pagination parameters */ async listAbacAttributes(filters?: { key?: string; values?: string[]; offset?: number; count?: number }): Promise<{ attributes: IAbacAttribute[]; @@ -99,11 +97,9 @@ export class AbacService extends ServiceClass implements IAbacService { const removedValues = existing.values.filter((v) => !newValues.includes(v)); const keyChanged = newKey !== existing.key; - // If key changed, all old values are considered removed under the old key context const valuesToCheck = keyChanged ? existing.values : removedValues; if (keyChanged || valuesToCheck.length) { - // Delegate usage detection to model helper to avoid duplicating query logic const inUse = await Rooms.isAbacAttributeInUse(existing.key, valuesToCheck.length ? valuesToCheck : existing.values); if (inUse) { throw new Error('error-attribute-in-use'); @@ -171,15 +167,123 @@ export class AbacService extends ServiceClass implements IAbacService { } async isAbacAttributeInUseByKey(key: string): Promise { - // Fetch the attribute definition by key to obtain its values const attribute = await AbacAttributes.findOne({ key }, { projection: { values: 1 } }); if (!attribute) { - // If it doesn't exist, it cannot be in use return false; } - // If any of its values is in use in any room, the attribute is considered in use return Rooms.isAbacAttributeInUse(key, attribute.values || []); } + + /** + * Sets ABAC attributes for a private room. + * This method now delegates to smaller private helpers for readability and testability. + */ + async setRoomAbacAttributes(rid: string, attributes: Record): Promise { + const room = await this.getPrivateRoomOrThrow(rid); + + const normalized = this.validateAndNormalizeAttributes(attributes); + + await this.ensureAttributeDefinitionsExist(normalized); + + const previous: IAbacAttributeDefinition[] = room.abacAttributes || []; + const removed = this.computeAttributesRemoval(previous, normalized); + + if (removed) { + await this.onRoomAttributesChanged(rid, normalized); + } + + await Rooms.setAbacAttributesById(rid, normalized); + } + + /** + * Fetches a private room by id or throws if not found. + */ + private async getPrivateRoomOrThrow(rid: string): Promise<{ abacAttributes?: IAbacAttributeDefinition[] }> { + const room = await Rooms.findOneByIdAndType(rid, 'p', { projection: { abacAttributes: 1 } }); + if (!room) { + throw new Error('error-room-not-found'); + } + return room; + } + + /** + * Validates provided raw attributes object and normalizes it into a list + * of attribute definitions while deduplicating values. + */ + private validateAndNormalizeAttributes(attributes: Record): IAbacAttributeDefinition[] { + const keyPattern = /^[A-Za-z0-9_-]+$/; + const normalized: IAbacAttributeDefinition[] = []; + + for (const [key, values] of Object.entries(attributes)) { + if (!keyPattern.test(key)) { + throw new Error('error-invalid-attribute-key'); + } + if (!values?.length) { + throw new Error('error-invalid-attribute-values'); + } + const uniqueValues = Array.from(new Set(values)); + normalized.push({ key, values: uniqueValues }); + } + + return normalized; + } + + /** + * Ensures all attribute definitions exist in the global registry and that every + * provided value is allowed for its corresponding key. + */ + private async ensureAttributeDefinitionsExist(normalized: IAbacAttributeDefinition[]): Promise { + if (!normalized.length) { + return; + } + + const keys = normalized.map((a) => a.key); + const attributeDefinitionsCursor = AbacAttributes.find({ key: { $in: keys } }, { projection: { key: 1, values: 1 } }); + const attributeDefinitions = await attributeDefinitionsCursor.toArray(); + + const definitionValuesMap = new Map>(attributeDefinitions.map((def: any) => [def.key, new Set(def.values)])); + if (definitionValuesMap.size !== keys.length) { + throw new Error('error-attribute-definition-not-found'); + } + + for (const a of normalized) { + const allowed = definitionValuesMap.get(a.key); + if (!allowed) { + throw new Error('error-attribute-definition-not-found'); + } + for (const v of a.values) { + if (!allowed.has(v)) { + throw new Error('error-invalid-attribute-values'); + } + } + } + } + + /** + * Compares previous vs new attributes and returns true if any attribute key + * or individual attribute value has been removed. + */ + private computeAttributesRemoval(previous: IAbacAttributeDefinition[], next: IAbacAttributeDefinition[]): boolean { + const newMap = new Map>(next.map((a) => [a.key, new Set(a.values)])); + + for (const prev of previous) { + const current = newMap.get(prev.key); + if (!current) { + return true; + } + for (const val of prev.values) { + if (!current.has(val)) { + return true; + } + } + } + + return false; + } + + protected async onRoomAttributesChanged(_rid: string, _newAttributes: IAbacAttributeDefinition[]): Promise { + throw new Error('not implemented'); + } } export default AbacService; diff --git a/packages/core-services/src/types/IAbacService.ts b/packages/core-services/src/types/IAbacService.ts index 7f410650eeb0d..08ebb0d1ecb89 100644 --- a/packages/core-services/src/types/IAbacService.ts +++ b/packages/core-services/src/types/IAbacService.ts @@ -13,4 +13,5 @@ export interface IAbacService { // Usage represents if the attribute values are in use or not. If no values are in use, the attribute is not in use. getAbacAttributeById(_id: string): Promise<{ key: string; values: string[]; usage: Record }>; isAbacAttributeInUseByKey(key: string): Promise; + setRoomAbacAttributes(rid: string, attributes: Record): Promise; } diff --git a/packages/model-typings/src/models/IRoomsModel.ts b/packages/model-typings/src/models/IRoomsModel.ts index 08f4bb5eba49c..1ce3ba74f6514 100644 --- a/packages/model-typings/src/models/IRoomsModel.ts +++ b/packages/model-typings/src/models/IRoomsModel.ts @@ -318,4 +318,5 @@ export interface IRoomsModel extends IBaseModel { hasCreatedRolePrioritiesForRoom(rid: IRoom['_id'], syncVersion: number): Promise; countDistinctFederationRoomsExcluding(serverNames?: string[]): Promise; updateAbacConfigurationById(rid: IRoom['_id'], abac: boolean): Promise; + setAbacAttributesById(rid: IRoom['_id'], attributes: NonNullable): Promise; } diff --git a/packages/models/src/models/Rooms.ts b/packages/models/src/models/Rooms.ts index 52d274cb109fd..02441430bb925 100644 --- a/packages/models/src/models/Rooms.ts +++ b/packages/models/src/models/Rooms.ts @@ -1966,6 +1966,18 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { return this.updateOne(query, update); } + setAbacAttributesById(_id: IRoom['_id'], attributes: NonNullable): Promise { + const query: Filter = { _id }; + + const update: UpdateFilter = { + $set: { + abacAttributes: attributes, + }, + }; + + return this.updateOne(query, update); + } + updateGroupDMsRemovingUsernamesByUsername(username: string, userId: string): Promise { const query: Filter = { t: 'd', From 60070df03d0c489032abc6e70d6bba4fc623275a Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Thu, 2 Oct 2025 14:25:54 -0600 Subject: [PATCH 026/125] remove comments --- apps/meteor/ee/server/api/abac/index.ts | 15 +++--- apps/meteor/ee/server/api/abac/schemas.ts | 15 +++--- ee/packages/abac/src/index.ts | 56 +++++------------------ 3 files changed, 27 insertions(+), 59 deletions(-) diff --git a/apps/meteor/ee/server/api/abac/index.ts b/apps/meteor/ee/server/api/abac/index.ts index 979044d21ea90..525037e9a6f6b 100644 --- a/apps/meteor/ee/server/api/abac/index.ts +++ b/apps/meteor/ee/server/api/abac/index.ts @@ -22,19 +22,16 @@ const abacEndpoints = API.v1 permissionsRequired: ['abac-management'], body: POSTRoomAbacAttributesBodySchema, response: { 200: GenericSuccessSchema }, - license: ['abac'], }, async function action() { const { rid } = this.urlParams; - if (!settings.get('ABAC_Enabled')) { + const { attributes } = this.bodyParams; + + if (!settings.get('ABAC_Enabled') && Object.keys(attributes).length) { throw new Error('error-abac-not-enabled'); } - const { attributes } = this.bodyParams; - const attributeRecord = Object.fromEntries( - (attributes || []).map(({ key, values }: { key: string; values: string[] }) => [key, values]), - ); - await Abac.setRoomAbacAttributes(rid, attributeRecord); + await Abac.setRoomAbacAttributes(rid, attributes); return API.v1.success(); }, ) @@ -43,7 +40,9 @@ const abacEndpoints = API.v1 'abac/room/:rid/attributes/:key', { authRequired: true, permissionsRequired: ['abac-management'], response: {}, license: ['abac'] }, async function action() { - throw new Error('not-implemented'); + if (!settings.get('ABAC_Enabled')) { + throw new Error('error-abac-not-enabled'); + } }, ) // delete a room attribute diff --git a/apps/meteor/ee/server/api/abac/schemas.ts b/apps/meteor/ee/server/api/abac/schemas.ts index 5853bc2f1645f..df39e341db788 100644 --- a/apps/meteor/ee/server/api/abac/schemas.ts +++ b/apps/meteor/ee/server/api/abac/schemas.ts @@ -155,20 +155,21 @@ const GetAbacAttributeIsInUseResponse = { export const GETAbacAttributeIsInUseResponseSchema = ajv.compile<{ inUse: boolean }>(GetAbacAttributeIsInUseResponse); -// Bulk set/merge room ABAC attributes body schema const PostRoomAbacAttributesBody = { type: 'object', properties: { attributes: { - type: 'array', - items: AbacAttributeDefinition, - minItems: 1, - maxItems: 50, - uniqueItems: true, + type: 'object', + propertyNames: { type: 'string', pattern: '^[A-Za-z0-9_-]+$' }, + additionalProperties: { + type: 'array', + items: { type: 'string', minLength: 1 }, + uniqueItems: true, + }, }, }, required: ['attributes'], additionalProperties: false, }; -export const POSTRoomAbacAttributesBodySchema = ajv.compile<{ attributes: IAbacAttributeDefinition[] }>(PostRoomAbacAttributesBody); +export const POSTRoomAbacAttributesBodySchema = ajv.compile<{ attributes: Record }>(PostRoomAbacAttributesBody); diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index 28dc8000e0b0e..db5de6616a16b 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -7,13 +7,6 @@ import type { Filter, UpdateFilter } from 'mongodb'; export class AbacService extends ServiceClass implements IAbacService { protected name = 'abac'; - /** - * Adds a new ABAC attribute definition entry for a given private room. - * - * @param rid Room ID - * @param attribute Attribute definition payload - * - */ async addAbacAttribute(attribute: IAbacAttributeDefinition): Promise { const keyPattern = /^[A-Za-z0-9_-]+$/; if (!keyPattern.test(attribute.key)) { @@ -34,9 +27,6 @@ export class AbacService extends ServiceClass implements IAbacService { } } - /** - * Lists ABAC attribute definitions with optional filtering and pagination. - */ async listAbacAttributes(filters?: { key?: string; values?: string[]; offset?: number; count?: number }): Promise<{ attributes: IAbacAttribute[]; offset: number; @@ -70,9 +60,6 @@ export class AbacService extends ServiceClass implements IAbacService { }; } - /** - * Updates an ABAC attribute definition by its _id. - */ async updateAbacAttributeById(_id: string, update: { key?: string; values?: string[] }): Promise { if (!update.key && !update.values) { return; @@ -128,9 +115,6 @@ export class AbacService extends ServiceClass implements IAbacService { } } - /** - * Deletes an ABAC attribute definition by its _id. - */ async deleteAbacAttributeById(_id: string): Promise { const existing = await AbacAttributes.findOne({ _id }, { projection: { key: 1, values: 1 } }); if (!existing) { @@ -174,12 +158,11 @@ export class AbacService extends ServiceClass implements IAbacService { return Rooms.isAbacAttributeInUse(key, attribute.values || []); } - /** - * Sets ABAC attributes for a private room. - * This method now delegates to smaller private helpers for readability and testability. - */ async setRoomAbacAttributes(rid: string, attributes: Record): Promise { - const room = await this.getPrivateRoomOrThrow(rid); + const room = await Rooms.findOneByIdAndType(rid, 'p', { projection: { abacAttributes: 1 } }); + if (!room) { + throw new Error('error-room-not-found'); + } const normalized = this.validateAndNormalizeAttributes(attributes); @@ -195,25 +178,14 @@ export class AbacService extends ServiceClass implements IAbacService { await Rooms.setAbacAttributesById(rid, normalized); } - /** - * Fetches a private room by id or throws if not found. - */ - private async getPrivateRoomOrThrow(rid: string): Promise<{ abacAttributes?: IAbacAttributeDefinition[] }> { - const room = await Rooms.findOneByIdAndType(rid, 'p', { projection: { abacAttributes: 1 } }); - if (!room) { - throw new Error('error-room-not-found'); - } - return room; - } - - /** - * Validates provided raw attributes object and normalizes it into a list - * of attribute definitions while deduplicating values. - */ private validateAndNormalizeAttributes(attributes: Record): IAbacAttributeDefinition[] { const keyPattern = /^[A-Za-z0-9_-]+$/; const normalized: IAbacAttributeDefinition[] = []; + if (Object.keys(attributes).length > 10) { + throw new Error('error-maximum-attributes-exceeded'); + } + for (const [key, values] of Object.entries(attributes)) { if (!keyPattern.test(key)) { throw new Error('error-invalid-attribute-key'); @@ -221,6 +193,10 @@ export class AbacService extends ServiceClass implements IAbacService { if (!values?.length) { throw new Error('error-invalid-attribute-values'); } + if (values.length > 10) { + throw new Error('error-maximum-attribute-values-exceeded'); + } + const uniqueValues = Array.from(new Set(values)); normalized.push({ key, values: uniqueValues }); } @@ -228,10 +204,6 @@ export class AbacService extends ServiceClass implements IAbacService { return normalized; } - /** - * Ensures all attribute definitions exist in the global registry and that every - * provided value is allowed for its corresponding key. - */ private async ensureAttributeDefinitionsExist(normalized: IAbacAttributeDefinition[]): Promise { if (!normalized.length) { return; @@ -259,10 +231,6 @@ export class AbacService extends ServiceClass implements IAbacService { } } - /** - * Compares previous vs new attributes and returns true if any attribute key - * or individual attribute value has been removed. - */ private computeAttributesRemoval(previous: IAbacAttributeDefinition[], next: IAbacAttributeDefinition[]): boolean { const newMap = new Map>(next.map((a) => [a.key, new Set(a.values)])); From 2b280f907088d0b707cb91a9cf6b5cb277fa3655 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Thu, 2 Oct 2025 15:17:13 -0600 Subject: [PATCH 027/125] update by key --- ee/packages/abac/src/index.ts | 56 ++++++++++++++++--- .../model-typings/src/models/IRoomsModel.ts | 2 + packages/models/src/models/Rooms.ts | 22 ++++++++ 3 files changed, 72 insertions(+), 8 deletions(-) diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index db5de6616a16b..f12363a477cd9 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -183,22 +183,17 @@ export class AbacService extends ServiceClass implements IAbacService { const normalized: IAbacAttributeDefinition[] = []; if (Object.keys(attributes).length > 10) { - throw new Error('error-maximum-attributes-exceeded'); + throw new Error('error-invalid-attribute-values'); } for (const [key, values] of Object.entries(attributes)) { if (!keyPattern.test(key)) { throw new Error('error-invalid-attribute-key'); } - if (!values?.length) { - throw new Error('error-invalid-attribute-values'); - } if (values.length > 10) { - throw new Error('error-maximum-attribute-values-exceeded'); + throw new Error('error-invalid-attribute-values'); } - - const uniqueValues = Array.from(new Set(values)); - normalized.push({ key, values: uniqueValues }); + normalized.push({ key, values }); } return normalized; @@ -249,6 +244,51 @@ export class AbacService extends ServiceClass implements IAbacService { return false; } + async updateRoomAbacAttributeValues(rid: string, key: string, values: string[]): Promise { + const keyPattern = /^[A-Za-z0-9_-]+$/; + if (!keyPattern.test(key)) { + throw new Error('error-invalid-attribute-key'); + } + if (values.length > 10) { + throw new Error('error-invalid-attribute-values'); + } + + const room = await Rooms.findOneByIdAndType(rid, 'p', { projection: { abacAttributes: 1 } }); + if (!room) { + throw new Error('error-room-not-found'); + } + const previous: IAbacAttributeDefinition[] = room.abacAttributes || []; + + const existingIndex = previous.findIndex((a) => a.key === key); + const isNewKey = existingIndex === -1; + if (isNewKey && previous.length >= 10) { + throw new Error('error-invalid-attribute-values'); + } + + await this.ensureAttributeDefinitionsExist([{ key, values }]); + + if (isNewKey) { + await Rooms.updateSingleAbacAttributeValuesById(rid, key, values); + return; + } + + const prevValues = previous[existingIndex].values; + + if (prevValues.length === values.length && prevValues.every((v, i) => v === values[i])) { + return; + } + + const valuesSet = new Set(values); + const removed = prevValues.some((v) => !valuesSet.has(v)); + + await Rooms.updateAbacAttributeValuesArrayFilteredById(rid, key, values); + + if (removed) { + const next = previous.map((a, i) => (i === existingIndex ? { key, values } : a)); + await this.onRoomAttributesChanged(rid, next); + } + } + protected async onRoomAttributesChanged(_rid: string, _newAttributes: IAbacAttributeDefinition[]): Promise { throw new Error('not implemented'); } diff --git a/packages/model-typings/src/models/IRoomsModel.ts b/packages/model-typings/src/models/IRoomsModel.ts index 1ce3ba74f6514..2783d1547f0e9 100644 --- a/packages/model-typings/src/models/IRoomsModel.ts +++ b/packages/model-typings/src/models/IRoomsModel.ts @@ -319,4 +319,6 @@ export interface IRoomsModel extends IBaseModel { countDistinctFederationRoomsExcluding(serverNames?: string[]): Promise; updateAbacConfigurationById(rid: IRoom['_id'], abac: boolean): Promise; setAbacAttributesById(rid: IRoom['_id'], attributes: NonNullable): Promise; + updateSingleAbacAttributeValuesById(rid: IRoom['_id'], key: string, values: string[]): Promise; + updateAbacAttributeValuesArrayFilteredById(rid: IRoom['_id'], key: string, values: string[]): Promise; } diff --git a/packages/models/src/models/Rooms.ts b/packages/models/src/models/Rooms.ts index 02441430bb925..35d41eac07abf 100644 --- a/packages/models/src/models/Rooms.ts +++ b/packages/models/src/models/Rooms.ts @@ -1978,6 +1978,28 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { return this.updateOne(query, update); } + updateSingleAbacAttributeValuesById(_id: IRoom['_id'], key: string, values: string[]): Promise { + const query: Filter = { _id, 'abacAttributes.key': key }; + + const update: UpdateFilter = { + $set: { + 'abacAttributes.$.values': values, + }, + }; + + return this.updateOne(query, update); + } + + updateAbacAttributeValuesArrayFilteredById(_id: IRoom['_id'], key: string, values: string[]): Promise { + const query: Filter = { _id }; + const update: UpdateFilter = { + $set: { + 'abacAttributes.$[attr].values': values, + }, + }; + return this.updateOne(query, update, { arrayFilters: [{ 'attr.key': key }] }); + } + updateGroupDMsRemovingUsernamesByUsername(username: string, userId: string): Promise { const query: Filter = { t: 'd', From 73f47075851649dbad34272ea680868ebd7aafab Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Thu, 2 Oct 2025 15:19:13 -0600 Subject: [PATCH 028/125] tests --- ee/packages/abac/src/index.spec.ts | 79 ++++++++++++++++++++++++++++-- 1 file changed, 75 insertions(+), 4 deletions(-) diff --git a/ee/packages/abac/src/index.spec.ts b/ee/packages/abac/src/index.spec.ts index a476a79d33e25..33cb98e99c6b8 100644 --- a/ee/packages/abac/src/index.spec.ts +++ b/ee/packages/abac/src/index.spec.ts @@ -10,6 +10,8 @@ const mockAbacDeleteOne = jest.fn(); const mockRoomsIsAbacAttributeInUse = jest.fn(); const mockSetAbacAttributesById = jest.fn(); const mockAbacFind = jest.fn(); +const mockUpdateSingleAbacAttributeValuesById = jest.fn(); +const mockUpdateAbacAttributeValuesArrayFilteredById = jest.fn(); jest.mock('@rocket.chat/models', () => ({ Rooms: { @@ -17,6 +19,8 @@ jest.mock('@rocket.chat/models', () => ({ updateAbacConfigurationById: (...args: any[]) => mockUpdateAbacConfigurationById(...args), isAbacAttributeInUse: (...args: any[]) => mockRoomsIsAbacAttributeInUse(...args), setAbacAttributesById: (...args: any[]) => mockSetAbacAttributesById(...args), + updateSingleAbacAttributeValuesById: (...args: any[]) => mockUpdateSingleAbacAttributeValuesById(...args), + updateAbacAttributeValuesArrayFilteredById: (...args: any[]) => mockUpdateAbacAttributeValuesArrayFilteredById(...args), }, AbacAttributes: { insertOne: (...args: any[]) => mockAbacInsertOne(...args), @@ -331,9 +335,9 @@ describe('AbacService (unit)', () => { expect(mockSetAbacAttributesById).not.toHaveBeenCalled(); }); - it('throws error-invalid-attribute-values for empty value array', async () => { + it('throws error-attribute-definition-not-found for empty value array', async () => { mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [] }); - await expect(service.setRoomAbacAttributes('r1', { dept: [] as any })).rejects.toThrow('error-invalid-attribute-values'); + await expect(service.setRoomAbacAttributes('r1', { dept: [] as any })).rejects.toThrow('error-attribute-definition-not-found'); expect(mockSetAbacAttributesById).not.toHaveBeenCalled(); }); @@ -354,7 +358,7 @@ describe('AbacService (unit)', () => { expect(mockSetAbacAttributesById).not.toHaveBeenCalled(); }); - it('normalizes (deduplicates) values and sets attributes', async () => { + it('accepts duplicate values unchanged and sets attributes', async () => { mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [] }); mockAbacFind.mockReturnValueOnce({ toArray: async () => [{ key: 'dept', values: ['eng', 'sales'] }], @@ -362,7 +366,7 @@ describe('AbacService (unit)', () => { await service.setRoomAbacAttributes('r1', { dept: ['eng', 'eng', 'sales'] }); - expect(mockSetAbacAttributesById).toHaveBeenCalledWith('r1', [{ key: 'dept', values: ['eng', 'sales'] }]); + expect(mockSetAbacAttributesById).toHaveBeenCalledWith('r1', [{ key: 'dept', values: ['eng', 'eng', 'sales'] }]); expect((service as any).onRoomAttributesChanged).not.toHaveBeenCalled(); }); @@ -426,4 +430,71 @@ describe('AbacService (unit)', () => { expect(mockRoomsIsAbacAttributeInUse).toHaveBeenCalledWith('Attr3', ['m', 'n']); }); }); + + describe('updateRoomAbacAttributeValues', () => { + beforeEach(() => { + mockFindOneByIdAndType.mockReset(); + mockUpdateSingleAbacAttributeValuesById.mockReset(); + mockUpdateAbacAttributeValuesArrayFilteredById.mockReset(); + mockAbacFind.mockReset(); + (service as any).onRoomAttributesChanged = jest.fn().mockResolvedValue(undefined); + // default definition cursor + mockAbacFind.mockReturnValue({ toArray: async () => [{ key: 'dept', values: ['eng', 'sales', 'hr'] }] }); + }); + + it('throws error-invalid-attribute-key for invalid key', async () => { + await expect(service.updateRoomAbacAttributeValues('r1', 'bad key', ['v'])).rejects.toThrow('error-invalid-attribute-key'); + expect(mockUpdateSingleAbacAttributeValuesById).not.toHaveBeenCalled(); + }); + + it('throws error-invalid-attribute-values when more than 10 values provided', async () => { + const values = Array.from({ length: 11 }, (_, i) => `v${i}`); + await expect(service.updateRoomAbacAttributeValues('r1', 'dept', values)).rejects.toThrow('error-invalid-attribute-values'); + }); + + it('throws error-room-not-found if room missing', async () => { + mockFindOneByIdAndType.mockResolvedValueOnce(null); + await expect(service.updateRoomAbacAttributeValues('missing', 'dept', ['eng'])).rejects.toThrow('error-room-not-found'); + }); + + it('throws error-invalid-attribute-values if adding new key exceeds max attributes', async () => { + const existing = Array.from({ length: 10 }, (_, i) => ({ key: `k${i}`, values: ['x'] })); + mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: existing }); + await expect(service.updateRoomAbacAttributeValues('r1', 'newKey', ['val'])).rejects.toThrow('error-invalid-attribute-values'); + }); + + it('adds new key using updateSingleAbacAttributeValuesById when within limit', async () => { + mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [{ key: 'other', values: ['x'] }] }); + await service.updateRoomAbacAttributeValues('r1', 'dept', ['eng']); + expect(mockUpdateSingleAbacAttributeValuesById).toHaveBeenCalledWith('r1', 'dept', ['eng']); + expect(mockUpdateAbacAttributeValuesArrayFilteredById).not.toHaveBeenCalled(); + }); + + it('does nothing when values array is identical (no update, no hook)', async () => { + mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [{ key: 'dept', values: ['eng', 'sales'] }] }); + await service.updateRoomAbacAttributeValues('r1', 'dept', ['eng', 'sales']); + expect(mockUpdateAbacAttributeValuesArrayFilteredById).not.toHaveBeenCalled(); + expect((service as any).onRoomAttributesChanged).not.toHaveBeenCalled(); + }); + + it('updates existing key (addition only) without triggering removal hook', async () => { + mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [{ key: 'dept', values: ['eng'] }] }); + await service.updateRoomAbacAttributeValues('r1', 'dept', ['eng', 'sales']); + expect(mockUpdateAbacAttributeValuesArrayFilteredById).toHaveBeenCalledWith('r1', 'dept', ['eng', 'sales']); + expect((service as any).onRoomAttributesChanged).not.toHaveBeenCalled(); + }); + + it('updates existing key and triggers hook when a value is removed', async () => { + mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [{ key: 'dept', values: ['eng', 'sales'] }] }); + await service.updateRoomAbacAttributeValues('r1', 'dept', ['eng']); + expect(mockUpdateAbacAttributeValuesArrayFilteredById).toHaveBeenCalledWith('r1', 'dept', ['eng']); + expect((service as any).onRoomAttributesChanged).toHaveBeenCalledWith('r1', [{ key: 'dept', values: ['eng'] }]); + }); + + it('validates against global definitions (invalid value)', async () => { + mockAbacFind.mockReturnValueOnce({ toArray: async () => [{ key: 'dept', values: ['eng'] }] }); + mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [] }); + await expect(service.updateRoomAbacAttributeValues('r1', 'dept', ['eng', 'sales'])).rejects.toThrow('error-invalid-attribute-values'); + }); + }); }); From bf29c6107df48b17360cd9b32818e2ba3fda3c47 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Fri, 3 Oct 2025 08:23:16 -0600 Subject: [PATCH 029/125] remove abac attr --- apps/meteor/ee/server/api/abac/index.ts | 10 ++++-- ee/packages/abac/src/index.spec.ts | 34 +++++++++++++++++++ ee/packages/abac/src/index.ts | 19 +++++++++++ .../core-services/src/types/IAbacService.ts | 1 + .../model-typings/src/models/IRoomsModel.ts | 1 + packages/models/src/models/Rooms.ts | 10 ++++++ 6 files changed, 72 insertions(+), 3 deletions(-) diff --git a/apps/meteor/ee/server/api/abac/index.ts b/apps/meteor/ee/server/api/abac/index.ts index 525037e9a6f6b..1db05f7689e5d 100644 --- a/apps/meteor/ee/server/api/abac/index.ts +++ b/apps/meteor/ee/server/api/abac/index.ts @@ -38,19 +38,23 @@ const abacEndpoints = API.v1 // edit a room attribute .put( 'abac/room/:rid/attributes/:key', - { authRequired: true, permissionsRequired: ['abac-management'], response: {}, license: ['abac'] }, + { authRequired: true, permissionsRequired: ['abac-management'], response: { 200: GenericSuccessSchema }, license: ['abac'] }, async function action() { if (!settings.get('ABAC_Enabled')) { throw new Error('error-abac-not-enabled'); } + return API.v1.success(); }, ) // delete a room attribute .delete( 'abac/room/:rid/attributes/:key', - { authRequired: true, permissionsRequired: ['abac-management'], response: {} }, + { authRequired: true, permissionsRequired: ['abac-management'], response: { 200: GenericSuccessSchema } }, async function action() { - throw new Error('not-implemented'); + const { rid, key } = this.urlParams; + + await Abac.removeRoomAbacAttribute(rid, key); + return API.v1.success(); }, ) // attribute endpoints diff --git a/ee/packages/abac/src/index.spec.ts b/ee/packages/abac/src/index.spec.ts index 33cb98e99c6b8..b702d9690cef4 100644 --- a/ee/packages/abac/src/index.spec.ts +++ b/ee/packages/abac/src/index.spec.ts @@ -12,6 +12,7 @@ const mockSetAbacAttributesById = jest.fn(); const mockAbacFind = jest.fn(); const mockUpdateSingleAbacAttributeValuesById = jest.fn(); const mockUpdateAbacAttributeValuesArrayFilteredById = jest.fn(); +const mockRemoveAbacAttributeByRoomIdAndKey = jest.fn(); jest.mock('@rocket.chat/models', () => ({ Rooms: { @@ -21,6 +22,7 @@ jest.mock('@rocket.chat/models', () => ({ setAbacAttributesById: (...args: any[]) => mockSetAbacAttributesById(...args), updateSingleAbacAttributeValuesById: (...args: any[]) => mockUpdateSingleAbacAttributeValuesById(...args), updateAbacAttributeValuesArrayFilteredById: (...args: any[]) => mockUpdateAbacAttributeValuesArrayFilteredById(...args), + removeAbacAttributeByRoomIdAndKey: (...args: any[]) => mockRemoveAbacAttributeByRoomIdAndKey(...args), }, AbacAttributes: { insertOne: (...args: any[]) => mockAbacInsertOne(...args), @@ -497,4 +499,36 @@ describe('AbacService (unit)', () => { await expect(service.updateRoomAbacAttributeValues('r1', 'dept', ['eng', 'sales'])).rejects.toThrow('error-invalid-attribute-values'); }); }); + + describe('removeRoomAbacAttribute', () => { + beforeEach(() => { + mockFindOneByIdAndType.mockReset(); + mockRemoveAbacAttributeByRoomIdAndKey.mockReset(); + (service as any).onRoomAttributesChanged = jest.fn().mockResolvedValue(undefined); + }); + + it('throws error-room-not-found when room does not exist', async () => { + mockFindOneByIdAndType.mockResolvedValueOnce(null); + await expect((service as any).removeRoomAbacAttribute('missing', 'dept')).rejects.toThrow('error-room-not-found'); + expect(mockRemoveAbacAttributeByRoomIdAndKey).not.toHaveBeenCalled(); + }); + + it('returns early (no update, no hook) when attribute key not present', async () => { + mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [{ key: 'other', values: ['x'] }] }); + await (service as any).removeRoomAbacAttribute('r1', 'dept'); + expect(mockRemoveAbacAttributeByRoomIdAndKey).not.toHaveBeenCalled(); + expect((service as any).onRoomAttributesChanged).not.toHaveBeenCalled(); + }); + + it('removes attribute and calls hook when key exists', async () => { + const existing = [ + { key: 'dept', values: ['eng', 'sales'] }, + { key: 'other', values: ['x'] }, + ]; + mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: existing }); + await (service as any).removeRoomAbacAttribute('r1', 'dept'); + expect(mockRemoveAbacAttributeByRoomIdAndKey).toHaveBeenCalledWith('r1', 'dept'); + expect((service as any).onRoomAttributesChanged).toHaveBeenCalledWith('r1', [{ key: 'other', values: ['x'] }]); + }); + }); }); diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index f12363a477cd9..723dd081de314 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -289,6 +289,25 @@ export class AbacService extends ServiceClass implements IAbacService { } } + async removeRoomAbacAttribute(rid: string, key: string): Promise { + const room = await Rooms.findOneByIdAndType(rid, 'p', { projection: { abacAttributes: 1 } }); + if (!room) { + throw new Error('error-room-not-found'); + } + + const previous: IAbacAttributeDefinition[] = room.abacAttributes || []; + const exists = previous.some((a) => a.key === key); + if (!exists) { + return; + } + + const next = previous.filter((a) => a.key !== key); + + await Rooms.removeAbacAttributeByRoomIdAndKey(rid, key); + + await this.onRoomAttributesChanged(rid, next); + } + protected async onRoomAttributesChanged(_rid: string, _newAttributes: IAbacAttributeDefinition[]): Promise { throw new Error('not implemented'); } diff --git a/packages/core-services/src/types/IAbacService.ts b/packages/core-services/src/types/IAbacService.ts index 08ebb0d1ecb89..aaea93b8c632d 100644 --- a/packages/core-services/src/types/IAbacService.ts +++ b/packages/core-services/src/types/IAbacService.ts @@ -14,4 +14,5 @@ export interface IAbacService { getAbacAttributeById(_id: string): Promise<{ key: string; values: string[]; usage: Record }>; isAbacAttributeInUseByKey(key: string): Promise; setRoomAbacAttributes(rid: string, attributes: Record): Promise; + removeRoomAbacAttribute(rid: string, key: string): Promise; } diff --git a/packages/model-typings/src/models/IRoomsModel.ts b/packages/model-typings/src/models/IRoomsModel.ts index 2783d1547f0e9..34f06704f4dd5 100644 --- a/packages/model-typings/src/models/IRoomsModel.ts +++ b/packages/model-typings/src/models/IRoomsModel.ts @@ -321,4 +321,5 @@ export interface IRoomsModel extends IBaseModel { setAbacAttributesById(rid: IRoom['_id'], attributes: NonNullable): Promise; updateSingleAbacAttributeValuesById(rid: IRoom['_id'], key: string, values: string[]): Promise; updateAbacAttributeValuesArrayFilteredById(rid: IRoom['_id'], key: string, values: string[]): Promise; + removeAbacAttributeByRoomIdAndKey(rid: IRoom['_id'], key: string): Promise; } diff --git a/packages/models/src/models/Rooms.ts b/packages/models/src/models/Rooms.ts index 35d41eac07abf..807b8c4eff3da 100644 --- a/packages/models/src/models/Rooms.ts +++ b/packages/models/src/models/Rooms.ts @@ -2000,6 +2000,16 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { return this.updateOne(query, update, { arrayFilters: [{ 'attr.key': key }] }); } + removeAbacAttributeByRoomIdAndKey(_id: IRoom['_id'], key: string): Promise { + const query: Filter = { _id }; + const update: UpdateFilter = { + $pull: { + abacAttributes: { key }, + }, + }; + return this.updateOne(query, update); + } + updateGroupDMsRemovingUsernamesByUsername(username: string, userId: string): Promise { const query: Filter = { t: 'd', From 5b4f6863a299232945fb33187515778850323744 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Fri, 3 Oct 2025 12:19:24 -0600 Subject: [PATCH 030/125] endpoints --- apps/meteor/ee/server/api/abac/index.ts | 14 +++- apps/meteor/ee/server/api/abac/schemas.ts | 17 ++++ ee/packages/abac/src/index.spec.ts | 81 +++++++++++++++++++ ee/packages/abac/src/index.ts | 39 ++++++++- .../core-services/src/types/IAbacService.ts | 1 + .../model-typings/src/models/IRoomsModel.ts | 5 +- packages/models/src/models/Rooms.ts | 34 ++++---- 7 files changed, 167 insertions(+), 24 deletions(-) diff --git a/apps/meteor/ee/server/api/abac/index.ts b/apps/meteor/ee/server/api/abac/index.ts index 1db05f7689e5d..0599832a70341 100644 --- a/apps/meteor/ee/server/api/abac/index.ts +++ b/apps/meteor/ee/server/api/abac/index.ts @@ -9,6 +9,7 @@ import { POSTAbacAttributeDefinitionSchema, GETAbacAttributeIsInUseResponseSchema, POSTRoomAbacAttributesBodySchema, + PUTRoomAbacAttributeValuesBodySchema, } from './schemas'; import { API } from '../../../../app/api/server'; import type { ExtractRoutesFromAPI } from '../../../../app/api/server/ApiClass'; @@ -38,11 +39,22 @@ const abacEndpoints = API.v1 // edit a room attribute .put( 'abac/room/:rid/attributes/:key', - { authRequired: true, permissionsRequired: ['abac-management'], response: { 200: GenericSuccessSchema }, license: ['abac'] }, + { + authRequired: true, + permissionsRequired: ['abac-management'], + body: PUTRoomAbacAttributeValuesBodySchema, + response: { 200: GenericSuccessSchema }, + license: ['abac'], + }, async function action() { + const { rid, key } = this.urlParams; + const { values } = this.bodyParams; + if (!settings.get('ABAC_Enabled')) { throw new Error('error-abac-not-enabled'); } + + await Abac.replaceRoomAbacAttributeByKey(rid, key, values); return API.v1.success(); }, ) diff --git a/apps/meteor/ee/server/api/abac/schemas.ts b/apps/meteor/ee/server/api/abac/schemas.ts index df39e341db788..a5a7e1e4eaf3d 100644 --- a/apps/meteor/ee/server/api/abac/schemas.ts +++ b/apps/meteor/ee/server/api/abac/schemas.ts @@ -173,3 +173,20 @@ const PostRoomAbacAttributesBody = { }; export const POSTRoomAbacAttributesBodySchema = ajv.compile<{ attributes: Record }>(PostRoomAbacAttributesBody); + +const PutRoomAbacAttributeValuesBody = { + type: 'object', + properties: { + values: { + type: 'array', + items: { type: 'string', minLength: 1 }, + minItems: 1, + maxItems: 10, + uniqueItems: true, + }, + }, + required: ['values'], + additionalProperties: false, +}; + +export const PUTRoomAbacAttributeValuesBodySchema = ajv.compile<{ values: string[] }>(PutRoomAbacAttributeValuesBody); diff --git a/ee/packages/abac/src/index.spec.ts b/ee/packages/abac/src/index.spec.ts index b702d9690cef4..2ba1300b2de57 100644 --- a/ee/packages/abac/src/index.spec.ts +++ b/ee/packages/abac/src/index.spec.ts @@ -13,6 +13,7 @@ const mockAbacFind = jest.fn(); const mockUpdateSingleAbacAttributeValuesById = jest.fn(); const mockUpdateAbacAttributeValuesArrayFilteredById = jest.fn(); const mockRemoveAbacAttributeByRoomIdAndKey = jest.fn(); +const mockInsertAbacAttributeIfNotExistsById = jest.fn(); jest.mock('@rocket.chat/models', () => ({ Rooms: { @@ -23,6 +24,7 @@ jest.mock('@rocket.chat/models', () => ({ updateSingleAbacAttributeValuesById: (...args: any[]) => mockUpdateSingleAbacAttributeValuesById(...args), updateAbacAttributeValuesArrayFilteredById: (...args: any[]) => mockUpdateAbacAttributeValuesArrayFilteredById(...args), removeAbacAttributeByRoomIdAndKey: (...args: any[]) => mockRemoveAbacAttributeByRoomIdAndKey(...args), + insertAbacAttributeIfNotExistsById: (...args: any[]) => mockInsertAbacAttributeIfNotExistsById(...args), }, AbacAttributes: { insertOne: (...args: any[]) => mockAbacInsertOne(...args), @@ -530,5 +532,84 @@ describe('AbacService (unit)', () => { expect(mockRemoveAbacAttributeByRoomIdAndKey).toHaveBeenCalledWith('r1', 'dept'); expect((service as any).onRoomAttributesChanged).toHaveBeenCalledWith('r1', [{ key: 'other', values: ['x'] }]); }); + + describe('replaceRoomAbacAttributeByKey', () => { + beforeEach(() => { + mockFindOneByIdAndType.mockReset(); + mockUpdateAbacAttributeValuesArrayFilteredById.mockReset(); + mockInsertAbacAttributeIfNotExistsById.mockReset(); + mockAbacFind.mockReset(); + (service as any).onRoomAttributesChanged = jest.fn().mockResolvedValue(undefined); + // default attribute definitions + mockAbacFind.mockReturnValue({ toArray: async () => [{ key: 'dept', values: ['eng', 'sales', 'hr'] }] }); + }); + + it('throws error-invalid-attribute-key for invalid key', async () => { + await expect((service as any).replaceRoomAbacAttributeByKey('r1', 'bad key', ['eng'])).rejects.toThrow( + 'error-invalid-attribute-key', + ); + expect(mockInsertAbacAttributeIfNotExistsById).not.toHaveBeenCalled(); + expect(mockUpdateAbacAttributeValuesArrayFilteredById).not.toHaveBeenCalled(); + }); + + it('throws error-invalid-attribute-values when more than 10 values provided', async () => { + const values = Array.from({ length: 11 }, (_, i) => `v${i}`); + await expect((service as any).replaceRoomAbacAttributeByKey('r1', 'dept', values)).rejects.toThrow( + 'error-invalid-attribute-values', + ); + }); + + it('throws error-room-not-found if room missing', async () => { + mockFindOneByIdAndType.mockResolvedValueOnce(null); + await expect((service as any).replaceRoomAbacAttributeByKey('missing', 'dept', ['eng'])).rejects.toThrow('error-room-not-found'); + }); + + it('throws error-invalid-attribute-values if adding new key exceeds max attributes', async () => { + const existing = Array.from({ length: 10 }, (_, i) => ({ key: `k${i}`, values: ['x'] })); + mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: existing }); + await expect((service as any).replaceRoomAbacAttributeByKey('r1', 'dept', ['eng'])).rejects.toThrow( + 'error-invalid-attribute-values', + ); + expect(mockInsertAbacAttributeIfNotExistsById).not.toHaveBeenCalled(); + }); + + it('adds new key when under limit (calls insert and hook)', async () => { + const existing = [{ key: 'other', values: ['x'] }]; + mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: existing }); + const updatedDoc = { abacAttributes: [...existing, { key: 'dept', values: ['eng'] }] }; + mockInsertAbacAttributeIfNotExistsById.mockResolvedValueOnce(updatedDoc); + + await (service as any).replaceRoomAbacAttributeByKey('r1', 'dept', ['eng']); + + expect(mockInsertAbacAttributeIfNotExistsById).toHaveBeenCalledWith('r1', 'dept', ['eng']); + expect(mockUpdateAbacAttributeValuesArrayFilteredById).not.toHaveBeenCalled(); + expect((service as any).onRoomAttributesChanged).toHaveBeenCalledWith('r1', updatedDoc.abacAttributes); + }); + + it('replaces existing key (calls update and hook)', async () => { + const existing = [{ key: 'dept', values: ['eng'] }]; + const updatedDoc = { abacAttributes: [{ key: 'dept', values: ['eng', 'sales'] }] }; + mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: existing }); + mockUpdateAbacAttributeValuesArrayFilteredById.mockResolvedValueOnce(updatedDoc); + + await (service as any).replaceRoomAbacAttributeByKey('r1', 'dept', ['eng', 'sales']); + + expect(mockUpdateAbacAttributeValuesArrayFilteredById).toHaveBeenCalledWith('r1', 'dept', ['eng', 'sales']); + expect(mockInsertAbacAttributeIfNotExistsById).not.toHaveBeenCalled(); + expect((service as any).onRoomAttributesChanged).toHaveBeenCalledWith('r1', updatedDoc.abacAttributes); + }); + + it('validates definitions and rejects invalid value', async () => { + // Only 'eng' allowed for dept + mockAbacFind.mockReturnValueOnce({ toArray: async () => [{ key: 'dept', values: ['eng'] }] }); + mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [] }); + + await expect((service as any).replaceRoomAbacAttributeByKey('r1', 'dept', ['eng', 'sales'])).rejects.toThrow( + 'error-invalid-attribute-values', + ); + expect(mockInsertAbacAttributeIfNotExistsById).not.toHaveBeenCalled(); + expect(mockUpdateAbacAttributeValuesArrayFilteredById).not.toHaveBeenCalled(); + }); + }); }); }); diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index 723dd081de314..f589edc0b6edd 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -171,11 +171,11 @@ export class AbacService extends ServiceClass implements IAbacService { const previous: IAbacAttributeDefinition[] = room.abacAttributes || []; const removed = this.computeAttributesRemoval(previous, normalized); + const updated = await Rooms.setAbacAttributesById(rid, normalized); + if (removed) { - await this.onRoomAttributesChanged(rid, normalized); + await this.onRoomAttributesChanged(rid, (updated?.abacAttributes as IAbacAttributeDefinition[] | undefined) ?? normalized); } - - await Rooms.setAbacAttributesById(rid, normalized); } private validateAndNormalizeAttributes(attributes: Record): IAbacAttributeDefinition[] { @@ -308,6 +308,39 @@ export class AbacService extends ServiceClass implements IAbacService { await this.onRoomAttributesChanged(rid, next); } + async replaceRoomAbacAttributeByKey(rid: string, key: string, values: string[]): Promise { + const keyPattern = /^[A-Za-z0-9_-]+$/; + if (!keyPattern.test(key)) { + throw new Error('error-invalid-attribute-key'); + } + if (values.length > 10) { + throw new Error('error-invalid-attribute-values'); + } + + await this.ensureAttributeDefinitionsExist([{ key, values }]); + + const room = await Rooms.findOneByIdAndType(rid, 'p', { projection: { abacAttributes: 1 } }); + if (!room) { + throw new Error('error-room-not-found'); + } + + const exists = room?.abacAttributes?.some((a) => a.key === key); + + if (exists) { + const updated = await Rooms.updateAbacAttributeValuesArrayFilteredById(rid, key, values); + + await this.onRoomAttributesChanged(rid, updated?.abacAttributes || []); + return; + } + + if (room?.abacAttributes?.length === 10) { + throw new Error('error-invalid-attribute-values'); + } + + const updated = await Rooms.insertAbacAttributeIfNotExistsById(rid, key, values); + await this.onRoomAttributesChanged(rid, updated?.abacAttributes || []); + } + protected async onRoomAttributesChanged(_rid: string, _newAttributes: IAbacAttributeDefinition[]): Promise { throw new Error('not implemented'); } diff --git a/packages/core-services/src/types/IAbacService.ts b/packages/core-services/src/types/IAbacService.ts index aaea93b8c632d..3df9bb145e211 100644 --- a/packages/core-services/src/types/IAbacService.ts +++ b/packages/core-services/src/types/IAbacService.ts @@ -15,4 +15,5 @@ export interface IAbacService { isAbacAttributeInUseByKey(key: string): Promise; setRoomAbacAttributes(rid: string, attributes: Record): Promise; removeRoomAbacAttribute(rid: string, key: string): Promise; + replaceRoomAbacAttributeByKey(rid: string, key: string, values: string[]): Promise; } diff --git a/packages/model-typings/src/models/IRoomsModel.ts b/packages/model-typings/src/models/IRoomsModel.ts index 34f06704f4dd5..d25d38cf0b762 100644 --- a/packages/model-typings/src/models/IRoomsModel.ts +++ b/packages/model-typings/src/models/IRoomsModel.ts @@ -318,8 +318,9 @@ export interface IRoomsModel extends IBaseModel { hasCreatedRolePrioritiesForRoom(rid: IRoom['_id'], syncVersion: number): Promise; countDistinctFederationRoomsExcluding(serverNames?: string[]): Promise; updateAbacConfigurationById(rid: IRoom['_id'], abac: boolean): Promise; - setAbacAttributesById(rid: IRoom['_id'], attributes: NonNullable): Promise; + setAbacAttributesById(rid: IRoom['_id'], attributes: NonNullable): Promise; updateSingleAbacAttributeValuesById(rid: IRoom['_id'], key: string, values: string[]): Promise; - updateAbacAttributeValuesArrayFilteredById(rid: IRoom['_id'], key: string, values: string[]): Promise; + insertAbacAttributeIfNotExistsById(rid: IRoom['_id'], key: string, values: string[]): Promise; + updateAbacAttributeValuesArrayFilteredById(rid: IRoom['_id'], key: string, values: string[]): Promise; removeAbacAttributeByRoomIdAndKey(rid: IRoom['_id'], key: string): Promise; } diff --git a/packages/models/src/models/Rooms.ts b/packages/models/src/models/Rooms.ts index 807b8c4eff3da..6f71ef087276a 100644 --- a/packages/models/src/models/Rooms.ts +++ b/packages/models/src/models/Rooms.ts @@ -1966,16 +1966,8 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { return this.updateOne(query, update); } - setAbacAttributesById(_id: IRoom['_id'], attributes: NonNullable): Promise { - const query: Filter = { _id }; - - const update: UpdateFilter = { - $set: { - abacAttributes: attributes, - }, - }; - - return this.updateOne(query, update); + setAbacAttributesById(_id: IRoom['_id'], attributes: NonNullable): Promise { + return this.findOneAndUpdate({ _id }, { $set: { abacAttributes: attributes } }, { returnDocument: 'after' }); } updateSingleAbacAttributeValuesById(_id: IRoom['_id'], key: string, values: string[]): Promise { @@ -1990,14 +1982,20 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { return this.updateOne(query, update); } - updateAbacAttributeValuesArrayFilteredById(_id: IRoom['_id'], key: string, values: string[]): Promise { - const query: Filter = { _id }; - const update: UpdateFilter = { - $set: { - 'abacAttributes.$[attr].values': values, - }, - }; - return this.updateOne(query, update, { arrayFilters: [{ 'attr.key': key }] }); + insertAbacAttributeIfNotExistsById(_id: IRoom['_id'], key: string, values: string[]): Promise { + return this.findOneAndUpdate( + { _id, 'abacAttributes.key': { $ne: key } }, + { $push: { abacAttributes: { key, values } } }, + { returnDocument: 'after', projection: { abacAttributes: 1 } }, + ) as unknown as Promise; + } + + updateAbacAttributeValuesArrayFilteredById(_id: IRoom['_id'], key: string, values: string[]): Promise { + return this.findOneAndUpdate( + { _id }, + { $set: { 'abacAttributes.$[attr].values': values } }, + { arrayFilters: [{ 'attr.key': key }], returnDocument: 'after' }, + ); } removeAbacAttributeByRoomIdAndKey(_id: IRoom['_id'], key: string): Promise { From b919fa73ebaeb67eb17ab7269ea28166587cb6c1 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Fri, 3 Oct 2025 12:46:39 -0600 Subject: [PATCH 031/125] minor changes to index --- apps/meteor/ee/server/api/abac/index.ts | 8 +++++++- packages/models/src/models/Rooms.ts | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/meteor/ee/server/api/abac/index.ts b/apps/meteor/ee/server/api/abac/index.ts index 0599832a70341..b18f7afec96a0 100644 --- a/apps/meteor/ee/server/api/abac/index.ts +++ b/apps/meteor/ee/server/api/abac/index.ts @@ -1,4 +1,5 @@ import { Abac } from '@rocket.chat/core-services'; +import { Settings } from '@rocket.chat/models'; import { GenericSuccessSchema, @@ -28,7 +29,12 @@ const abacEndpoints = API.v1 const { rid } = this.urlParams; const { attributes } = this.bodyParams; - if (!settings.get('ABAC_Enabled') && Object.keys(attributes).length) { + // This endpoint could be called without a license + // If we use settings.get, it will return false because it's the "invalid value" + // So we get the real value from the setting + // But we only need to check the setting if the user is trying to set attributes + // If it's trying to remove attributes by setting an empty object, then we allow it + if (Object.keys(attributes).length && !(await Settings.getValueById('ABAC_Enabled'))) { throw new Error('error-abac-not-enabled'); } diff --git a/packages/models/src/models/Rooms.ts b/packages/models/src/models/Rooms.ts index 6f71ef087276a..4e80c32e41ec9 100644 --- a/packages/models/src/models/Rooms.ts +++ b/packages/models/src/models/Rooms.ts @@ -112,7 +112,7 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { }, { key: { 'attributes.key': 1, 'attributes.values': 1 }, - partialFilterExpression: { attributes: { $exists: true } }, + partialFilterExpression: { attributes: { $exists: true, $ne: [] } }, }, ]; } From 1aa331b33edc0ca89a59f7904cb228c9b0474ce6 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Fri, 3 Oct 2025 14:45:45 -0600 Subject: [PATCH 032/125] smol issues with request validators --- apps/meteor/ee/server/api/abac/index.ts | 24 ++++++++++++------- apps/meteor/ee/server/api/abac/schemas.ts | 29 +++++++++++++++++------ 2 files changed, 37 insertions(+), 16 deletions(-) diff --git a/apps/meteor/ee/server/api/abac/index.ts b/apps/meteor/ee/server/api/abac/index.ts index b18f7afec96a0..de76dffb361ba 100644 --- a/apps/meteor/ee/server/api/abac/index.ts +++ b/apps/meteor/ee/server/api/abac/index.ts @@ -1,5 +1,6 @@ import { Abac } from '@rocket.chat/core-services'; import { Settings } from '@rocket.chat/models'; +import { validateUnauthorizedErrorResponse } from '@rocket.chat/rest-typings/src/v1/Ajv'; import { GenericSuccessSchema, @@ -11,6 +12,7 @@ import { GETAbacAttributeIsInUseResponseSchema, POSTRoomAbacAttributesBodySchema, PUTRoomAbacAttributeValuesBodySchema, + GenericErrorSchema, } from './schemas'; import { API } from '../../../../app/api/server'; import type { ExtractRoutesFromAPI } from '../../../../app/api/server/ApiClass'; @@ -23,7 +25,7 @@ const abacEndpoints = API.v1 authRequired: true, permissionsRequired: ['abac-management'], body: POSTRoomAbacAttributesBodySchema, - response: { 200: GenericSuccessSchema }, + response: { 200: GenericSuccessSchema, 401: validateUnauthorizedErrorResponse, 400: GenericErrorSchema }, }, async function action() { const { rid } = this.urlParams; @@ -49,7 +51,7 @@ const abacEndpoints = API.v1 authRequired: true, permissionsRequired: ['abac-management'], body: PUTRoomAbacAttributeValuesBodySchema, - response: { 200: GenericSuccessSchema }, + response: { 200: GenericSuccessSchema, 401: validateUnauthorizedErrorResponse, 400: GenericErrorSchema }, license: ['abac'], }, async function action() { @@ -67,7 +69,11 @@ const abacEndpoints = API.v1 // delete a room attribute .delete( 'abac/room/:rid/attributes/:key', - { authRequired: true, permissionsRequired: ['abac-management'], response: { 200: GenericSuccessSchema } }, + { + authRequired: true, + permissionsRequired: ['abac-management'], + response: { 200: GenericSuccessSchema, 401: validateUnauthorizedErrorResponse, 400: GenericErrorSchema }, + }, async function action() { const { rid, key } = this.urlParams; @@ -83,7 +89,7 @@ const abacEndpoints = API.v1 authRequired: true, permissionsRequired: ['abac-management'], query: GETAbacAttributesQuerySchema, - response: { 200: GETAbacAttributesResponseSchema }, + response: { 200: GETAbacAttributesResponseSchema, 401: validateUnauthorizedErrorResponse, 400: GenericErrorSchema }, license: ['abac'], }, async function action() { @@ -111,7 +117,7 @@ const abacEndpoints = API.v1 permissionsRequired: ['abac-management'], license: ['abac'], body: POSTAbacAttributeDefinitionSchema, - response: { 200: GenericSuccessSchema }, + response: { 200: GenericSuccessSchema, 401: validateUnauthorizedErrorResponse, 400: GenericErrorSchema }, }, async function action() { if (!settings.get('ABAC_Enabled')) { @@ -130,7 +136,7 @@ const abacEndpoints = API.v1 permissionsRequired: ['abac-management'], license: ['abac'], body: PUTAbacAttributeUpdateBodySchema, - response: { 200: GenericSuccessSchema }, + response: { 200: GenericSuccessSchema, 401: validateUnauthorizedErrorResponse, 400: GenericErrorSchema }, }, async function action() { const { _id } = this.urlParams; @@ -148,7 +154,7 @@ const abacEndpoints = API.v1 { authRequired: true, permissionsRequired: ['abac-management'], - response: { 200: GETAbacAttributeByIdResponseSchema }, + response: { 200: GETAbacAttributeByIdResponseSchema, 401: validateUnauthorizedErrorResponse, 400: GenericErrorSchema }, license: ['abac'], }, async function action() { @@ -166,7 +172,7 @@ const abacEndpoints = API.v1 { authRequired: true, permissionsRequired: ['abac-management'], - response: { 200: GenericSuccessSchema }, + response: { 200: GenericSuccessSchema, 401: validateUnauthorizedErrorResponse, 400: GenericErrorSchema }, license: ['abac'], }, async function action() { @@ -184,7 +190,7 @@ const abacEndpoints = API.v1 { authRequired: true, permissionsRequired: ['abac-management'], - response: { 200: GETAbacAttributeIsInUseResponseSchema }, + response: { 200: GETAbacAttributeIsInUseResponseSchema, 401: validateUnauthorizedErrorResponse, 400: GenericErrorSchema }, license: ['abac'], }, async function action() { diff --git a/apps/meteor/ee/server/api/abac/schemas.ts b/apps/meteor/ee/server/api/abac/schemas.ts index a5a7e1e4eaf3d..d4920176d1e07 100644 --- a/apps/meteor/ee/server/api/abac/schemas.ts +++ b/apps/meteor/ee/server/api/abac/schemas.ts @@ -19,6 +19,19 @@ const GenericSuccess = { export const GenericSuccessSchema = ajv.compile(GenericSuccess); +const GenericError = { + type: 'object', + properties: { + success: { + type: 'boolean', + }, + message: { + type: 'string', + }, + }, +}; +export const GenericErrorSchema = ajv.compile<{ success: boolean; message: string }>(GenericError); + // Update ABAC attribute (request body) const UpdateAbacAttributeBody = { type: 'object', @@ -37,12 +50,11 @@ const UpdateAbacAttributeBody = { }; export const PUTAbacAttributeUpdateBodySchema = ajv.compile(UpdateAbacAttributeBody); -// Create an abac attribute using the IAbacAttributeDefintion type, create the ajv schemas const AbacAttributeDefinition = { type: 'object', properties: { - key: { type: 'string', minLength: 1 }, + key: { type: 'string', minLength: 1, pattern: '^[A-Za-z0-9_-]+$' }, values: { type: 'array', items: { type: 'string', minLength: 1 }, @@ -82,7 +94,7 @@ const AbacAttributeRecord = { type: 'object', properties: { _id: { type: 'string', minLength: 1 }, - key: { type: 'string', minLength: 1 }, + key: { type: 'string', minLength: 1, pattern: '^[A-Za-z0-9_-]+$' }, values: { type: 'array', items: { type: 'string', minLength: 1 }, @@ -99,6 +111,7 @@ const AbacAttributeRecord = { const GetAbacAttributesResponse = { type: 'object', properties: { + success: { type: 'boolean', enum: [true] }, attributes: { type: 'array', items: AbacAttributeRecord, @@ -121,7 +134,9 @@ export const GETAbacAttributesResponseSchema = ajv.compile<{ const GetAbacAttributeByIdResponse = { type: 'object', properties: { - key: { type: 'string', minLength: 1 }, + success: { type: 'boolean', enum: [true] }, + _id: { type: 'string', minLength: 1 }, + key: { type: 'string', minLength: 1, pattern: '^[A-Za-z0-9_-]+$' }, values: { type: 'array', items: { type: 'string', minLength: 1 }, @@ -134,11 +149,12 @@ const GetAbacAttributeByIdResponse = { additionalProperties: { type: 'boolean' }, }, }, - required: ['attribute', 'usage'], + required: ['key', 'values'], additionalProperties: false, }; export const GETAbacAttributeByIdResponseSchema = ajv.compile<{ + _id: string; key: string; values: string[]; usage: Record; @@ -147,6 +163,7 @@ export const GETAbacAttributeByIdResponseSchema = ajv.compile<{ const GetAbacAttributeIsInUseResponse = { type: 'object', properties: { + success: { type: 'boolean', enum: [true] }, inUse: { type: 'boolean' }, }, required: ['inUse'], @@ -164,7 +181,6 @@ const PostRoomAbacAttributesBody = { additionalProperties: { type: 'array', items: { type: 'string', minLength: 1 }, - uniqueItems: true, }, }, }, @@ -182,7 +198,6 @@ const PutRoomAbacAttributeValuesBody = { items: { type: 'string', minLength: 1 }, minItems: 1, maxItems: 10, - uniqueItems: true, }, }, required: ['values'], From 2577b4756f0f51c702607d2e3faf1e2c848e5a57 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Fri, 3 Oct 2025 14:52:38 -0600 Subject: [PATCH 033/125] schemas again --- apps/meteor/ee/server/api/abac/schemas.ts | 56 +++++++++-------------- 1 file changed, 22 insertions(+), 34 deletions(-) diff --git a/apps/meteor/ee/server/api/abac/schemas.ts b/apps/meteor/ee/server/api/abac/schemas.ts index d4920176d1e07..bd6c3df4f0d7b 100644 --- a/apps/meteor/ee/server/api/abac/schemas.ts +++ b/apps/meteor/ee/server/api/abac/schemas.ts @@ -1,14 +1,17 @@ import type { IAbacAttribute, IAbacAttributeDefinition } from '@rocket.chat/core-typings'; import Ajv from 'ajv'; +import { unique } from 'underscore'; // Removed AbacEndpoints import to avoid circular type reference (endpoints import these schemas) +const ATTRIBUTE_KEY_PATTERN = '^[A-Za-z0-9_-]+$'; +const MAX_ATTRIBUTE_VALUES = 10; +const MAX_ROOM_ATTRIBUTE_VALUES = 10; + const ajv = new Ajv({ coerceTypes: true, }); -// Omitted module augmentation to prevent circular reference with endpoint definitions - const GenericSuccess = { type: 'object', properties: { @@ -19,29 +22,16 @@ const GenericSuccess = { export const GenericSuccessSchema = ajv.compile(GenericSuccess); -const GenericError = { - type: 'object', - properties: { - success: { - type: 'boolean', - }, - message: { - type: 'string', - }, - }, -}; -export const GenericErrorSchema = ajv.compile<{ success: boolean; message: string }>(GenericError); - // Update ABAC attribute (request body) const UpdateAbacAttributeBody = { type: 'object', properties: { - key: { type: 'string', minLength: 1, pattern: '^[A-Za-z0-9_-]+$' }, + key: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN }, values: { type: 'array', items: { type: 'string', minLength: 1 }, minItems: 1, - maxItems: 10, + maxItems: MAX_ATTRIBUTE_VALUES, uniqueItems: true, }, }, @@ -50,16 +40,17 @@ const UpdateAbacAttributeBody = { }; export const PUTAbacAttributeUpdateBodySchema = ajv.compile(UpdateAbacAttributeBody); +// Create an abac attribute using the IAbacAttributeDefintion type, create the ajv schemas const AbacAttributeDefinition = { type: 'object', properties: { - key: { type: 'string', minLength: 1, pattern: '^[A-Za-z0-9_-]+$' }, + key: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN }, values: { type: 'array', items: { type: 'string', minLength: 1 }, minItems: 1, - maxItems: 10, + maxItems: MAX_ATTRIBUTE_VALUES, uniqueItems: true, }, }, @@ -72,12 +63,12 @@ export const POSTAbacAttributeDefinitionSchema = ajv.compile; @@ -163,7 +149,6 @@ export const GETAbacAttributeByIdResponseSchema = ajv.compile<{ const GetAbacAttributeIsInUseResponse = { type: 'object', properties: { - success: { type: 'boolean', enum: [true] }, inUse: { type: 'boolean' }, }, required: ['inUse'], @@ -177,10 +162,12 @@ const PostRoomAbacAttributesBody = { properties: { attributes: { type: 'object', - propertyNames: { type: 'string', pattern: '^[A-Za-z0-9_-]+$' }, + propertyNames: { type: 'string', pattern: ATTRIBUTE_KEY_PATTERN }, additionalProperties: { type: 'array', items: { type: 'string', minLength: 1 }, + maxItems: MAX_ROOM_ATTRIBUTE_VALUES, + uniqueItems: true, }, }, }, @@ -197,7 +184,8 @@ const PutRoomAbacAttributeValuesBody = { type: 'array', items: { type: 'string', minLength: 1 }, minItems: 1, - maxItems: 10, + maxItems: MAX_ROOM_ATTRIBUTE_VALUES, + uniqueItems: true, }, }, required: ['values'], From 114742efdaeb2c999010e3303ef2753cb40d4184 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 6 Oct 2025 10:22:52 -0600 Subject: [PATCH 034/125] endpoints --- apps/meteor/ee/server/api/abac/index.ts | 23 ++++++++ apps/meteor/ee/server/api/abac/schemas.ts | 52 +++++++++++++++---- ee/packages/abac/src/index.ts | 21 ++++++++ .../core-services/src/types/IAbacService.ts | 1 + packages/models/src/models/Rooms.ts | 2 +- 5 files changed, 89 insertions(+), 10 deletions(-) diff --git a/apps/meteor/ee/server/api/abac/index.ts b/apps/meteor/ee/server/api/abac/index.ts index de76dffb361ba..8f256d7988f78 100644 --- a/apps/meteor/ee/server/api/abac/index.ts +++ b/apps/meteor/ee/server/api/abac/index.ts @@ -11,6 +11,7 @@ import { POSTAbacAttributeDefinitionSchema, GETAbacAttributeIsInUseResponseSchema, POSTRoomAbacAttributesBodySchema, + POSTSingleRoomAbacAttributeBodySchema, PUTRoomAbacAttributeValuesBodySchema, GenericErrorSchema, } from './schemas'; @@ -44,6 +45,28 @@ const abacEndpoints = API.v1 return API.v1.success(); }, ) + // add an abac attribute by key + .post( + 'abac/room/:rid/attributes/:key', + { + authRequired: true, + permissionsRequired: ['abac-management'], + license: ['abac'], + body: POSTSingleRoomAbacAttributeBodySchema, + response: { 200: GenericSuccessSchema, 401: validateUnauthorizedErrorResponse, 400: GenericErrorSchema }, + }, + async function action() { + const { rid, key } = this.urlParams; + const { values } = this.bodyParams; + + if (!settings.get('ABAC_Enabled')) { + throw new Error('error-abac-not-enabled'); + } + + await Abac.addRoomAbacAttributeByKey(rid, key, values); + return API.v1.success(); + }, + ) // edit a room attribute .put( 'abac/room/:rid/attributes/:key', diff --git a/apps/meteor/ee/server/api/abac/schemas.ts b/apps/meteor/ee/server/api/abac/schemas.ts index bd6c3df4f0d7b..f35adbd85e836 100644 --- a/apps/meteor/ee/server/api/abac/schemas.ts +++ b/apps/meteor/ee/server/api/abac/schemas.ts @@ -1,6 +1,5 @@ import type { IAbacAttribute, IAbacAttributeDefinition } from '@rocket.chat/core-typings'; import Ajv from 'ajv'; -import { unique } from 'underscore'; // Removed AbacEndpoints import to avoid circular type reference (endpoints import these schemas) @@ -29,7 +28,7 @@ const UpdateAbacAttributeBody = { key: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN }, values: { type: 'array', - items: { type: 'string', minLength: 1 }, + items: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN }, minItems: 1, maxItems: MAX_ATTRIBUTE_VALUES, uniqueItems: true, @@ -48,7 +47,7 @@ const AbacAttributeDefinition = { key: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN }, values: { type: 'array', - items: { type: 'string', minLength: 1 }, + items: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN }, minItems: 1, maxItems: MAX_ATTRIBUTE_VALUES, uniqueItems: true, @@ -66,7 +65,7 @@ const GetAbacAttributesQuery = { key: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN }, values: { type: 'array', - items: { type: 'string', minLength: 1 }, + items: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN }, minItems: 1, maxItems: MAX_ATTRIBUTE_VALUES, uniqueItems: true, @@ -88,7 +87,7 @@ const AbacAttributeRecord = { key: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN }, values: { type: 'array', - items: { type: 'string', minLength: 1 }, + items: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN }, minItems: 1, maxItems: MAX_ATTRIBUTE_VALUES, uniqueItems: true, @@ -101,6 +100,7 @@ const AbacAttributeRecord = { const GetAbacAttributesResponse = { type: 'object', properties: { + success: { type: 'boolean', enum: [true] }, attributes: { type: 'array', items: AbacAttributeRecord, @@ -123,10 +123,12 @@ export const GETAbacAttributesResponseSchema = ajv.compile<{ const GetAbacAttributeByIdResponse = { type: 'object', properties: { + success: { type: 'boolean', enum: [true] }, + _id: { type: 'string', minLength: 1 }, key: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN }, values: { type: 'array', - items: { type: 'string', minLength: 1 }, + items: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN }, minItems: 1, maxItems: MAX_ATTRIBUTE_VALUES, uniqueItems: true, @@ -136,7 +138,7 @@ const GetAbacAttributeByIdResponse = { additionalProperties: { type: 'boolean' }, }, }, - required: ['attribute', 'usage'], + required: ['key', 'values'], additionalProperties: false, }; @@ -149,6 +151,7 @@ export const GETAbacAttributeByIdResponseSchema = ajv.compile<{ const GetAbacAttributeIsInUseResponse = { type: 'object', properties: { + success: { type: 'boolean', enum: [true] }, inUse: { type: 'boolean' }, }, required: ['inUse'], @@ -165,7 +168,7 @@ const PostRoomAbacAttributesBody = { propertyNames: { type: 'string', pattern: ATTRIBUTE_KEY_PATTERN }, additionalProperties: { type: 'array', - items: { type: 'string', minLength: 1 }, + items: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN }, maxItems: MAX_ROOM_ATTRIBUTE_VALUES, uniqueItems: true, }, @@ -177,12 +180,29 @@ const PostRoomAbacAttributesBody = { export const POSTRoomAbacAttributesBodySchema = ajv.compile<{ attributes: Record }>(PostRoomAbacAttributesBody); +const PostSingleRoomAbacAttributeBody = { + type: 'object', + properties: { + values: { + type: 'array', + items: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN }, + minItems: 1, + maxItems: MAX_ROOM_ATTRIBUTE_VALUES, + uniqueItems: true, + }, + }, + required: ['values'], + additionalProperties: false, +}; + +export const POSTSingleRoomAbacAttributeBodySchema = ajv.compile<{ values: string[] }>(PostSingleRoomAbacAttributeBody); + const PutRoomAbacAttributeValuesBody = { type: 'object', properties: { values: { type: 'array', - items: { type: 'string', minLength: 1 }, + items: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN }, minItems: 1, maxItems: MAX_ROOM_ATTRIBUTE_VALUES, uniqueItems: true, @@ -193,3 +213,17 @@ const PutRoomAbacAttributeValuesBody = { }; export const PUTRoomAbacAttributeValuesBodySchema = ajv.compile<{ values: string[] }>(PutRoomAbacAttributeValuesBody); + +const GenericError = { + type: 'object', + properties: { + success: { + type: 'boolean', + }, + message: { + type: 'string', + }, + }, +}; + +export const GenericErrorSchema = ajv.compile<{ success: boolean; message: string }>(GenericError); diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index f589edc0b6edd..8cb58fbb6a60f 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -308,6 +308,27 @@ export class AbacService extends ServiceClass implements IAbacService { await this.onRoomAttributesChanged(rid, next); } + async addRoomAbacAttributeByKey(rid: string, key: string, values: string[]): Promise { + await this.ensureAttributeDefinitionsExist([{ key, values }]); + + const room = await Rooms.findOneByIdAndType(rid, 'p', { projection: { abacAttributes: 1 } }); + if (!room) { + throw new Error('error-room-not-found'); + } + + const previous: IAbacAttributeDefinition[] = room.abacAttributes || []; + if (previous.some((a) => a.key === key)) { + throw new Error('error-duplicate-attribute-key'); + } + + if (previous.length >= 10) { + throw new Error('error-invalid-attribute-values'); + } + + const updated = await Rooms.insertAbacAttributeIfNotExistsById(rid, key, values); + await this.onRoomAttributesChanged(rid, updated?.abacAttributes || [...previous, { key, values }]); + } + async replaceRoomAbacAttributeByKey(rid: string, key: string, values: string[]): Promise { const keyPattern = /^[A-Za-z0-9_-]+$/; if (!keyPattern.test(key)) { diff --git a/packages/core-services/src/types/IAbacService.ts b/packages/core-services/src/types/IAbacService.ts index 3df9bb145e211..85409d06fa9dd 100644 --- a/packages/core-services/src/types/IAbacService.ts +++ b/packages/core-services/src/types/IAbacService.ts @@ -15,5 +15,6 @@ export interface IAbacService { isAbacAttributeInUseByKey(key: string): Promise; setRoomAbacAttributes(rid: string, attributes: Record): Promise; removeRoomAbacAttribute(rid: string, key: string): Promise; + addRoomAbacAttributeByKey(rid: string, key: string, values: string[]): Promise; replaceRoomAbacAttributeByKey(rid: string, key: string, values: string[]): Promise; } diff --git a/packages/models/src/models/Rooms.ts b/packages/models/src/models/Rooms.ts index 4e80c32e41ec9..aaa4c9f923570 100644 --- a/packages/models/src/models/Rooms.ts +++ b/packages/models/src/models/Rooms.ts @@ -112,7 +112,7 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { }, { key: { 'attributes.key': 1, 'attributes.values': 1 }, - partialFilterExpression: { attributes: { $exists: true, $ne: [] } }, + partialFilterExpression: { 'attributes': { $exists: true }, 'attributes.key': { $exists: true } }, }, ]; } From cc70695b9b1a6e3d21e087400babae411a4a6d70 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 6 Oct 2025 10:23:53 -0600 Subject: [PATCH 035/125] void insted of error --- ee/packages/abac/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index 8cb58fbb6a60f..851894b7c0bd0 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -363,7 +363,7 @@ export class AbacService extends ServiceClass implements IAbacService { } protected async onRoomAttributesChanged(_rid: string, _newAttributes: IAbacAttributeDefinition[]): Promise { - throw new Error('not implemented'); + // Intentionally left blank for adding code later. For now, its no-op } } From 766cbd76bb911eda293aed5a5dca06adcaa32155 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 6 Oct 2025 11:01:58 -0600 Subject: [PATCH 036/125] fix index usage --- packages/models/src/models/Rooms.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/models/src/models/Rooms.ts b/packages/models/src/models/Rooms.ts index aaa4c9f923570..23130e1d56d0a 100644 --- a/packages/models/src/models/Rooms.ts +++ b/packages/models/src/models/Rooms.ts @@ -111,8 +111,8 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { }, }, { - key: { 'attributes.key': 1, 'attributes.values': 1 }, - partialFilterExpression: { 'attributes': { $exists: true }, 'attributes.key': { $exists: true } }, + key: { 'abacAttributes.key': 1, 'abacAttributes.values': 1 }, + partialFilterExpression: { abacAttributes: { $exists: true } }, }, ]; } From 777f6c984c4accff31535a61d2c4a2391ee49ac7 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 6 Oct 2025 11:51:43 -0600 Subject: [PATCH 037/125] tests --- apps/meteor/ee/server/api/abac/schemas.ts | 2 +- ee/packages/abac/src/index.spec.ts | 93 ++++++++++++++++++----- ee/packages/abac/src/index.ts | 16 ---- 3 files changed, 76 insertions(+), 35 deletions(-) diff --git a/apps/meteor/ee/server/api/abac/schemas.ts b/apps/meteor/ee/server/api/abac/schemas.ts index f35adbd85e836..2292bc6639335 100644 --- a/apps/meteor/ee/server/api/abac/schemas.ts +++ b/apps/meteor/ee/server/api/abac/schemas.ts @@ -39,10 +39,10 @@ const UpdateAbacAttributeBody = { }; export const PUTAbacAttributeUpdateBodySchema = ajv.compile(UpdateAbacAttributeBody); -// Create an abac attribute using the IAbacAttributeDefintion type, create the ajv schemas const AbacAttributeDefinition = { type: 'object', + properties: { key: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN }, values: { diff --git a/ee/packages/abac/src/index.spec.ts b/ee/packages/abac/src/index.spec.ts index 2ba1300b2de57..32546d7df8c5a 100644 --- a/ee/packages/abac/src/index.spec.ts +++ b/ee/packages/abac/src/index.spec.ts @@ -446,16 +446,6 @@ describe('AbacService (unit)', () => { mockAbacFind.mockReturnValue({ toArray: async () => [{ key: 'dept', values: ['eng', 'sales', 'hr'] }] }); }); - it('throws error-invalid-attribute-key for invalid key', async () => { - await expect(service.updateRoomAbacAttributeValues('r1', 'bad key', ['v'])).rejects.toThrow('error-invalid-attribute-key'); - expect(mockUpdateSingleAbacAttributeValuesById).not.toHaveBeenCalled(); - }); - - it('throws error-invalid-attribute-values when more than 10 values provided', async () => { - const values = Array.from({ length: 11 }, (_, i) => `v${i}`); - await expect(service.updateRoomAbacAttributeValues('r1', 'dept', values)).rejects.toThrow('error-invalid-attribute-values'); - }); - it('throws error-room-not-found if room missing', async () => { mockFindOneByIdAndType.mockResolvedValueOnce(null); await expect(service.updateRoomAbacAttributeValues('missing', 'dept', ['eng'])).rejects.toThrow('error-room-not-found'); @@ -544,14 +534,6 @@ describe('AbacService (unit)', () => { mockAbacFind.mockReturnValue({ toArray: async () => [{ key: 'dept', values: ['eng', 'sales', 'hr'] }] }); }); - it('throws error-invalid-attribute-key for invalid key', async () => { - await expect((service as any).replaceRoomAbacAttributeByKey('r1', 'bad key', ['eng'])).rejects.toThrow( - 'error-invalid-attribute-key', - ); - expect(mockInsertAbacAttributeIfNotExistsById).not.toHaveBeenCalled(); - expect(mockUpdateAbacAttributeValuesArrayFilteredById).not.toHaveBeenCalled(); - }); - it('throws error-invalid-attribute-values when more than 10 values provided', async () => { const values = Array.from({ length: 11 }, (_, i) => `v${i}`); await expect((service as any).replaceRoomAbacAttributeByKey('r1', 'dept', values)).rejects.toThrow( @@ -611,5 +593,80 @@ describe('AbacService (unit)', () => { expect(mockUpdateAbacAttributeValuesArrayFilteredById).not.toHaveBeenCalled(); }); }); + + describe('addRoomAbacAttributeByKey', () => { + beforeEach(() => { + mockFindOneByIdAndType.mockReset(); + mockInsertAbacAttributeIfNotExistsById.mockReset(); + mockAbacFind.mockReset(); + (service as any).onRoomAttributesChanged = jest.fn().mockResolvedValue(undefined); + }); + + it('throws error-room-not-found when room does not exist', async () => { + // Ensure definitions exist to pass definition check, but room missing + mockAbacFind.mockReturnValueOnce({ toArray: async () => [{ key: 'dept', values: ['eng'] }] }); + mockFindOneByIdAndType.mockResolvedValueOnce(null); + await expect(service.addRoomAbacAttributeByKey('missing', 'dept', ['eng'])).rejects.toThrow('error-room-not-found'); + expect(mockInsertAbacAttributeIfNotExistsById).not.toHaveBeenCalled(); + }); + + it('throws error-attribute-definition-not-found when attribute definition missing', async () => { + // No definitions returned + mockAbacFind.mockReturnValueOnce({ toArray: async () => [] }); + mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [] }); + await expect(service.addRoomAbacAttributeByKey('r1', 'dept', ['eng'])).rejects.toThrow('error-attribute-definition-not-found'); + expect(mockInsertAbacAttributeIfNotExistsById).not.toHaveBeenCalled(); + }); + + it('throws error-duplicate-attribute-key when key already exists in room', async () => { + mockAbacFind.mockReturnValueOnce({ toArray: async () => [{ key: 'dept', values: ['eng', 'sales'] }] }); + mockFindOneByIdAndType.mockResolvedValueOnce({ + _id: 'r1', + abacAttributes: [{ key: 'dept', values: ['eng'] }], + }); + await expect(service.addRoomAbacAttributeByKey('r1', 'dept', ['sales'])).rejects.toThrow('error-duplicate-attribute-key'); + expect(mockInsertAbacAttributeIfNotExistsById).not.toHaveBeenCalled(); + }); + + it('throws error-invalid-attribute-values when room already has 10 attributes', async () => { + const existing = Array.from({ length: 10 }, (_, i) => ({ key: `k${i}`, values: ['x'] })); + mockAbacFind.mockReturnValueOnce({ toArray: async () => [{ key: 'dept', values: ['eng'] }] }); + mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: existing }); + await expect(service.addRoomAbacAttributeByKey('r1', 'dept', ['eng'])).rejects.toThrow('error-invalid-attribute-values'); + expect(mockInsertAbacAttributeIfNotExistsById).not.toHaveBeenCalled(); + }); + + it('inserts new attribute and calls hook with DB returned document', async () => { + const existing = [{ key: 'other', values: ['x'] }]; + const updatedDoc = { abacAttributes: [...existing, { key: 'dept', values: ['eng', 'sales'] }] }; + mockAbacFind.mockReturnValueOnce({ toArray: async () => [{ key: 'dept', values: ['eng', 'sales'] }] }); + mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: existing }); + mockInsertAbacAttributeIfNotExistsById.mockResolvedValueOnce(updatedDoc); + + await service.addRoomAbacAttributeByKey('r1', 'dept', ['eng', 'sales']); + + expect(mockInsertAbacAttributeIfNotExistsById).toHaveBeenCalledWith('r1', 'dept', ['eng', 'sales']); + expect((service as any).onRoomAttributesChanged).toHaveBeenCalledWith('r1', updatedDoc.abacAttributes); + }); + + it('inserts new attribute and calls hook with constructed list when DB returns undefined', async () => { + const existing = [{ key: 'other', values: ['x'] }]; + mockAbacFind.mockReturnValueOnce({ toArray: async () => [{ key: 'dept', values: ['eng'] }] }); + mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: existing }); + mockInsertAbacAttributeIfNotExistsById.mockResolvedValueOnce(undefined); + + await service.addRoomAbacAttributeByKey('r1', 'dept', ['eng']); + + expect(mockInsertAbacAttributeIfNotExistsById).toHaveBeenCalledWith('r1', 'dept', ['eng']); + expect((service as any).onRoomAttributesChanged).toHaveBeenCalledWith('r1', [...existing, { key: 'dept', values: ['eng'] }]); + }); + + it('rejects when provided value not allowed by definition', async () => { + mockAbacFind.mockReturnValueOnce({ toArray: async () => [{ key: 'dept', values: ['eng'] }] }); + mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [] }); + await expect(service.addRoomAbacAttributeByKey('r1', 'dept', ['eng', 'sales'])).rejects.toThrow('error-invalid-attribute-values'); + expect(mockInsertAbacAttributeIfNotExistsById).not.toHaveBeenCalled(); + }); + }); }); }); diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index 851894b7c0bd0..ed74fd3eb6770 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -245,14 +245,6 @@ export class AbacService extends ServiceClass implements IAbacService { } async updateRoomAbacAttributeValues(rid: string, key: string, values: string[]): Promise { - const keyPattern = /^[A-Za-z0-9_-]+$/; - if (!keyPattern.test(key)) { - throw new Error('error-invalid-attribute-key'); - } - if (values.length > 10) { - throw new Error('error-invalid-attribute-values'); - } - const room = await Rooms.findOneByIdAndType(rid, 'p', { projection: { abacAttributes: 1 } }); if (!room) { throw new Error('error-room-not-found'); @@ -330,14 +322,6 @@ export class AbacService extends ServiceClass implements IAbacService { } async replaceRoomAbacAttributeByKey(rid: string, key: string, values: string[]): Promise { - const keyPattern = /^[A-Za-z0-9_-]+$/; - if (!keyPattern.test(key)) { - throw new Error('error-invalid-attribute-key'); - } - if (values.length > 10) { - throw new Error('error-invalid-attribute-values'); - } - await this.ensureAttributeDefinitionsExist([{ key, values }]); const room = await Rooms.findOneByIdAndType(rid, 'p', { projection: { abacAttributes: 1 } }); From 27ad8b27eefad1961bdeebcfa965f71b90633945 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 6 Oct 2025 13:59:35 -0600 Subject: [PATCH 038/125] delete all attributes from room --- apps/meteor/ee/server/api/abac/index.ts | 27 ++++++++++++++++++----- apps/meteor/ee/server/api/abac/schemas.ts | 2 ++ ee/packages/abac/src/index.ts | 4 ++++ 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/apps/meteor/ee/server/api/abac/index.ts b/apps/meteor/ee/server/api/abac/index.ts index 8f256d7988f78..27e9a2ec5a4e6 100644 --- a/apps/meteor/ee/server/api/abac/index.ts +++ b/apps/meteor/ee/server/api/abac/index.ts @@ -27,24 +27,39 @@ const abacEndpoints = API.v1 permissionsRequired: ['abac-management'], body: POSTRoomAbacAttributesBodySchema, response: { 200: GenericSuccessSchema, 401: validateUnauthorizedErrorResponse, 400: GenericErrorSchema }, + license: ['abac'], }, async function action() { const { rid } = this.urlParams; const { attributes } = this.bodyParams; - // This endpoint could be called without a license - // If we use settings.get, it will return false because it's the "invalid value" - // So we get the real value from the setting - // But we only need to check the setting if the user is trying to set attributes - // If it's trying to remove attributes by setting an empty object, then we allow it - if (Object.keys(attributes).length && !(await Settings.getValueById('ABAC_Enabled'))) { + if (settings.get('ABAC_Enabled')) { throw new Error('error-abac-not-enabled'); } + // This is a replace-all operation + // IF you need fine grained, use the other endpoints for removing, editing & adding single attributes await Abac.setRoomAbacAttributes(rid, attributes); return API.v1.success(); }, ) + .delete( + 'abac/room/:rid/attributes', + { + authRequired: true, + permissionsRequired: ['abac-management'], + response: { 200: GenericSuccessSchema, 401: validateUnauthorizedErrorResponse, 400: GenericErrorSchema }, + }, + async function action() { + const { rid } = this.urlParams; + + // We don't need to check if ABAC is enabled to clear attributes + // Since we're always allowing this operation + // license check is also not required + await Abac.setRoomAbacAttributes(rid, {}); + return API.v1.success(); + }, + ) // add an abac attribute by key .post( 'abac/room/:rid/attributes/:key', diff --git a/apps/meteor/ee/server/api/abac/schemas.ts b/apps/meteor/ee/server/api/abac/schemas.ts index 2292bc6639335..3603987764290 100644 --- a/apps/meteor/ee/server/api/abac/schemas.ts +++ b/apps/meteor/ee/server/api/abac/schemas.ts @@ -166,6 +166,8 @@ const PostRoomAbacAttributesBody = { attributes: { type: 'object', propertyNames: { type: 'string', pattern: ATTRIBUTE_KEY_PATTERN }, + minProperties: 1, + maxProperties: MAX_ATTRIBUTE_VALUES, additionalProperties: { type: 'array', items: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN }, diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index ed74fd3eb6770..bf6147118c4eb 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -227,6 +227,10 @@ export class AbacService extends ServiceClass implements IAbacService { } private computeAttributesRemoval(previous: IAbacAttributeDefinition[], next: IAbacAttributeDefinition[]): boolean { + if (!next.length) { + return previous.length > 0; + } + const newMap = new Map>(next.map((a) => [a.key, new Set(a.values)])); for (const prev of previous) { From 25eaf3319d5b1a60fc67e8f015661c1a87702367 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 6 Oct 2025 14:17:55 -0600 Subject: [PATCH 039/125] ts --- apps/meteor/ee/server/api/abac/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/apps/meteor/ee/server/api/abac/index.ts b/apps/meteor/ee/server/api/abac/index.ts index 27e9a2ec5a4e6..f3c3fdd62bdea 100644 --- a/apps/meteor/ee/server/api/abac/index.ts +++ b/apps/meteor/ee/server/api/abac/index.ts @@ -1,5 +1,4 @@ import { Abac } from '@rocket.chat/core-services'; -import { Settings } from '@rocket.chat/models'; import { validateUnauthorizedErrorResponse } from '@rocket.chat/rest-typings/src/v1/Ajv'; import { From fe03e236ff746f4443a077862bb708bf142d3b5c Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 6 Oct 2025 15:27:56 -0600 Subject: [PATCH 040/125] test' --- apps/meteor/ee/server/api/abac/index.ts | 77 +++- apps/meteor/tests/end-to-end/api/abac.ts | 428 +++++++++++++++++++++++ 2 files changed, 494 insertions(+), 11 deletions(-) create mode 100644 apps/meteor/tests/end-to-end/api/abac.ts diff --git a/apps/meteor/ee/server/api/abac/index.ts b/apps/meteor/ee/server/api/abac/index.ts index f3c3fdd62bdea..387189f090ed5 100644 --- a/apps/meteor/ee/server/api/abac/index.ts +++ b/apps/meteor/ee/server/api/abac/index.ts @@ -25,7 +25,12 @@ const abacEndpoints = API.v1 authRequired: true, permissionsRequired: ['abac-management'], body: POSTRoomAbacAttributesBodySchema, - response: { 200: GenericSuccessSchema, 401: validateUnauthorizedErrorResponse, 400: GenericErrorSchema }, + response: { + 200: GenericSuccessSchema, + 401: validateUnauthorizedErrorResponse, + 400: GenericErrorSchema, + 403: validateUnauthorizedErrorResponse, + }, license: ['abac'], }, async function action() { @@ -47,7 +52,12 @@ const abacEndpoints = API.v1 { authRequired: true, permissionsRequired: ['abac-management'], - response: { 200: GenericSuccessSchema, 401: validateUnauthorizedErrorResponse, 400: GenericErrorSchema }, + response: { + 200: GenericSuccessSchema, + 401: validateUnauthorizedErrorResponse, + 400: GenericErrorSchema, + 403: validateUnauthorizedErrorResponse, + }, }, async function action() { const { rid } = this.urlParams; @@ -67,7 +77,12 @@ const abacEndpoints = API.v1 permissionsRequired: ['abac-management'], license: ['abac'], body: POSTSingleRoomAbacAttributeBodySchema, - response: { 200: GenericSuccessSchema, 401: validateUnauthorizedErrorResponse, 400: GenericErrorSchema }, + response: { + 200: GenericSuccessSchema, + 401: validateUnauthorizedErrorResponse, + 400: GenericErrorSchema, + 403: validateUnauthorizedErrorResponse, + }, }, async function action() { const { rid, key } = this.urlParams; @@ -88,7 +103,12 @@ const abacEndpoints = API.v1 authRequired: true, permissionsRequired: ['abac-management'], body: PUTRoomAbacAttributeValuesBodySchema, - response: { 200: GenericSuccessSchema, 401: validateUnauthorizedErrorResponse, 400: GenericErrorSchema }, + response: { + 200: GenericSuccessSchema, + 401: validateUnauthorizedErrorResponse, + 400: GenericErrorSchema, + 403: validateUnauthorizedErrorResponse, + }, license: ['abac'], }, async function action() { @@ -109,7 +129,12 @@ const abacEndpoints = API.v1 { authRequired: true, permissionsRequired: ['abac-management'], - response: { 200: GenericSuccessSchema, 401: validateUnauthorizedErrorResponse, 400: GenericErrorSchema }, + response: { + 200: GenericSuccessSchema, + 401: validateUnauthorizedErrorResponse, + 400: GenericErrorSchema, + 403: validateUnauthorizedErrorResponse, + }, }, async function action() { const { rid, key } = this.urlParams; @@ -126,7 +151,12 @@ const abacEndpoints = API.v1 authRequired: true, permissionsRequired: ['abac-management'], query: GETAbacAttributesQuerySchema, - response: { 200: GETAbacAttributesResponseSchema, 401: validateUnauthorizedErrorResponse, 400: GenericErrorSchema }, + response: { + 200: GETAbacAttributesResponseSchema, + 401: validateUnauthorizedErrorResponse, + 400: GenericErrorSchema, + 403: validateUnauthorizedErrorResponse, + }, license: ['abac'], }, async function action() { @@ -154,7 +184,12 @@ const abacEndpoints = API.v1 permissionsRequired: ['abac-management'], license: ['abac'], body: POSTAbacAttributeDefinitionSchema, - response: { 200: GenericSuccessSchema, 401: validateUnauthorizedErrorResponse, 400: GenericErrorSchema }, + response: { + 200: GenericSuccessSchema, + 401: validateUnauthorizedErrorResponse, + 400: GenericErrorSchema, + 403: validateUnauthorizedErrorResponse, + }, }, async function action() { if (!settings.get('ABAC_Enabled')) { @@ -173,7 +208,12 @@ const abacEndpoints = API.v1 permissionsRequired: ['abac-management'], license: ['abac'], body: PUTAbacAttributeUpdateBodySchema, - response: { 200: GenericSuccessSchema, 401: validateUnauthorizedErrorResponse, 400: GenericErrorSchema }, + response: { + 200: GenericSuccessSchema, + 401: validateUnauthorizedErrorResponse, + 400: GenericErrorSchema, + 403: validateUnauthorizedErrorResponse, + }, }, async function action() { const { _id } = this.urlParams; @@ -191,7 +231,12 @@ const abacEndpoints = API.v1 { authRequired: true, permissionsRequired: ['abac-management'], - response: { 200: GETAbacAttributeByIdResponseSchema, 401: validateUnauthorizedErrorResponse, 400: GenericErrorSchema }, + response: { + 200: GETAbacAttributeByIdResponseSchema, + 401: validateUnauthorizedErrorResponse, + 400: GenericErrorSchema, + 403: validateUnauthorizedErrorResponse, + }, license: ['abac'], }, async function action() { @@ -209,7 +254,12 @@ const abacEndpoints = API.v1 { authRequired: true, permissionsRequired: ['abac-management'], - response: { 200: GenericSuccessSchema, 401: validateUnauthorizedErrorResponse, 400: GenericErrorSchema }, + response: { + 200: GenericSuccessSchema, + 401: validateUnauthorizedErrorResponse, + 400: GenericErrorSchema, + 403: validateUnauthorizedErrorResponse, + }, license: ['abac'], }, async function action() { @@ -227,7 +277,12 @@ const abacEndpoints = API.v1 { authRequired: true, permissionsRequired: ['abac-management'], - response: { 200: GETAbacAttributeIsInUseResponseSchema, 401: validateUnauthorizedErrorResponse, 400: GenericErrorSchema }, + response: { + 200: GETAbacAttributeIsInUseResponseSchema, + 401: validateUnauthorizedErrorResponse, + 400: GenericErrorSchema, + 403: validateUnauthorizedErrorResponse, + }, license: ['abac'], }, async function action() { diff --git a/apps/meteor/tests/end-to-end/api/abac.ts b/apps/meteor/tests/end-to-end/api/abac.ts new file mode 100644 index 0000000000000..5dc201241aa29 --- /dev/null +++ b/apps/meteor/tests/end-to-end/api/abac.ts @@ -0,0 +1,428 @@ +import type { Credentials } from '@rocket.chat/api-client'; +import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import { expect } from 'chai'; +import { before, after, describe, it } from 'mocha'; + +import { getCredentials, request, credentials } from '../../data/api-data'; +import { updatePermission, updateSetting } from '../../data/permissions.helper'; +import { createRoom, deleteRoom } from '../../data/rooms.helper'; +import { password } from '../../data/user'; +import { createUser, deleteUser, login } from '../../data/users.helper'; + +// NOTE: +// The original request suggested using describe.only to focus on this suite, +// but the lint configuration disallows describe.only (diagnostic error). +// If you want to focus these tests locally, temporarily add `.only` and run them. +// All endpoints are accessed via direct URL strings to bypass the typed api() helper, +// since ABAC endpoints are not yet included in its union type (causing TS errors). + +describe('[ABAC] (Enterprise Only)', function () { + this.retries(0); + + let testRoom: IRoom; + let unauthorizedUser: IUser; + let unauthorizedCredentials: Credentials; + + const initialKey = `attr_${Date.now()}`; + const updatedKey = `${initialKey}_renamed`; + const anotherKey = `${initialKey}_another`; + let attributeId: string; + + before((done) => getCredentials(done)); + + before(async () => { + await updatePermission('abac-management', ['admin']); + await updateSetting('ABAC_Enabled', true); + + testRoom = (await createRoom({ type: 'p', name: `abac-test-${Date.now()}` })).body.group; + + unauthorizedUser = await createUser(); + unauthorizedCredentials = await login(unauthorizedUser.username, password); + }); + + after(async () => { + await deleteRoom({ type: 'p', roomId: testRoom._id }); + await deleteUser(unauthorizedUser); + }); + + const v1 = '/api/v1'; + + describe('Permission & Authentication', () => { + it('GET /api/v1/abac/attributes should return 401 when not authenticated', async () => { + await request.get(`${v1}/abac/attributes`).expect(401); + }); + + it('POST /api/v1/abac/attributes should return 403 for user without abac-management permission', async () => { + await request + .post(`${v1}/abac/attributes`) + .set(unauthorizedCredentials) + .send({ key: 'nokey', values: ['v1'] }) + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'User does not have the permissions required for this action [error-unauthorized]'); + }); + }); + }); + + describe('Attribute Definition - Validations & CRUD', () => { + it('POST should fail with invalid key pattern (space)', async () => { + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: 'invalid key with space', values: ['one'] }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('POST should fail with duplicate values', async () => { + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: anotherKey, values: ['dup', 'dup'] }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('POST should fail with empty values array', async () => { + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: `${anotherKey}_empty`, values: [] }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('POST should fail with > 10 values', async () => { + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ + key: `${anotherKey}_toolong`, + values: ['v1', 'v2', 'v3', 'v4', 'v5', 'v6', 'v7', 'v8', 'v9', 'v10', 'v11'], + }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('POST should create a valid attribute definition', async () => { + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: initialKey, values: ['red', 'green', 'blue'] }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + + it('POST should fail creating duplicate key', async () => { + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: initialKey, values: ['another'] }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('GET should list attributes including the created one', async () => { + await request + .get(`${v1}/abac/attributes`) + .set(credentials) + .query({ key: initialKey }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('attributes').that.is.an('array'); + const found = res.body.attributes.find((a: any) => a.key === initialKey); + expect(found).to.exist; + expect(found).to.have.property('_id'); + attributeId = found._id; + }); + }); + + it('GET should fail when count > 100', async () => { + await request + .get(`${v1}/abac/attributes`) + .set(credentials) + .query({ count: 101 }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('GET by id should retrieve attribute definition', async () => { + await request + .get(`${v1}/abac/attributes/${attributeId}`) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('key', initialKey); + expect(res.body).to.have.property('values').that.is.an('array'); + }); + }); + + it('PUT should fail with empty body (needs key or values)', async () => { + await request + .put(`${v1}/abac/attributes/${attributeId}`) + .set(credentials) + .send({}) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('PUT should update values only', async () => { + await request + .put(`${v1}/abac/attributes/${attributeId}`) + .set(credentials) + .send({ values: ['cyan', 'magenta', 'yellow'] }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(`${v1}/abac/attributes/${attributeId}`) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body.values).to.deep.equal(['cyan', 'magenta', 'yellow']); + }); + }); + + it('PUT should update key only', async () => { + await request + .put(`${v1}/abac/attributes/${attributeId}`) + .set(credentials) + .send({ key: updatedKey }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(`${v1}/abac/attributes/${attributeId}`) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('key', updatedKey); + }); + }); + }); + + describe('Room Attribute Operations', () => { + it('GET is-in-use should initially be false (no room usage yet)', async () => { + await request + .get(`${v1}/abac/attributes/${updatedKey}/is-in-use`) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('inUse', false); + }); + // Also check per-value usage map (all false) + await request + .get(`${v1}/abac/attributes/${attributeId}`) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('usage'); + expect((Object.values(res.body.usage) as boolean[]).every((v) => v === false)).to.be.true; + }); + }); + + it('POST room attribute should fail with duplicate values', async () => { + await request + .post(`${v1}/abac/room/${testRoom._id}/attributes/${updatedKey}`) + .set(credentials) + .send({ values: ['dup', 'dup'] }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); + + it('POST room attribute should add values and reflect usage/inUse=true', async () => { + await request + .post(`${v1}/abac/room/${testRoom._id}/attributes/${updatedKey}`) + .set(credentials) + .send({ values: ['cyan'] }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + // inUse endpoint should now be true + await request + .get(`${v1}/abac/attributes/${updatedKey}/is-in-use`) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body.inUse).to.be.true; + }); + + // usage map: cyan true, others false + await request + .get(`${v1}/abac/attributes/${attributeId}`) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('usage'); + expect(res.body.usage).to.have.property('cyan', true); + // magenta & yellow not in use yet + if (res.body.usage.magenta !== undefined) expect(res.body.usage.magenta).to.be.false; + if (res.body.usage.yellow !== undefined) expect(res.body.usage.yellow).to.be.false; + }); + }); + + it('PUT room attribute should replace values and update usage map accordingly', async () => { + await request + .put(`${v1}/abac/room/${testRoom._id}/attributes/${updatedKey}`) + .set(credentials) + .send({ values: ['magenta', 'yellow'] }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + // usage now: magenta true, yellow true, cyan false + await request + .get(`${v1}/abac/attributes/${attributeId}`) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body.usage).to.have.property('magenta', true); + expect(res.body.usage).to.have.property('yellow', true); + if (res.body.usage.cyan !== undefined) expect(res.body.usage.cyan).to.be.false; + }); + + // inUse should remain true + await request + .get(`${v1}/abac/attributes/${updatedKey}/is-in-use`) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body.inUse).to.be.true; + }); + }); + + it('DELETE room attribute key should succeed and clear usage/inUse=false', async () => { + await request + .delete(`${v1}/abac/room/${testRoom._id}/attributes/${updatedKey}`) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + // usage all false again + await request + .get(`${v1}/abac/attributes/${attributeId}`) + .set(credentials) + .expect(200) + .expect((res) => { + expect((Object.values(res.body.usage) as boolean[]).every((v) => v === false)).to.be.true; + }); + + await request + .get(`${v1}/abac/attributes/${updatedKey}/is-in-use`) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body.inUse).to.be.false; + }); + }); + + it('POST (replace-all) should currently fail while ABAC enabled due to inverted check (documenting behavior)', async () => { + // Endpoint code throws error-abac-not-enabled when ABAC_Enabled === true (likely a bug). + await request + .post(`${v1}/abac/room/${testRoom._id}/attributes`) + .set(credentials) + .send({ attributes: { [updatedKey]: ['cyan'] } }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error').that.includes('error-abac-not-enabled'); + }); + }); + + it('DELETE all room attributes should succeed even if ABAC disabled', async () => { + await updateSetting('ABAC_Enabled', false); + + await request + .delete(`${v1}/abac/room/${testRoom._id}/attributes`) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await updateSetting('ABAC_Enabled', true); + }); + }); + + describe('Usage & Deletion', () => { + it('POST add room usage for attribute (re-add after clearing) and expect delete while in use to fail', async () => { + await request + .post(`${v1}/abac/room/${testRoom._id}/attributes/${updatedKey}`) + .set(credentials) + .send({ values: ['cyan'] }) + .expect(200); + + // Attempt to delete attribute while in use should fail + await request + .delete(`${v1}/abac/attributes/${attributeId}`) + .set(credentials) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error').that.includes('error-attribute-in-use'); + }); + }); + + it('GET is-in-use should reflect usage true before clearing, then false after removal', async () => { + await request + .get(`${v1}/abac/attributes/${updatedKey}/is-in-use`) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body.inUse).to.be.true; + }); + + // Remove room attribute again + await request.delete(`${v1}/abac/room/${testRoom._id}/attributes/${updatedKey}`).set(credentials).expect(200); + + await request + .get(`${v1}/abac/attributes/${updatedKey}/is-in-use`) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body.inUse).to.be.false; + }); + }); + + it('DELETE attribute definition should now succeed (no room usage)', async () => { + await request + .delete(`${v1}/abac/attributes/${attributeId}`) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); + }); +}); From ef11083e9f487404da84988d755bac2edf86f7e1 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 7 Oct 2025 10:28:57 -0600 Subject: [PATCH 041/125] api --- apps/meteor/ee/server/api/abac/index.ts | 2 +- apps/meteor/tests/end-to-end/api/abac.ts | 417 +++++++++++++++++++++-- ee/packages/abac/src/index.ts | 9 +- 3 files changed, 402 insertions(+), 26 deletions(-) diff --git a/apps/meteor/ee/server/api/abac/index.ts b/apps/meteor/ee/server/api/abac/index.ts index 387189f090ed5..d4495fda755dc 100644 --- a/apps/meteor/ee/server/api/abac/index.ts +++ b/apps/meteor/ee/server/api/abac/index.ts @@ -37,7 +37,7 @@ const abacEndpoints = API.v1 const { rid } = this.urlParams; const { attributes } = this.bodyParams; - if (settings.get('ABAC_Enabled')) { + if (!settings.get('ABAC_Enabled')) { throw new Error('error-abac-not-enabled'); } diff --git a/apps/meteor/tests/end-to-end/api/abac.ts b/apps/meteor/tests/end-to-end/api/abac.ts index 5dc201241aa29..5c5215ed784c7 100644 --- a/apps/meteor/tests/end-to-end/api/abac.ts +++ b/apps/meteor/tests/end-to-end/api/abac.ts @@ -8,15 +8,9 @@ import { updatePermission, updateSetting } 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'; -// NOTE: -// The original request suggested using describe.only to focus on this suite, -// but the lint configuration disallows describe.only (diagnostic error). -// If you want to focus these tests locally, temporarily add `.only` and run them. -// All endpoints are accessed via direct URL strings to bypass the typed api() helper, -// since ABAC endpoints are not yet included in its union type (causing TS errors). - -describe('[ABAC] (Enterprise Only)', function () { +(IS_EE ? describe : describe.skip)('[ABAC] (Enterprise Only)', function () { this.retries(0); let testRoom: IRoom; @@ -347,19 +341,6 @@ describe('[ABAC] (Enterprise Only)', function () { }); }); - it('POST (replace-all) should currently fail while ABAC enabled due to inverted check (documenting behavior)', async () => { - // Endpoint code throws error-abac-not-enabled when ABAC_Enabled === true (likely a bug). - await request - .post(`${v1}/abac/room/${testRoom._id}/attributes`) - .set(credentials) - .send({ attributes: { [updatedKey]: ['cyan'] } }) - .expect(400) - .expect((res) => { - expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('error').that.includes('error-abac-not-enabled'); - }); - }); - it('DELETE all room attributes should succeed even if ABAC disabled', async () => { await updateSetting('ABAC_Enabled', false); @@ -425,4 +406,398 @@ describe('[ABAC] (Enterprise Only)', function () { }); }); }); + + describe('Extended Validations & Edge Cases', () => { + let firstAttributeId: string; + let secondAttributeId: string; + const firstKey = `${initialKey}_first`; + const secondKey = `${initialKey}_second`; + const bulkAttrPrefix = `bulk_${Date.now()}`; + const tempKeyForPattern = `Tmp${Date.now()}`; + const invalidCharKey = `bad*key`; + const invalidCharValue = `bad*value`; + const randomId = 'nonExistingId1234567890'; + + it('POST should create a second attribute definition for conflict tests', async () => { + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: secondKey, values: ['alpha', 'beta', 'gamma'] }) + .expect(200); + + await request + .get(`${v1}/abac/attributes`) + .set(credentials) + .query({ key: secondKey }) + .expect(200) + .expect((res) => { + const found = res.body.attributes.find((a: any) => a.key === secondKey); + expect(found).to.exist; + secondAttributeId = found._id; + }); + }); + + it('POST should create an attribute definition for conflicts', async () => { + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: firstKey, values: ['alpha', 'beta', 'gamma'] }) + .expect(200); + + await request + .get(`${v1}/abac/attributes`) + .set(credentials) + .query({ key: firstKey }) + .expect(200) + .expect((res) => { + const found = res.body.attributes.find((a: any) => a.key === firstKey); + expect(found).to.exist; + firstAttributeId = found._id; + }); + }); + + it('PUT attribute should fail when renaming to an existing key (duplicate key)', async () => { + await request + .put(`${v1}/abac/attributes/${secondAttributeId}`) + .set(credentials) + .send({ key: firstKey }) + .expect(400) + .expect((res) => { + expect(res.body.success).to.be.false; + }); + }); + + it('PUT attribute should fail when values array has duplicates', async () => { + await request + .put(`${v1}/abac/attributes/${secondAttributeId}`) + .set(credentials) + .send({ values: ['alpha', 'alpha'] }) + .expect(400); + }); + + it('PUT attribute should fail when values array > 10', async () => { + const eleven = Array.from({ length: 11 }, (_, i) => `v${i}`); + await request.put(`${v1}/abac/attributes/${secondAttributeId}`).set(credentials).send({ values: eleven }).expect(400); + }); + + it('PUT attribute should fail when values array empty', async () => { + await request.put(`${v1}/abac/attributes/${secondAttributeId}`).set(credentials).send({ values: [] }).expect(400); + }); + + it('GET attribute by invalid/non-existing id should fail', async () => { + await request.get(`${v1}/abac/attributes/${randomId}`).set(credentials).expect(400); + }); + + it('DELETE attribute by invalid/non-existing id should fail', async () => { + await request.delete(`${v1}/abac/attributes/${randomId}`).set(credentials).expect(400); + }); + + it('PUT attribute by invalid/non-existing id should fail', async () => { + await request + .put(`${v1}/abac/attributes/${randomId}`) + .set(credentials) + .send({ key: `${tempKeyForPattern}_X` }) + .expect(400); + }); + + it('POST attribute should fail with invalid key pattern (special char)', async () => { + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: invalidCharKey, values: ['ok'] }) + .expect(400); + }); + + it('POST attribute should fail with invalid value pattern (special char in value)', async () => { + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: `${tempKeyForPattern}_values`, values: [invalidCharValue] }) + .expect(400); + }); + + it('POST attribute should succeed with exactly 10 values (boundary)', async () => { + const tenValues = Array.from({ length: 10 }, (_, i) => `b${i}`); + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: `${tempKeyForPattern}_maxvals`, values: tenValues }) + .expect(200); + }); + + it('GET attributes with count=100 should succeed (boundary)', async () => { + await request + .get(`${v1}/abac/attributes`) + .set(credentials) + .query({ count: 100 }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('count'); + }); + }); + + it('GET attributes should fail with count=0 (schema min=1)', async () => { + await request.get(`${v1}/abac/attributes`).set(credentials).query({ count: 0 }).expect(400); + }); + + it('GET attributes should fail with negative offset', async () => { + await request.get(`${v1}/abac/attributes`).set(credentials).query({ offset: -1 }).expect(400); + }); + + it('GET attributes should fail with unexpected extra query parameter', async () => { + await request.get(`${v1}/abac/attributes`).set(credentials).query({ extraneous: 'param' }).expect(400); + }); + + it('GET attributes should fail with invalid value pattern in values filter', async () => { + await request + .get(`${v1}/abac/attributes`) + .set(credentials) + .query({ values: [invalidCharValue] }) + .expect(400); + }); + + it('GET attributes should filter by values when valid (expect subset or empty)', async () => { + await request + .get(`${v1}/abac/attributes`) + .set(credentials) + .query({ 'values[]': 'magenta' }) + .expect(200) + .expect((res) => { + expect(res.body.success).to.be.true; + }); + }); + + describe('Room attribute bulk operations validations', () => { + it('POST bulk room attributes should fail when more than 10 distinct attribute keys provided', async () => { + const tooMany: Record = {}; + for (let i = 0; i < 11; i++) tooMany[`${bulkAttrPrefix}_${i}`] = ['v1']; + await request.post(`${v1}/abac/room/${testRoom._id}/attributes`).set(credentials).send({ attributes: tooMany }).expect(400); + }); + + it('POST bulk room attributes should fail when one attribute has >10 values', async () => { + const bigValues = Array.from({ length: 11 }, (_, i) => `v${i}`); + await request + .post(`${v1}/abac/room/${testRoom._id}/attributes`) + .set(credentials) + .send({ attributes: { [`${bulkAttrPrefix}_k1`]: bigValues } }) + .expect(400); + }); + + it('POST bulk room attributes should fail when using a value not in attribute definition', async () => { + await request + .post(`${v1}/abac/room/${testRoom._id}/attributes`) + .set(credentials) + .send({ attributes: { [secondKey]: ['alpha', 'delta'] } }) + .expect(400); + }); + + it('POST bulk room attributes should succeed when providing valid subset for existing definitions', async () => { + await request + .post(`${v1}/abac/room/${testRoom._id}/attributes`) + .set(credentials) + .send({ + attributes: { + [secondKey]: ['alpha', 'beta'], + }, + }) + .expect(200); + }); + }); + + describe('Room attribute key/value validation edge cases', () => { + it('POST single room attribute should fail with >10 values', async () => { + const eleven = Array.from({ length: 11 }, (_, i) => `x${i}`); + await request.post(`${v1}/abac/room/${testRoom._id}/attributes/${secondKey}`).set(credentials).send({ values: eleven }).expect(400); + }); + + it('POST single room attribute should fail when value not allowed by definition', async () => { + await request + .post(`${v1}/abac/room/${testRoom._id}/attributes/${secondKey}`) + .set(credentials) + .send({ values: ['alpha', 'zzz'] }) + .expect(400); + }); + + it('PUT single room attribute should fail with value not in definition', async () => { + await request + .put(`${v1}/abac/room/${testRoom._id}/attributes/${secondKey}`) + .set(credentials) + .send({ values: ['gamma', 'invalid'] }) + .expect(400); + }); + }); + + describe('Room attribute limits (max 10 attribute keys)', () => { + const tempAttrKeys: string[] = []; + it('Reset room attributes before limit test and populate with 10 keys', async () => { + await request.delete(`${v1}/abac/room/${testRoom._id}/attributes`).set(credentials).expect(200); + + const timestamp = Date.now(); + const keys = Array.from({ length: 10 }, (_, i) => `limitk_${timestamp}_${i}`); + tempAttrKeys.push(...keys); + await Promise.all( + keys.map((k) => + request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: k, values: ['v1'] }) + .expect(200) + .then(() => + request + .post(`${v1}/abac/room/${testRoom._id}/attributes/${k}`) + .set(credentials) + .send({ values: ['v1'] }) + .expect(200), + ), + ), + ); + }); + + it('Adding an 11th attribute key to room should fail', async () => { + const extraKey = `limitk_extra_${Date.now()}`; + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: extraKey, values: ['v1'] }) + .expect(200); + + await request + .post(`${v1}/abac/room/${testRoom._id}/attributes/${extraKey}`) + .set(credentials) + .send({ values: ['v1'] }) + .expect(400); + }); + }); + + describe('Permission & Auth extended checks', () => { + it('POST /abac/room/:rid/attributes/:key should return 403 for unauthorized user', async () => { + await request + .post(`${v1}/abac/room/${testRoom._id}/attributes/${secondKey}`) + .set(unauthorizedCredentials) + .send({ values: ['alpha'] }) + .expect(403); + }); + + it('PUT /abac/room/:rid/attributes/:key should return 403 for unauthorized user', async () => { + await request + .put(`${v1}/abac/room/${testRoom._id}/attributes/${secondKey}`) + .set(unauthorizedCredentials) + .send({ values: ['alpha'] }) + .expect(403); + }); + + it('DELETE /abac/room/:rid/attributes/:key should return 403 for unauthorized user', async () => { + await request.delete(`${v1}/abac/room/${testRoom._id}/attributes/${secondKey}`).set(unauthorizedCredentials).expect(403); + }); + + it('GET /abac/attributes/:key/is-in-use should return 403 for unauthorized user', async () => { + await request.get(`${v1}/abac/attributes/${secondKey}/is-in-use`).set(unauthorizedCredentials).expect(403); + }); + }); + + describe('ABAC Disabled behavior for protected endpoints', () => { + before(async () => { + await updateSetting('ABAC_Enabled', false); + }); + + it('POST /abac/attributes should fail with error-abac-not-enabled', async () => { + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: `disabled_${Date.now()}`, values: ['one'] }) + .expect(400) + .expect((res) => { + expect(res.body.error).to.include('error-abac-not-enabled'); + }); + }); + + it('GET /abac/attributes should fail while disabled', async () => { + await request + .get(`${v1}/abac/attributes`) + .set(credentials) + .expect(400) + .expect((res) => { + expect(res.body.error).to.include('error-abac-not-enabled'); + }); + }); + + it('GET /abac/attributes/:_id should fail while disabled', async () => { + await request + .get(`${v1}/abac/attributes/${secondAttributeId}`) + .set(credentials) + .expect(400) + .expect((res) => { + expect(res.body.error).to.include('error-abac-not-enabled'); + }); + }); + + it('PUT /abac/attributes/:_id should fail while disabled', async () => { + await request + .put(`${v1}/abac/attributes/${secondAttributeId}`) + .set(credentials) + .send({ values: ['alpha'] }) + .expect(400) + .expect((res) => { + expect(res.body.error).to.include('error-abac-not-enabled'); + }); + }); + + it('DELETE /abac/attributes/:_id should fail while disabled', async () => { + await request + .delete(`${v1}/abac/attributes/${secondAttributeId}`) + .set(credentials) + .expect(400) + .expect((res) => { + expect(res.body.error).to.include('error-abac-not-enabled'); + }); + }); + + it('GET /abac/attributes/:key/is-in-use should fail while disabled', async () => { + await request + .get(`${v1}/abac/attributes/${secondKey}/is-in-use`) + .set(credentials) + .expect(400) + .expect((res) => { + expect(res.body.error).to.include('error-abac-not-enabled'); + }); + }); + + it('POST /abac/room/:rid/attributes/:key should fail while disabled', async () => { + await request + .post(`${v1}/abac/room/${testRoom._id}/attributes/${secondKey}`) + .set(credentials) + .send({ values: ['alpha'] }) + .expect(400) + .expect((res) => { + expect(res.body.error).to.include('error-abac-not-enabled'); + }); + }); + + it('PUT /abac/room/:rid/attributes/:key should fail while disabled', async () => { + await request + .put(`${v1}/abac/room/${testRoom._id}/attributes/${secondKey}`) + .set(credentials) + .send({ values: ['alpha'] }) + .expect(400) + .expect((res) => { + expect(res.body.error).to.include('error-abac-not-enabled'); + }); + }); + + it('POST /abac/room/:rid/attributes (bulk replace) should fail while disabled', async () => { + await request + .post(`${v1}/abac/room/${testRoom._id}/attributes`) + .set(credentials) + .send({ attributes: { [secondKey]: ['alpha'] } }) + .expect(400) + .expect((res) => { + expect(res.body.error).to.include('error-abac-not-enabled'); + }); + }); + + after(async () => { + await updateSetting('ABAC_Enabled', true); + }); + }); + }); }); diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index bf6147118c4eb..d1b79af5649a4 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -65,15 +65,16 @@ export class AbacService extends ServiceClass implements IAbacService { return; } - const existing = await AbacAttributes.findOne({ _id }, { projection: { key: 1, values: 1 } }); + const existing = await AbacAttributes.findOneById(_id, { projection: { key: 1, values: 1 } }); if (!existing) { throw new Error('error-attribute-not-found'); } - const keyPattern = /^[A-Za-z0-9_-]+$/; - if (update.key && !keyPattern.test(update.key)) { - throw new Error('error-invalid-attribute-key'); + const duplicated = update.key && (await AbacAttributes.findOne({ key: update.key, _id: { $ne: _id } }, { projection: { _id: 1 } })); + if (duplicated) { + throw new Error('error-duplicate-attribute-key'); } + if (update.values && !update.values.length) { throw new Error('error-invalid-attribute-values'); } From 76b52c4d3daae843c8d753d139df9c6f304aac10 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 7 Oct 2025 10:44:51 -0600 Subject: [PATCH 042/125] ts --- apps/meteor/tests/end-to-end/api/abac.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/apps/meteor/tests/end-to-end/api/abac.ts b/apps/meteor/tests/end-to-end/api/abac.ts index 5c5215ed784c7..cb309931ce2cd 100644 --- a/apps/meteor/tests/end-to-end/api/abac.ts +++ b/apps/meteor/tests/end-to-end/api/abac.ts @@ -408,7 +408,7 @@ import { IS_EE } from '../../e2e/config/constants'; }); describe('Extended Validations & Edge Cases', () => { - let firstAttributeId: string; + // eslint-disable-next-line @typescript-eslint/no-unused-vars let secondAttributeId: string; const firstKey = `${initialKey}_first`; const secondKey = `${initialKey}_second`; @@ -452,7 +452,6 @@ import { IS_EE } from '../../e2e/config/constants'; .expect((res) => { const found = res.body.attributes.find((a: any) => a.key === firstKey); expect(found).to.exist; - firstAttributeId = found._id; }); }); From 9d1dd36dbb56922d091905fb2407722037e2b0fc Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 7 Oct 2025 11:13:05 -0600 Subject: [PATCH 043/125] test --- ee/packages/abac/src/index.spec.ts | 35 ++++++++++++++++++++---------- ee/packages/abac/src/index.ts | 5 ----- 2 files changed, 24 insertions(+), 16 deletions(-) diff --git a/ee/packages/abac/src/index.spec.ts b/ee/packages/abac/src/index.spec.ts index 32546d7df8c5a..5227ac290d25e 100644 --- a/ee/packages/abac/src/index.spec.ts +++ b/ee/packages/abac/src/index.spec.ts @@ -30,6 +30,7 @@ jest.mock('@rocket.chat/models', () => ({ insertOne: (...args: any[]) => mockAbacInsertOne(...args), findPaginated: (...args: any[]) => mockAbacFindPaginated(...args), findOne: (...args: any[]) => mockAbacFindOne(...args), + findOneById: (...args: any[]) => mockAbacFindOne(...args), // map findOneById calls to same mock updateOne: (...args: any[]) => mockAbacUpdateOne(...args), deleteOne: (...args: any[]) => mockAbacDeleteOne(...args), find: (...args: any[]) => mockAbacFind(...args), @@ -57,10 +58,10 @@ describe('AbacService (unit)', () => { expect(mockAbacInsertOne).toHaveBeenCalledWith(attribute); }); - it('throws error-invalid-attribute-key for invalid key', async () => { + it('accepts key with spaces (no key pattern validation in service)', async () => { const attribute = { key: 'Invalid Key!', values: ['v1'] }; - await expect(service.addAbacAttribute(attribute as any)).rejects.toThrow('error-invalid-attribute-key'); - expect(mockAbacInsertOne).not.toHaveBeenCalled(); + await service.addAbacAttribute(attribute as any); + expect(mockAbacInsertOne).toHaveBeenCalledWith(attribute); }); it('throws error-invalid-attribute-values for empty values array', async () => { @@ -190,13 +191,17 @@ describe('AbacService (unit)', () => { it('throws error-attribute-not-found when attribute does not exist', async () => { mockAbacFindOne.mockResolvedValueOnce(null); await expect(service.updateAbacAttributeById('idMissing', { key: 'newKey' })).rejects.toThrow('error-attribute-not-found'); - expect(mockAbacFindOne).toHaveBeenCalledWith({ _id: 'idMissing' }, { projection: { key: 1, values: 1 } }); + expect(mockAbacFindOne).toHaveBeenCalledWith('idMissing', { projection: { key: 1, values: 1 } }); }); - it('throws error-invalid-attribute-key for invalid new key', async () => { - mockAbacFindOne.mockResolvedValueOnce({ _id: 'id2', key: 'OldKey', values: ['a'] }); - await expect(service.updateAbacAttributeById('id2', { key: 'Invalid Key!' })).rejects.toThrow('error-invalid-attribute-key'); - expect(mockAbacUpdateOne).not.toHaveBeenCalled(); + it('updates key even if format contains spaces (no validation in service)', async () => { + mockAbacFindOne + .mockResolvedValueOnce({ _id: 'id2', key: 'OldKey', values: ['a'] }) // findOneById + .mockResolvedValueOnce(null); // duplicate check + mockRoomsIsAbacAttributeInUse.mockResolvedValueOnce(false); + mockAbacUpdateOne.mockResolvedValueOnce({ modifiedCount: 1 }); + await service.updateAbacAttributeById('id2', { key: 'Invalid Key!' }); + expect(mockAbacUpdateOne).toHaveBeenCalledWith({ _id: 'id2' }, { $set: { key: 'Invalid Key!' } }); }); it('throws error-invalid-attribute-values for empty values array', async () => { @@ -215,7 +220,7 @@ describe('AbacService (unit)', () => { }); it('updates key when changed and not in use', async () => { - mockAbacFindOne.mockResolvedValueOnce({ _id: 'id5', key: 'Old', values: ['a'] }); + mockAbacFindOne.mockResolvedValueOnce({ _id: 'id5', key: 'Old', values: ['a'] }).mockResolvedValueOnce(null); mockRoomsIsAbacAttributeInUse.mockResolvedValueOnce(false); mockAbacUpdateOne.mockResolvedValueOnce({ modifiedCount: 1 }); await service.updateAbacAttributeById('id5', { key: 'NewKey' }); @@ -250,14 +255,14 @@ describe('AbacService (unit)', () => { }); it('throws error-duplicate-attribute-key on duplicate key error', async () => { - mockAbacFindOne.mockResolvedValueOnce({ _id: 'id9', key: 'Old', values: ['v'] }); + mockAbacFindOne.mockResolvedValueOnce({ _id: 'id9', key: 'Old', values: ['v'] }).mockResolvedValueOnce(null); mockRoomsIsAbacAttributeInUse.mockResolvedValueOnce(false); mockAbacUpdateOne.mockRejectedValueOnce(new Error('E11000 duplicate key error collection')); await expect(service.updateAbacAttributeById('id9', { key: 'NewKey' })).rejects.toThrow('error-duplicate-attribute-key'); }); it('propagates unexpected update errors', async () => { - mockAbacFindOne.mockResolvedValueOnce({ _id: 'id10', key: 'Old', values: ['v'] }); + mockAbacFindOne.mockResolvedValueOnce({ _id: 'id10', key: 'Old', values: ['v'] }).mockResolvedValueOnce(null); mockRoomsIsAbacAttributeInUse.mockResolvedValueOnce(false); mockAbacUpdateOne.mockRejectedValueOnce(new Error('write-failed')); await expect(service.updateAbacAttributeById('id10', { key: 'Another' })).rejects.toThrow('write-failed'); @@ -285,6 +290,10 @@ describe('AbacService (unit)', () => { }); }); describe('getAbacAttributeById', () => { + beforeEach(() => { + mockAbacFindOne.mockReset(); + mockRoomsIsAbacAttributeInUse.mockReset(); + }); it('throws error-attribute-not-found when attribute does not exist', async () => { mockAbacFindOne.mockResolvedValueOnce(null); await expect(service.getAbacAttributeById('missingAttr')).rejects.toThrow('error-attribute-not-found'); @@ -403,6 +412,10 @@ describe('AbacService (unit)', () => { }); describe('isAbacAttributeInUseByKey', () => { + beforeEach(() => { + mockAbacFindOne.mockReset(); + mockRoomsIsAbacAttributeInUse.mockReset(); + }); it('returns false when attribute does not exist', async () => { mockAbacFindOne.mockResolvedValueOnce(null); const result = await service.isAbacAttributeInUseByKey('missing'); diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index d1b79af5649a4..f3c8bab793799 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -8,11 +8,6 @@ export class AbacService extends ServiceClass implements IAbacService { protected name = 'abac'; async addAbacAttribute(attribute: IAbacAttributeDefinition): Promise { - const keyPattern = /^[A-Za-z0-9_-]+$/; - if (!keyPattern.test(attribute.key)) { - throw new Error('error-invalid-attribute-key'); - } - if (!attribute.values.length) { throw new Error('error-invalid-attribute-values'); } From 2d60839baf06a7b05084360e9a41dd8ff5c3f0fd Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 7 Oct 2025 12:37:15 -0600 Subject: [PATCH 044/125] test --- apps/meteor/tests/end-to-end/api/abac.ts | 2 + ee/packages/abac/src/index.spec.ts | 62 ++++++++++++++++++++---- 2 files changed, 54 insertions(+), 10 deletions(-) diff --git a/apps/meteor/tests/end-to-end/api/abac.ts b/apps/meteor/tests/end-to-end/api/abac.ts index cb309931ce2cd..deda7b721eeab 100644 --- a/apps/meteor/tests/end-to-end/api/abac.ts +++ b/apps/meteor/tests/end-to-end/api/abac.ts @@ -119,6 +119,8 @@ import { IS_EE } from '../../e2e/config/constants'; }); it('POST should fail creating duplicate key', async () => { + const response = await request.get(`${v1}/abac/attributes`).set(credentials).expect(200); + console.log(response.attributes); await request .post(`${v1}/abac/attributes`) .set(credentials) diff --git a/ee/packages/abac/src/index.spec.ts b/ee/packages/abac/src/index.spec.ts index 5227ac290d25e..6ae1a5730c989 100644 --- a/ee/packages/abac/src/index.spec.ts +++ b/ee/packages/abac/src/index.spec.ts @@ -181,6 +181,12 @@ describe('AbacService (unit)', () => { }); describe('updateAbacAttributeById', () => { + beforeEach(() => { + mockAbacFindOne.mockReset(); + mockAbacUpdateOne.mockReset(); + mockRoomsIsAbacAttributeInUse.mockReset(); + }); + it('returns early (no-op) when neither key nor values provided', async () => { await service.updateAbacAttributeById('id1', {} as any); expect(mockAbacFindOne).not.toHaveBeenCalled(); @@ -197,7 +203,7 @@ describe('AbacService (unit)', () => { it('updates key even if format contains spaces (no validation in service)', async () => { mockAbacFindOne .mockResolvedValueOnce({ _id: 'id2', key: 'OldKey', values: ['a'] }) // findOneById - .mockResolvedValueOnce(null); // duplicate check + .mockResolvedValueOnce(null); // duplicate key check mockRoomsIsAbacAttributeInUse.mockResolvedValueOnce(false); mockAbacUpdateOne.mockResolvedValueOnce({ modifiedCount: 1 }); await service.updateAbacAttributeById('id2', { key: 'Invalid Key!' }); @@ -205,22 +211,32 @@ describe('AbacService (unit)', () => { }); it('throws error-invalid-attribute-values for empty values array', async () => { + mockAbacFindOne.mockReset(); + mockRoomsIsAbacAttributeInUse.mockReset(); + mockAbacUpdateOne.mockReset(); mockAbacFindOne.mockResolvedValueOnce({ _id: 'id3', key: 'Key3', values: ['x'] }); await expect(service.updateAbacAttributeById('id3', { values: [] })).rejects.toThrow('error-invalid-attribute-values'); + expect(mockRoomsIsAbacAttributeInUse).not.toHaveBeenCalled(); expect(mockAbacUpdateOne).not.toHaveBeenCalled(); }); it('throws error-attribute-in-use when key changes and old definition is in use', async () => { - mockAbacFindOne.mockResolvedValueOnce({ _id: 'id4', key: 'Old', values: ['v1', 'v2'] }); + mockAbacFindOne.mockReset(); + mockRoomsIsAbacAttributeInUse.mockReset(); + mockAbacUpdateOne.mockReset(); + mockAbacFindOne + .mockResolvedValueOnce({ _id: 'id4', key: 'Old', values: ['v1', 'v2'] }) // findOneById + .mockResolvedValueOnce(null); // duplicate key check mockRoomsIsAbacAttributeInUse.mockResolvedValueOnce(true); await expect(service.updateAbacAttributeById('id4', { key: 'New' })).rejects.toThrow('error-attribute-in-use'); expect(mockRoomsIsAbacAttributeInUse).toHaveBeenCalledWith('Old', ['v1', 'v2']); - expect(mockAbacUpdateOne).not.toHaveBeenCalled(); }); it('updates key when changed and not in use', async () => { - mockAbacFindOne.mockResolvedValueOnce({ _id: 'id5', key: 'Old', values: ['a'] }).mockResolvedValueOnce(null); + mockAbacFindOne + .mockResolvedValueOnce({ _id: 'id5', key: 'Old', values: ['a'] }) // findOneById + .mockResolvedValueOnce(null); // duplicate key check mockRoomsIsAbacAttributeInUse.mockResolvedValueOnce(false); mockAbacUpdateOne.mockResolvedValueOnce({ modifiedCount: 1 }); await service.updateAbacAttributeById('id5', { key: 'NewKey' }); @@ -228,12 +244,13 @@ describe('AbacService (unit)', () => { }); it('throws error-attribute-in-use when removing a value that is in use', async () => { + mockAbacFindOne.mockReset(); + mockRoomsIsAbacAttributeInUse.mockReset(); + mockAbacUpdateOne.mockReset(); mockAbacFindOne.mockResolvedValueOnce({ _id: 'id6', key: 'Attr', values: ['a', 'b', 'c'] }); - // removedValues => ['b'] - mockRoomsIsAbacAttributeInUse.mockResolvedValueOnce(true); + mockRoomsIsAbacAttributeInUse.mockResolvedValueOnce(true); // removed value in use await expect(service.updateAbacAttributeById('id6', { values: ['a', 'c'] })).rejects.toThrow('error-attribute-in-use'); expect(mockRoomsIsAbacAttributeInUse).toHaveBeenCalledWith('Attr', ['b']); - expect(mockAbacUpdateOne).not.toHaveBeenCalled(); }); @@ -247,7 +264,6 @@ describe('AbacService (unit)', () => { it('updates values when only adding (no removal) without in-use check', async () => { mockAbacFindOne.mockResolvedValueOnce({ _id: 'id8', key: 'Attr', values: ['a'] }); - // newValues = ['a','b'] => removedValues = [] mockAbacUpdateOne.mockResolvedValueOnce({ modifiedCount: 1 }); await service.updateAbacAttributeById('id8', { values: ['a', 'b'] }); expect(mockRoomsIsAbacAttributeInUse).not.toHaveBeenCalled(); @@ -255,25 +271,48 @@ describe('AbacService (unit)', () => { }); it('throws error-duplicate-attribute-key on duplicate key error', async () => { - mockAbacFindOne.mockResolvedValueOnce({ _id: 'id9', key: 'Old', values: ['v'] }).mockResolvedValueOnce(null); + mockAbacFindOne.mockReset(); + mockRoomsIsAbacAttributeInUse.mockReset(); + mockAbacUpdateOne.mockReset(); + mockAbacFindOne + .mockResolvedValueOnce({ _id: 'id9', key: 'Old', values: ['v'] }) // findOneById + .mockResolvedValueOnce(null); // duplicate key check mockRoomsIsAbacAttributeInUse.mockResolvedValueOnce(false); mockAbacUpdateOne.mockRejectedValueOnce(new Error('E11000 duplicate key error collection')); await expect(service.updateAbacAttributeById('id9', { key: 'NewKey' })).rejects.toThrow('error-duplicate-attribute-key'); }); it('propagates unexpected update errors', async () => { - mockAbacFindOne.mockResolvedValueOnce({ _id: 'id10', key: 'Old', values: ['v'] }).mockResolvedValueOnce(null); + mockAbacFindOne.mockReset(); + mockRoomsIsAbacAttributeInUse.mockReset(); + mockAbacUpdateOne.mockReset(); + mockAbacFindOne + .mockResolvedValueOnce({ _id: 'id10', key: 'Old', values: ['v'] }) // findOneById + .mockResolvedValueOnce(null); // duplicate key check mockRoomsIsAbacAttributeInUse.mockResolvedValueOnce(false); mockAbacUpdateOne.mockRejectedValueOnce(new Error('write-failed')); await expect(service.updateAbacAttributeById('id10', { key: 'Another' })).rejects.toThrow('write-failed'); }); + describe('deleteAbacAttributeById', () => { + beforeEach(() => { + mockAbacFindOne.mockReset(); + mockAbacDeleteOne.mockReset(); + mockRoomsIsAbacAttributeInUse.mockReset(); + }); + it('throws error-attribute-not-found when attribute does not exist', async () => { + mockAbacFindOne.mockReset(); + mockRoomsIsAbacAttributeInUse.mockReset(); + mockAbacDeleteOne.mockReset(); mockAbacFindOne.mockResolvedValueOnce(null); await expect(service.deleteAbacAttributeById('missing')).rejects.toThrow('error-attribute-not-found'); }); it('throws error-attribute-in-use when attribute is referenced by a room', async () => { + mockAbacFindOne.mockReset(); + mockRoomsIsAbacAttributeInUse.mockReset(); + mockAbacDeleteOne.mockReset(); mockAbacFindOne.mockResolvedValueOnce({ _id: 'id11', key: 'KeyInUse', values: ['a', 'b'] }); mockRoomsIsAbacAttributeInUse.mockResolvedValueOnce(true); await expect(service.deleteAbacAttributeById('id11')).rejects.toThrow('error-attribute-in-use'); @@ -281,6 +320,9 @@ describe('AbacService (unit)', () => { }); it('deletes attribute when not in use', async () => { + mockAbacFindOne.mockReset(); + mockRoomsIsAbacAttributeInUse.mockReset(); + mockAbacDeleteOne.mockReset(); mockAbacFindOne.mockResolvedValueOnce({ _id: 'id12', key: 'FreeKey', values: ['x'] }); mockRoomsIsAbacAttributeInUse.mockResolvedValueOnce(false); mockAbacDeleteOne.mockResolvedValueOnce({ deletedCount: 1 }); From 21fb93421cacdf88468efc2c4d305e84305b7cc9 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 7 Oct 2025 12:37:28 -0600 Subject: [PATCH 045/125] svc --- ee/packages/abac/src/index.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index f3c8bab793799..df3f11dcdae0c 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -65,11 +65,6 @@ export class AbacService extends ServiceClass implements IAbacService { throw new Error('error-attribute-not-found'); } - const duplicated = update.key && (await AbacAttributes.findOne({ key: update.key, _id: { $ne: _id } }, { projection: { _id: 1 } })); - if (duplicated) { - throw new Error('error-duplicate-attribute-key'); - } - if (update.values && !update.values.length) { throw new Error('error-invalid-attribute-values'); } From 5121bd94e46243a74fda18704b558c9bd9998467 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 7 Oct 2025 12:43:48 -0600 Subject: [PATCH 046/125] test --- apps/meteor/tests/end-to-end/api/abac.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/tests/end-to-end/api/abac.ts b/apps/meteor/tests/end-to-end/api/abac.ts index deda7b721eeab..82f05299840a8 100644 --- a/apps/meteor/tests/end-to-end/api/abac.ts +++ b/apps/meteor/tests/end-to-end/api/abac.ts @@ -120,7 +120,7 @@ import { IS_EE } from '../../e2e/config/constants'; it('POST should fail creating duplicate key', async () => { const response = await request.get(`${v1}/abac/attributes`).set(credentials).expect(200); - console.log(response.attributes); + console.log(response.body); await request .post(`${v1}/abac/attributes`) .set(credentials) From 152aa2706585917a51bfaa82b6168de336c951d6 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 7 Oct 2025 14:26:50 -0600 Subject: [PATCH 047/125] ? --- apps/meteor/ee/server/api/abac/index.ts | 5 +++++ apps/meteor/ee/server/api/abac/schemas.ts | 2 -- ee/packages/abac/src/index.ts | 5 +++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/apps/meteor/ee/server/api/abac/index.ts b/apps/meteor/ee/server/api/abac/index.ts index d4495fda755dc..32cf7ef5d9e45 100644 --- a/apps/meteor/ee/server/api/abac/index.ts +++ b/apps/meteor/ee/server/api/abac/index.ts @@ -296,3 +296,8 @@ const abacEndpoints = API.v1 ); export type AbacEndpoints = ExtractRoutesFromAPI; + +declare module '@rocket.chat/rest-typings' { + // eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-empty-interface + interface Endpoints extends AbacEndpoints {} +} diff --git a/apps/meteor/ee/server/api/abac/schemas.ts b/apps/meteor/ee/server/api/abac/schemas.ts index 3603987764290..e500a329a5f6d 100644 --- a/apps/meteor/ee/server/api/abac/schemas.ts +++ b/apps/meteor/ee/server/api/abac/schemas.ts @@ -1,8 +1,6 @@ import type { IAbacAttribute, IAbacAttributeDefinition } from '@rocket.chat/core-typings'; import Ajv from 'ajv'; -// Removed AbacEndpoints import to avoid circular type reference (endpoints import these schemas) - const ATTRIBUTE_KEY_PATTERN = '^[A-Za-z0-9_-]+$'; const MAX_ATTRIBUTE_VALUES = 10; const MAX_ROOM_ATTRIBUTE_VALUES = 10; diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index df3f11dcdae0c..2a98d60d50707 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -47,6 +47,11 @@ export class AbacService extends ServiceClass implements IAbacService { const attributes = await cursor.toArray(); + console.log(attributes, { + attributes, + offset, + count: attributes.length, + }); return { attributes, offset, From 5dac393ddd53e57e02e59b5103708a085a9d2cb8 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 7 Oct 2025 21:25:01 -0600 Subject: [PATCH 048/125] I'm dum dum --- apps/meteor/ee/server/startup/services.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/meteor/ee/server/startup/services.ts b/apps/meteor/ee/server/startup/services.ts index 5e33f9e17c771..a09d6c6f11b5c 100644 --- a/apps/meteor/ee/server/startup/services.ts +++ b/apps/meteor/ee/server/startup/services.ts @@ -17,9 +17,9 @@ api.registerService(new LicenseService()); api.registerService(new MessageReadsService()); api.registerService(new OmnichannelEE()); api.registerService(new VoipFreeSwitchService()); +api.registerService(new AbacService()); // when not running micro services we want to start up the instance intercom if (!isRunningMs()) { - api.registerService(new AbacService()); api.registerService(new InstanceService()); } From 0cd92a95da4de88b54e29fa389597363b4a31d78 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 8 Oct 2025 09:04:43 -0600 Subject: [PATCH 049/125] rollback this change --- packages/http-router/src/Router.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/http-router/src/Router.ts b/packages/http-router/src/Router.ts index 7585cd0090224..90887b9702b74 100644 --- a/packages/http-router/src/Router.ts +++ b/packages/http-router/src/Router.ts @@ -264,7 +264,6 @@ export class Router< method: req.method, path: req.url, error: responseValidatorFn.errors?.map((error: any) => error.message).join('\n '), - originalResponse: body, }); return c.json( { From f272b44c733e358e024c184749ef23f5354d5cbc Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Fri, 10 Oct 2025 11:07:14 -0600 Subject: [PATCH 050/125] fix: Pagination not working on `abac/attributes` endpoint (#37189) --- apps/meteor/ee/server/api/abac/index.ts | 4 +- apps/meteor/ee/server/api/abac/schemas.ts | 16 +++----- apps/meteor/tests/end-to-end/api/abac.ts | 50 +++++++++++++++++++---- ee/packages/abac/src/index.ts | 5 --- 4 files changed, 51 insertions(+), 24 deletions(-) diff --git a/apps/meteor/ee/server/api/abac/index.ts b/apps/meteor/ee/server/api/abac/index.ts index 32cf7ef5d9e45..2d5e0b7be23da 100644 --- a/apps/meteor/ee/server/api/abac/index.ts +++ b/apps/meteor/ee/server/api/abac/index.ts @@ -16,6 +16,7 @@ import { } from './schemas'; import { API } from '../../../../app/api/server'; import type { ExtractRoutesFromAPI } from '../../../../app/api/server/ApiClass'; +import { getPaginationItems } from '../../../../app/api/server/helpers/getPaginationItems'; import { settings } from '../../../../app/settings/server'; const abacEndpoints = API.v1 @@ -160,7 +161,8 @@ const abacEndpoints = API.v1 license: ['abac'], }, async function action() { - const { key, values, offset, count } = this.queryParams; + const { offset, count } = await getPaginationItems(this.queryParams as Record); + const { key, values } = this.queryParams; if (!settings.get('ABAC_Enabled')) { throw new Error('error-abac-not-enabled'); diff --git a/apps/meteor/ee/server/api/abac/schemas.ts b/apps/meteor/ee/server/api/abac/schemas.ts index e500a329a5f6d..4114aee43ba45 100644 --- a/apps/meteor/ee/server/api/abac/schemas.ts +++ b/apps/meteor/ee/server/api/abac/schemas.ts @@ -1,14 +1,10 @@ import type { IAbacAttribute, IAbacAttributeDefinition } from '@rocket.chat/core-typings'; -import Ajv from 'ajv'; +import { ajv } from '@rocket.chat/rest-typings'; const ATTRIBUTE_KEY_PATTERN = '^[A-Za-z0-9_-]+$'; const MAX_ATTRIBUTE_VALUES = 10; const MAX_ROOM_ATTRIBUTE_VALUES = 10; -const ajv = new Ajv({ - coerceTypes: true, -}); - const GenericSuccess = { type: 'object', properties: { @@ -68,8 +64,8 @@ const GetAbacAttributesQuery = { maxItems: MAX_ATTRIBUTE_VALUES, uniqueItems: true, }, - offset: { type: 'integer', minimum: 0, default: 0 }, - count: { type: 'integer', minimum: 1, maximum: 100, default: 25 }, + offset: { type: 'number' }, + count: { type: 'number' }, }, additionalProperties: false, }; @@ -103,9 +99,9 @@ const GetAbacAttributesResponse = { type: 'array', items: AbacAttributeRecord, }, - offset: { type: 'integer', minimum: 0 }, - count: { type: 'integer', minimum: 0 }, - total: { type: 'integer', minimum: 0 }, + offset: { type: 'number' }, + count: { type: 'number' }, + total: { type: 'number' }, }, required: ['attributes', 'offset', 'count', 'total'], additionalProperties: false, diff --git a/apps/meteor/tests/end-to-end/api/abac.ts b/apps/meteor/tests/end-to-end/api/abac.ts index 82f05299840a8..0c08da9317306 100644 --- a/apps/meteor/tests/end-to-end/api/abac.ts +++ b/apps/meteor/tests/end-to-end/api/abac.ts @@ -21,6 +21,8 @@ import { IS_EE } from '../../e2e/config/constants'; const updatedKey = `${initialKey}_renamed`; const anotherKey = `${initialKey}_another`; let attributeId: string; + let paginationBase: string; + let page1AttributeIds: string[] = []; before((done) => getCredentials(done)); @@ -147,14 +149,50 @@ import { IS_EE } from '../../e2e/config/constants'; }); }); - it('GET should fail when count > 100', async () => { + it('GET should paginate attributes (page 1)', async () => { + paginationBase = `pg_${Date.now()}`; + await Promise.all( + ['a', 'b', 'c'].map((suffix) => + request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: `${paginationBase}_${suffix}`, values: ['one'] }) + .expect(200), + ), + ); + await request .get(`${v1}/abac/attributes`) .set(credentials) - .query({ count: 101 }) - .expect(400) + .query({ count: 2, offset: 0 }) + .expect(200) .expect((res) => { - expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('offset', 0); + expect(res.body).to.have.property('count', 2); + expect(res.body).to.have.property('total').that.is.a('number').and.to.be.at.least(4); + expect(res.body).to.have.property('attributes').that.is.an('array').with.lengthOf(2); + page1AttributeIds = res.body.attributes.map((a: any) => a._id); + }); + }); + + it('GET should paginate attributes (page 2)', async () => { + await request + .get(`${v1}/abac/attributes`) + .set(credentials) + .query({ count: 2, offset: 2 }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('offset', 2); + expect(res.body).to.have.property('count').that.is.a('number'); + expect(res.body.count).to.be.at.most(2); + expect(res.body).to.have.property('total').that.is.a('number').and.to.be.at.least(4); + expect(res.body).to.have.property('attributes').that.is.an('array'); + const page2Ids = res.body.attributes.map((a: any) => a._id); + page2Ids.forEach((id: string) => { + expect(page1AttributeIds).to.not.include(id); + }); }); }); @@ -537,10 +575,6 @@ import { IS_EE } from '../../e2e/config/constants'; }); }); - it('GET attributes should fail with count=0 (schema min=1)', async () => { - await request.get(`${v1}/abac/attributes`).set(credentials).query({ count: 0 }).expect(400); - }); - it('GET attributes should fail with negative offset', async () => { await request.get(`${v1}/abac/attributes`).set(credentials).query({ offset: -1 }).expect(400); }); diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index 2a98d60d50707..df3f11dcdae0c 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -47,11 +47,6 @@ export class AbacService extends ServiceClass implements IAbacService { const attributes = await cursor.toArray(); - console.log(attributes, { - attributes, - offset, - count: attributes.length, - }); return { attributes, offset, From 31e87c3022f005af04d01ac90b34aa40dc587bc1 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 15 Oct 2025 09:07:58 -0600 Subject: [PATCH 051/125] fix: `abac/attributes` endpoint filtering (#37225) --- apps/meteor/ee/server/api/abac/schemas.ts | 10 ++------ apps/meteor/tests/end-to-end/api/abac.ts | 2 +- ee/packages/abac/package.json | 1 + ee/packages/abac/src/index.spec.ts | 15 +++++++----- ee/packages/abac/src/index.ts | 24 +++++++++++-------- .../core-services/src/types/IAbacService.ts | 2 +- yarn.lock | 8 +++++++ 7 files changed, 36 insertions(+), 26 deletions(-) diff --git a/apps/meteor/ee/server/api/abac/schemas.ts b/apps/meteor/ee/server/api/abac/schemas.ts index 4114aee43ba45..552f66df2f5d4 100644 --- a/apps/meteor/ee/server/api/abac/schemas.ts +++ b/apps/meteor/ee/server/api/abac/schemas.ts @@ -57,20 +57,14 @@ const GetAbacAttributesQuery = { type: 'object', properties: { key: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN }, - values: { - type: 'array', - items: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN }, - minItems: 1, - maxItems: MAX_ATTRIBUTE_VALUES, - uniqueItems: true, - }, + values: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN }, offset: { type: 'number' }, count: { type: 'number' }, }, additionalProperties: false, }; -export const GETAbacAttributesQuerySchema = ajv.compile<{ key: string; values: string[]; offset: number; count: number; total: number }>( +export const GETAbacAttributesQuerySchema = ajv.compile<{ key: string; values: string; offset: number; count: number }>( GetAbacAttributesQuery, ); diff --git a/apps/meteor/tests/end-to-end/api/abac.ts b/apps/meteor/tests/end-to-end/api/abac.ts index 0c08da9317306..0b506c6875649 100644 --- a/apps/meteor/tests/end-to-end/api/abac.ts +++ b/apps/meteor/tests/end-to-end/api/abac.ts @@ -595,7 +595,7 @@ import { IS_EE } from '../../e2e/config/constants'; await request .get(`${v1}/abac/attributes`) .set(credentials) - .query({ 'values[]': 'magenta' }) + .query({ values: 'magenta' }) .expect(200) .expect((res) => { expect(res.body.success).to.be.true; diff --git a/ee/packages/abac/package.json b/ee/packages/abac/package.json index b5aa912312490..cfe014db547a3 100644 --- a/ee/packages/abac/package.json +++ b/ee/packages/abac/package.json @@ -24,6 +24,7 @@ "@rocket.chat/core-services": "workspace:^", "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/models": "workspace:^", + "@rocket.chat/string-helpers": "^0.32.0", "mongodb": "6.10.0" }, "devDependencies": { diff --git a/ee/packages/abac/src/index.spec.ts b/ee/packages/abac/src/index.spec.ts index 6ae1a5730c989..aba43f4bf5c42 100644 --- a/ee/packages/abac/src/index.spec.ts +++ b/ee/packages/abac/src/index.spec.ts @@ -111,7 +111,10 @@ describe('AbacService (unit)', () => { }); const result = await service.listAbacAttributes({ key: 'FilterKey' }); - expect(mockAbacFindPaginated).toHaveBeenCalledWith({ key: 'FilterKey' }, { projection: { key: 1, values: 1 }, skip: 0, limit: 25 }); + expect(mockAbacFindPaginated).toHaveBeenCalledWith( + { $or: [{ key: /FilterKey/i }] }, + { projection: { key: 1, values: 1 }, skip: 0, limit: 25 }, + ); expect(result).toEqual({ attributes: docs, offset: 0, @@ -130,9 +133,9 @@ describe('AbacService (unit)', () => { totalCount: Promise.resolve(10), }); - const result = await service.listAbacAttributes({ values: ['n', 'z'], offset: 5, count: 2 }); + const result = await service.listAbacAttributes({ values: 'n,z', offset: 5, count: 2 }); expect(mockAbacFindPaginated).toHaveBeenCalledWith( - { values: { $in: ['n', 'z'] } }, + { $or: [{ values: /n,z/i }] }, { projection: { key: 1, values: 1 }, skip: 5, limit: 2 }, ); expect(result).toEqual({ @@ -150,9 +153,9 @@ describe('AbacService (unit)', () => { totalCount: Promise.resolve(docs.length), }); - const result = await service.listAbacAttributes({ key: 'gamma', values: ['q'] }); + const result = await service.listAbacAttributes({ key: 'gamma', values: 'q' }); expect(mockAbacFindPaginated).toHaveBeenCalledWith( - { key: 'gamma', values: { $in: ['q'] } }, + { $or: [{ key: /gamma/i }, { values: /q/i }] }, { projection: { key: 1, values: 1 }, skip: 0, limit: 25 }, ); expect(result).toEqual({ @@ -169,7 +172,7 @@ describe('AbacService (unit)', () => { totalCount: Promise.resolve(0), }); - const result = await service.listAbacAttributes({ key: 'nope', values: ['none'] }); + const result = await service.listAbacAttributes({ key: 'nope', values: 'none' }); expect(result).toEqual({ attributes: [], offset: 0, diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index df3f11dcdae0c..31d6642738c83 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -2,7 +2,8 @@ import { ServiceClass } from '@rocket.chat/core-services'; import type { IAbacService } from '@rocket.chat/core-services'; import type { IAbacAttribute, IAbacAttributeDefinition } from '@rocket.chat/core-typings'; import { Rooms, AbacAttributes } from '@rocket.chat/models'; -import type { Filter, UpdateFilter } from 'mongodb'; +import { escapeRegExp } from '@rocket.chat/string-helpers'; +import type { Document, UpdateFilter } from 'mongodb'; export class AbacService extends ServiceClass implements IAbacService { protected name = 'abac'; @@ -22,28 +23,31 @@ export class AbacService extends ServiceClass implements IAbacService { } } - async listAbacAttributes(filters?: { key?: string; values?: string[]; offset?: number; count?: number }): Promise<{ + async listAbacAttributes(filters?: { key?: string; values?: string; offset?: number; count?: number }): Promise<{ attributes: IAbacAttribute[]; offset: number; count: number; total: number; }> { - const query: Filter = {}; + const query: Document[] = []; if (filters?.key) { - query.key = filters.key; + query.push({ key: new RegExp(escapeRegExp(filters.key), 'i') }); } if (filters?.values?.length) { - query.values = { $in: filters.values }; + query.push({ values: new RegExp(escapeRegExp(filters.values), 'i') }); } const offset = filters?.offset ?? 0; const limit = filters?.count ?? 25; - const { cursor, totalCount } = AbacAttributes.findPaginated(query, { - projection: { key: 1, values: 1 }, - skip: offset, - limit, - }); + const { cursor, totalCount } = AbacAttributes.findPaginated( + { ...(query.length && { $or: query }) }, + { + projection: { key: 1, values: 1 }, + skip: offset, + limit, + }, + ); const attributes = await cursor.toArray(); diff --git a/packages/core-services/src/types/IAbacService.ts b/packages/core-services/src/types/IAbacService.ts index 85409d06fa9dd..357e9f55b1eab 100644 --- a/packages/core-services/src/types/IAbacService.ts +++ b/packages/core-services/src/types/IAbacService.ts @@ -4,7 +4,7 @@ export interface IAbacService { addAbacAttribute(attribute: IAbacAttributeDefinition): Promise; listAbacAttributes(filters?: { key?: string; - values?: string[]; + values?: string; offset?: number; count?: number; }): Promise<{ attributes: IAbacAttribute[]; offset: number; count: number; total: number }>; diff --git a/yarn.lock b/yarn.lock index c5bea5684d15b..0e1c49ed0269e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8083,6 +8083,7 @@ __metadata: "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/eslint-config": "workspace:^" "@rocket.chat/models": "workspace:^" + "@rocket.chat/string-helpers": "npm:^0.32.0" "@rocket.chat/tsconfig": "workspace:*" "@types/jest": "npm:~30.0.0" "@types/node": "npm:~22.16.1" @@ -10191,6 +10192,13 @@ __metadata: languageName: node linkType: hard +"@rocket.chat/string-helpers@npm:^0.32.0": + version: 0.32.0 + resolution: "@rocket.chat/string-helpers@npm:0.32.0" + checksum: 10/4f503a42e9a93ea9e6e85da320f125da5af5da1693e6782dee828ffc6c0d05e6ec76d75f7efc342d0738a4d37d67a729486a4d96b109642d70610660beb38e21 + languageName: node + linkType: hard + "@rocket.chat/styled@npm:^0.32.0, @rocket.chat/styled@npm:~0.32.0": version: 0.32.0 resolution: "@rocket.chat/styled@npm:0.32.0" From c4dea9a2dcf6049bcb10808af05539bf774f7d0d Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 15 Oct 2025 10:13:06 -0600 Subject: [PATCH 052/125] chore: Remove unused expect-error directives (#37234) --- .../message/toolbar/items/actions/ForwardMessageAction.tsx | 1 - .../client/components/message/toolbar/usePermalinkAction.ts | 1 - .../client/components/message/toolbar/useReplyInDMAction.ts | 1 - 3 files changed, 3 deletions(-) diff --git a/apps/meteor/client/components/message/toolbar/items/actions/ForwardMessageAction.tsx b/apps/meteor/client/components/message/toolbar/items/actions/ForwardMessageAction.tsx index 161edabde7dc8..64e11d578272c 100644 --- a/apps/meteor/client/components/message/toolbar/items/actions/ForwardMessageAction.tsx +++ b/apps/meteor/client/components/message/toolbar/items/actions/ForwardMessageAction.tsx @@ -18,7 +18,6 @@ const ForwardMessageAction = ({ message, room }: ForwardMessageActionProps) => { const { t } = useTranslation(); const encrypted = isE2EEMessage(message); - // @ts-expect-error to be implemented const isABACEnabled = !!room.abacAttributes; const getTitle = useMemo(() => { diff --git a/apps/meteor/client/components/message/toolbar/usePermalinkAction.ts b/apps/meteor/client/components/message/toolbar/usePermalinkAction.ts index f9b59abca1d8c..593fdb728bcff 100644 --- a/apps/meteor/client/components/message/toolbar/usePermalinkAction.ts +++ b/apps/meteor/client/components/message/toolbar/usePermalinkAction.ts @@ -16,7 +16,6 @@ export const usePermalinkAction = ( const dispatchToastMessage = useToastMessageDispatch(); - // @ts-expect-error - to be implemented const isABACEnabled = !!room.abacAttributes; const encrypted = isE2EEMessage(message); const tooltip = useMemo(() => { diff --git a/apps/meteor/client/components/message/toolbar/useReplyInDMAction.ts b/apps/meteor/client/components/message/toolbar/useReplyInDMAction.ts index 6bebb1d8c1729..588ab0a607624 100644 --- a/apps/meteor/client/components/message/toolbar/useReplyInDMAction.ts +++ b/apps/meteor/client/components/message/toolbar/useReplyInDMAction.ts @@ -16,7 +16,6 @@ export const useReplyInDMAction = ( const user = useUser(); const router = useRouter(); const encrypted = isE2EEMessage(message); - // @ts-expect-error - abacAttributes is not yet implemented in IRoom type const isABACEnabled = !!room.abacAttributes; const canCreateDM = usePermission('create-d'); const isLayoutEmbedded = useEmbeddedLayout(); From 14d059b452764b2e615e847636ca60abc57eaa37 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 15 Oct 2025 11:56:06 -0600 Subject: [PATCH 053/125] chore: Replace findOne with findOneById and use removeById (#37240) --- ee/packages/abac/src/index.spec.ts | 5 +++-- ee/packages/abac/src/index.ts | 11 +++++------ 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/ee/packages/abac/src/index.spec.ts b/ee/packages/abac/src/index.spec.ts index aba43f4bf5c42..81944d255c6f2 100644 --- a/ee/packages/abac/src/index.spec.ts +++ b/ee/packages/abac/src/index.spec.ts @@ -33,6 +33,7 @@ jest.mock('@rocket.chat/models', () => ({ findOneById: (...args: any[]) => mockAbacFindOne(...args), // map findOneById calls to same mock updateOne: (...args: any[]) => mockAbacUpdateOne(...args), deleteOne: (...args: any[]) => mockAbacDeleteOne(...args), + removeById: (...args: any[]) => mockAbacDeleteOne(...args), find: (...args: any[]) => mockAbacFind(...args), }, })); @@ -330,7 +331,7 @@ describe('AbacService (unit)', () => { mockRoomsIsAbacAttributeInUse.mockResolvedValueOnce(false); mockAbacDeleteOne.mockResolvedValueOnce({ deletedCount: 1 }); await service.deleteAbacAttributeById('id12'); - expect(mockAbacDeleteOne).toHaveBeenCalledWith({ _id: 'id12' }); + expect(mockAbacDeleteOne).toHaveBeenCalledWith('id12'); }); }); }); @@ -354,7 +355,7 @@ describe('AbacService (unit)', () => { .mockResolvedValueOnce(true); // c const result = await service.getAbacAttributeById('id13'); - expect(mockAbacFindOne).toHaveBeenCalledWith({ _id: 'id13' }, { projection: { key: 1, values: 1 } }); + expect(mockAbacFindOne).toHaveBeenCalledWith('id13', { projection: { key: 1, values: 1 } }); expect(mockRoomsIsAbacAttributeInUse).toHaveBeenNthCalledWith(1, 'Attr', ['a']); expect(mockRoomsIsAbacAttributeInUse).toHaveBeenNthCalledWith(2, 'Attr', ['b']); expect(mockRoomsIsAbacAttributeInUse).toHaveBeenNthCalledWith(3, 'Attr', ['c']); diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index 31d6642738c83..01343128c0775 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -111,7 +111,7 @@ export class AbacService extends ServiceClass implements IAbacService { } async deleteAbacAttributeById(_id: string): Promise { - const existing = await AbacAttributes.findOne({ _id }, { projection: { key: 1, values: 1 } }); + const existing = await AbacAttributes.findOneById(_id, { projection: { key: 1, values: 1 } }); if (!existing) { throw new Error('error-attribute-not-found'); } @@ -121,11 +121,11 @@ export class AbacService extends ServiceClass implements IAbacService { throw new Error('error-attribute-in-use'); } - await AbacAttributes.deleteOne({ _id }); + await AbacAttributes.removeById(_id); } async getAbacAttributeById(_id: string): Promise<{ key: string; values: string[]; usage: Record }> { - const attribute = await AbacAttributes.findOne({ _id }, { projection: { key: 1, values: 1 } }); + const attribute = await AbacAttributes.findOneById(_id, { projection: { key: 1, values: 1 } }); if (!attribute) { throw new Error('error-attribute-not-found'); } @@ -146,7 +146,7 @@ export class AbacService extends ServiceClass implements IAbacService { } async isAbacAttributeInUseByKey(key: string): Promise { - const attribute = await AbacAttributes.findOne({ key }, { projection: { values: 1 } }); + const attribute = await AbacAttributes.findOneById(key, { projection: { values: 1 } }); if (!attribute) { return false; } @@ -200,8 +200,7 @@ export class AbacService extends ServiceClass implements IAbacService { } const keys = normalized.map((a) => a.key); - const attributeDefinitionsCursor = AbacAttributes.find({ key: { $in: keys } }, { projection: { key: 1, values: 1 } }); - const attributeDefinitions = await attributeDefinitionsCursor.toArray(); + const attributeDefinitions = await AbacAttributes.find({ key: { $in: keys } }, { projection: { key: 1, values: 1 } }).toArray(); const definitionValuesMap = new Map>(attributeDefinitions.map((def: any) => [def.key, new Set(def.values)])); if (definitionValuesMap.size !== keys.length) { From 26e7ad7d42de35fb61207bfa211cce4f422a2ab5 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 15 Oct 2025 13:49:44 -0600 Subject: [PATCH 054/125] fix: FindOneById used where key was provided --- ee/packages/abac/src/index.ts | 2 +- packages/model-typings/src/models/IAbacAttributesModel.ts | 5 ++++- packages/models/src/models/AbacAttributes.ts | 6 +++++- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index 01343128c0775..e0b32c8ba144e 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -146,7 +146,7 @@ export class AbacService extends ServiceClass implements IAbacService { } async isAbacAttributeInUseByKey(key: string): Promise { - const attribute = await AbacAttributes.findOneById(key, { projection: { values: 1 } }); + const attribute = await AbacAttributes.findOneByKey(key, { projection: { values: 1 } }); if (!attribute) { return false; } diff --git a/packages/model-typings/src/models/IAbacAttributesModel.ts b/packages/model-typings/src/models/IAbacAttributesModel.ts index 0592d680a0dda..6e1ae5050ee59 100644 --- a/packages/model-typings/src/models/IAbacAttributesModel.ts +++ b/packages/model-typings/src/models/IAbacAttributesModel.ts @@ -1,6 +1,9 @@ import type { IAbacAttribute } from '@rocket.chat/core-typings'; +import type { FindOptions } from 'mongodb'; import type { IBaseModel } from './IBaseModel'; // eslint-disable-next-line @typescript-eslint/no-empty-interface -export interface IAbacAttributesModel extends IBaseModel {} +export interface IAbacAttributesModel extends IBaseModel { + findOneByKey(key: string, options?: FindOptions): Promise; +} diff --git a/packages/models/src/models/AbacAttributes.ts b/packages/models/src/models/AbacAttributes.ts index 12c14d3890f32..5ced8887ddfb0 100644 --- a/packages/models/src/models/AbacAttributes.ts +++ b/packages/models/src/models/AbacAttributes.ts @@ -1,5 +1,5 @@ import type { IAbacAttribute } from '@rocket.chat/core-typings'; -import type { Db, IndexDescription } from 'mongodb'; +import type { Db, FindOptions, IndexDescription } from 'mongodb'; import { BaseRaw } from './BaseRaw'; @@ -11,4 +11,8 @@ export class AbacAttributesRaw extends BaseRaw { protected modelIndexes(): IndexDescription[] { return [{ key: { key: 1 }, unique: true }, { key: { values: 1 } }]; } + + findOneByKey(key: string, options: FindOptions = {}): Promise { + return this.findOne({ key }, options); + } } From e6a602baaafc9e4c99975b571a0536ee7717313a Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 15 Oct 2025 14:20:41 -0600 Subject: [PATCH 055/125] fix: Unit tests --- ee/packages/abac/src/index.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/ee/packages/abac/src/index.spec.ts b/ee/packages/abac/src/index.spec.ts index 81944d255c6f2..e1c20ca579a14 100644 --- a/ee/packages/abac/src/index.spec.ts +++ b/ee/packages/abac/src/index.spec.ts @@ -31,6 +31,7 @@ jest.mock('@rocket.chat/models', () => ({ findPaginated: (...args: any[]) => mockAbacFindPaginated(...args), findOne: (...args: any[]) => mockAbacFindOne(...args), findOneById: (...args: any[]) => mockAbacFindOne(...args), // map findOneById calls to same mock + findOneByKey: (...args: any[]) => mockAbacFindOne(...args), // map findOneByKey to same mock updateOne: (...args: any[]) => mockAbacUpdateOne(...args), deleteOne: (...args: any[]) => mockAbacDeleteOne(...args), removeById: (...args: any[]) => mockAbacDeleteOne(...args), From acf89ea7dbce9703b0c115861db291c684d46d8b Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Fri, 17 Oct 2025 08:55:03 -0600 Subject: [PATCH 056/125] restore --- packages/http-router/src/Router.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/http-router/src/Router.ts b/packages/http-router/src/Router.ts index 90887b9702b74..7585cd0090224 100644 --- a/packages/http-router/src/Router.ts +++ b/packages/http-router/src/Router.ts @@ -264,6 +264,7 @@ export class Router< method: req.method, path: req.url, error: responseValidatorFn.errors?.map((error: any) => error.message).join('\n '), + originalResponse: body, }); return c.json( { From b6769c7a90d7ca4c07b5099c25cb0eec9d8268e9 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 20 Oct 2025 14:04:02 -0600 Subject: [PATCH 057/125] feat: Remove users from room when new attributes are added to the room (#37172) --- .../server/functions/removeUserFromRoom.ts | 68 +- .../dataExport/exportRoomMessagesToFile.ts | 3 + apps/meteor/server/services/room/service.ts | 17 +- ee/packages/abac/package.json | 80 +- ee/packages/abac/src/index.spec.ts | 52 +- ee/packages/abac/src/index.ts | 207 +++-- .../abac/src/user-auto-removal.spec.ts | 411 ++++++++++ .../src/definition/messages/MessageType.ts | 4 +- .../core-services/src/types/IRoomService.ts | 8 +- .../core-typings/src/IMessage/IMessage.ts | 1 + packages/i18n/src/locales/en.i18n.json | 1 + .../message-types/src/registrations/common.ts | 6 + yarn.lock | 734 +++++++++++++++++- 13 files changed, 1432 insertions(+), 160 deletions(-) create mode 100644 ee/packages/abac/src/user-auto-removal.spec.ts diff --git a/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts b/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts index 56cdd03a701d4..8480dcecdd9f2 100644 --- a/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts +++ b/apps/meteor/app/lib/server/functions/removeUserFromRoom.ts @@ -1,7 +1,7 @@ import { Apps, AppEvents } from '@rocket.chat/apps'; import { AppsEngineException } from '@rocket.chat/apps-engine/definition/exceptions'; import { Message, Team, Room } from '@rocket.chat/core-services'; -import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import type { IRoom, IUser, MessageTypesValues } from '@rocket.chat/core-typings'; import { Subscriptions, Rooms } from '@rocket.chat/models'; import { Meteor } from 'meteor/meteor'; @@ -72,26 +72,76 @@ export const performUserRemoval = async function (room: IRoom, user: IUser, opti * and triggering all standard callbacks. Used for local actions (UI or API) * that should propagate normally to federation and other subscribers. */ -export const removeUserFromRoom = async function (rid: string, user: IUser, options?: { byUser: IUser }): Promise { +export const removeUserFromRoom = async function ( + rid: string, + user: IUser, + options?: { byUser?: IUser; skipAppPreEvents?: boolean; customSystemMessage?: MessageTypesValues }, +): Promise { const room = await Rooms.findOneById(rid); if (!room) { return; } - try { - await Apps.self?.triggerEvent(AppEvents.IPreRoomUserLeave, room, user, options?.byUser); - } catch (error: any) { - if (error.name === AppsEngineException.name) { - throw new Meteor.Error('error-app-prevented', error.message); - } + // Rationale: for an abac room, we don't want apps to be able to prevent a user from leaving + if (!options?.skipAppPreEvents) { + try { + await Apps.self?.triggerEvent(AppEvents.IPreRoomUserLeave, room, user, options?.byUser); + } catch (error: any) { + if (error.name === AppsEngineException.name) { + throw new Meteor.Error('error-app-prevented', error.message); + } - throw error; + throw error; + } } await Room.beforeLeave(room); await performUserRemoval(room, user, options); + const subscription = await Subscriptions.findOneByRoomIdAndUserId(rid, user._id, { + projection: { _id: 1 }, + }); + + if (subscription) { + const removedUser = user; + if (options?.customSystemMessage) { + await Message.saveSystemMessage(options?.customSystemMessage, rid, user.username || '', user); + } else if (options?.byUser) { + const extraData = { + u: options.byUser, + }; + + if (room.teamMain) { + await Message.saveSystemMessage('removed-user-from-team', rid, user.username || '', user, extraData); + } else { + await Message.saveSystemMessage('ru', rid, user.username || '', user, extraData); + } + } else if (room.teamMain) { + await Message.saveSystemMessage('ult', rid, removedUser.username || '', removedUser); + } else { + await Message.saveSystemMessage('ul', rid, removedUser.username || '', removedUser); + } + } + + if (room.t === 'l') { + await Message.saveSystemMessage('command', rid, 'survey', user); + } + + const deletedSubscription = await Subscriptions.removeByRoomIdAndUserId(rid, user._id); + if (deletedSubscription) { + void notifyOnSubscriptionChanged(deletedSubscription, 'removed'); + } + + if (room.teamId && room.teamMain) { + await Team.removeMember(room.teamId, user._id); + } + + if (room.encrypted && settings.get('E2E_Enable')) { + await Rooms.removeUsersFromE2EEQueueByRoomId(room._id, [user._id]); + } + + // TODO: CACHE: maybe a queue? await afterLeaveRoomCallback.run({ user, kicker: options?.byUser }, room); await Apps.self?.triggerEvent(AppEvents.IPostRoomUserLeave, room, user, options?.byUser); diff --git a/apps/meteor/server/lib/dataExport/exportRoomMessagesToFile.ts b/apps/meteor/server/lib/dataExport/exportRoomMessagesToFile.ts index c4791b7e80aca..9dff1771cc877 100644 --- a/apps/meteor/server/lib/dataExport/exportRoomMessagesToFile.ts +++ b/apps/meteor/server/lib/dataExport/exportRoomMessagesToFile.ts @@ -155,6 +155,9 @@ export const getMessageData = ( case 'livechat-started': messageObject.msg = i18n.t('Chat_started'); break; + case 'abac-removed-user-from-room': + messageObject.msg = i18n.t('abac_removed_user_from_the_room'); + break; } return messageObject; diff --git a/apps/meteor/server/services/room/service.ts b/apps/meteor/server/services/room/service.ts index b32abc80edb3d..481de2a38f22e 100644 --- a/apps/meteor/server/services/room/service.ts +++ b/apps/meteor/server/services/room/service.ts @@ -1,7 +1,14 @@ import { ServiceClassInternal, Authorization, Message, MeteorError } from '@rocket.chat/core-services'; import type { ICreateRoomParams, IRoomService } from '@rocket.chat/core-services'; -import { isOmnichannelRoom, isRoomWithJoinCode } from '@rocket.chat/core-typings'; -import type { ISubscription, AtLeast, IRoom, IUser } from '@rocket.chat/core-typings'; +import { + type AtLeast, + type IRoom, + type IUser, + type MessageTypesValues, + type ISubscription, + isOmnichannelRoom, + isRoomWithJoinCode, +} from '@rocket.chat/core-typings'; import { Rooms, Subscriptions, Users } from '@rocket.chat/models'; import { FederationActions } from './hooks/BeforeFederationActions'; @@ -82,7 +89,11 @@ export class RoomService extends ServiceClassInternal implements IRoomService { return addUserToRoom(roomId, user, inviter, options); } - async removeUserFromRoom(roomId: string, user: IUser, options?: { byUser: IUser }): Promise { + async removeUserFromRoom( + roomId: string, + user: IUser, + options?: { byUser?: IUser; skipAppPreEvents?: boolean; customSystemMessage?: MessageTypesValues }, + ): Promise { return removeUserFromRoom(roomId, user, options); } diff --git a/ee/packages/abac/package.json b/ee/packages/abac/package.json index cfe014db547a3..a9629ff00b994 100644 --- a/ee/packages/abac/package.json +++ b/ee/packages/abac/package.json @@ -1,43 +1,41 @@ { - "name": "@rocket.chat/abac", - "version": "0.0.1", - "private": true, - "description": "Rocket.Chat Enterprise - Attribute Based Access Control (ABAC) support utilities", - "main": "./dist/index.js", - "typings": "./dist/index.d.ts", - "files": [ - "/dist" - ], - "scripts": { - "build": "tsc", - "dev": "tsc --watch --preserveWatchOutput", - "lint": "eslint --ext .js,.jsx,.ts,.tsx src", - "lint:fix": "eslint --ext .js,.jsx,.ts,.tsx src --fix", - "test": "jest", - "testunit": "jest", - "typecheck": "tsc --noEmit --skipLibCheck" - }, - "volta": { - "extends": "../../../package.json" - }, - "dependencies": { - "@rocket.chat/core-services": "workspace:^", - "@rocket.chat/core-typings": "workspace:^", - "@rocket.chat/models": "workspace:^", - "@rocket.chat/string-helpers": "^0.32.0", - "mongodb": "6.10.0" - }, - "devDependencies": { - "@rocket.chat/eslint-config": "workspace:^", - "@rocket.chat/tsconfig": "workspace:*", - "@types/jest": "~30.0.0", - "@types/node": "~22.16.1", - "eslint": "~8.45.0", - "jest": "~30.0.5", - "typescript": "~5.9.2" - }, - "engines": { - "node": ">=18.0.0" - }, - "license": "SEE LICENSE IN ../../../../LICENSE" + "name": "@rocket.chat/abac", + "version": "0.0.1", + "private": true, + "description": "Rocket.Chat - Attribute Based Access Control (ABAC) support utilities", + "main": "./dist/index.js", + "typings": "./dist/index.d.ts", + "files": [ + "/dist" + ], + "scripts": { + "build": "tsc", + "dev": "tsc --watch --preserveWatchOutput", + "lint": "eslint --ext .js,.jsx,.ts,.tsx src", + "lint:fix": "eslint --ext .js,.jsx,.ts,.tsx src --fix", + "test": "jest", + "testunit": "jest", + "typecheck": "tsc --noEmit --skipLibCheck" + }, + "volta": { + "extends": "../../../package.json" + }, + "dependencies": { + "@rocket.chat/core-services": "workspace:^", + "@rocket.chat/core-typings": "workspace:^", + "@rocket.chat/logger": "workspace:^", + "@rocket.chat/models": "workspace:^", + "mongodb": "6.10.0", + "p-limit": "3.1.0" + }, + "devDependencies": { + "@rocket.chat/eslint-config": "workspace:^", + "@rocket.chat/tsconfig": "workspace:*", + "@types/jest": "~30.0.0", + "@types/node": "~22.16.1", + "eslint": "~8.45.0", + "jest": "~30.0.5", + "mongodb-memory-server": "^10.1.4", + "typescript": "~5.9.2" + } } diff --git a/ee/packages/abac/src/index.spec.ts b/ee/packages/abac/src/index.spec.ts index e1c20ca579a14..f16a1c83bf222 100644 --- a/ee/packages/abac/src/index.spec.ts +++ b/ee/packages/abac/src/index.spec.ts @@ -418,7 +418,7 @@ describe('AbacService (unit)', () => { expect(mockSetAbacAttributesById).not.toHaveBeenCalled(); }); - it('accepts duplicate values unchanged and sets attributes', async () => { + it('sets attributes for new key (with duplicate values) and calls hook', async () => { mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [] }); mockAbacFind.mockReturnValueOnce({ toArray: async () => [{ key: 'dept', values: ['eng', 'sales'] }], @@ -427,10 +427,12 @@ describe('AbacService (unit)', () => { await service.setRoomAbacAttributes('r1', { dept: ['eng', 'eng', 'sales'] }); expect(mockSetAbacAttributesById).toHaveBeenCalledWith('r1', [{ key: 'dept', values: ['eng', 'eng', 'sales'] }]); - expect((service as any).onRoomAttributesChanged).not.toHaveBeenCalled(); + expect((service as any).onRoomAttributesChanged).toHaveBeenCalledWith(expect.objectContaining({ _id: 'r1' }), [ + { key: 'dept', values: ['eng', 'eng', 'sales'] }, + ]); }); - it('calls onRoomAttributesChanged when an existing value is removed', async () => { + it('does not call onRoomAttributesChanged when an existing value is removed', async () => { const existing = [{ key: 'dept', values: ['eng', 'sales'] }]; mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: existing }); mockAbacFind.mockReturnValueOnce({ @@ -439,11 +441,11 @@ describe('AbacService (unit)', () => { await service.setRoomAbacAttributes('r1', { dept: ['eng'] }); // removing 'sales' - expect((service as any).onRoomAttributesChanged).toHaveBeenCalledWith('r1', [{ key: 'dept', values: ['eng'] }]); + expect((service as any).onRoomAttributesChanged).not.toHaveBeenCalled(); expect(mockSetAbacAttributesById).toHaveBeenCalledWith('r1', [{ key: 'dept', values: ['eng'] }]); }); - it('does not call onRoomAttributesChanged when only adding values', async () => { + it('calls onRoomAttributesChanged when adding values to an existing attribute', async () => { const existing = [{ key: 'dept', values: ['eng'] }]; mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: existing }); mockAbacFind.mockReturnValueOnce({ @@ -452,7 +454,9 @@ describe('AbacService (unit)', () => { await service.setRoomAbacAttributes('r1', { dept: ['eng', 'sales'] }); // adding sales - expect((service as any).onRoomAttributesChanged).not.toHaveBeenCalled(); + expect((service as any).onRoomAttributesChanged).toHaveBeenCalledWith(expect.objectContaining({ _id: 'r1' }), [ + { key: 'dept', values: ['eng', 'sales'] }, + ]); expect(mockSetAbacAttributesById).toHaveBeenCalledWith('r1', [{ key: 'dept', values: ['eng', 'sales'] }]); }); }); @@ -531,18 +535,21 @@ describe('AbacService (unit)', () => { expect((service as any).onRoomAttributesChanged).not.toHaveBeenCalled(); }); - it('updates existing key (addition only) without triggering removal hook', async () => { + it('updates existing key (addition only) and triggers hook when a value is added', async () => { mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [{ key: 'dept', values: ['eng'] }] }); await service.updateRoomAbacAttributeValues('r1', 'dept', ['eng', 'sales']); expect(mockUpdateAbacAttributeValuesArrayFilteredById).toHaveBeenCalledWith('r1', 'dept', ['eng', 'sales']); - expect((service as any).onRoomAttributesChanged).not.toHaveBeenCalled(); + expect((service as any).onRoomAttributesChanged).toHaveBeenCalledWith(expect.objectContaining({ _id: 'r1' }), [ + { key: 'dept', values: ['eng', 'sales'] }, + ]); }); - it('updates existing key and triggers hook when a value is removed', async () => { + it('updates existing key and does NOT trigger hook when a value is removed', async () => { + // Existing attribute loses one value; hook should NOT fire per new behavior mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [{ key: 'dept', values: ['eng', 'sales'] }] }); await service.updateRoomAbacAttributeValues('r1', 'dept', ['eng']); expect(mockUpdateAbacAttributeValuesArrayFilteredById).toHaveBeenCalledWith('r1', 'dept', ['eng']); - expect((service as any).onRoomAttributesChanged).toHaveBeenCalledWith('r1', [{ key: 'dept', values: ['eng'] }]); + expect((service as any).onRoomAttributesChanged).not.toHaveBeenCalled(); }); it('validates against global definitions (invalid value)', async () => { @@ -572,7 +579,8 @@ describe('AbacService (unit)', () => { expect((service as any).onRoomAttributesChanged).not.toHaveBeenCalled(); }); - it('removes attribute and calls hook when key exists', async () => { + it('removes attribute and does NOT call hook when key exists', async () => { + // Removing an entire attribute should not trigger the hook anymore const existing = [ { key: 'dept', values: ['eng', 'sales'] }, { key: 'other', values: ['x'] }, @@ -580,7 +588,7 @@ describe('AbacService (unit)', () => { mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: existing }); await (service as any).removeRoomAbacAttribute('r1', 'dept'); expect(mockRemoveAbacAttributeByRoomIdAndKey).toHaveBeenCalledWith('r1', 'dept'); - expect((service as any).onRoomAttributesChanged).toHaveBeenCalledWith('r1', [{ key: 'other', values: ['x'] }]); + expect((service as any).onRoomAttributesChanged).not.toHaveBeenCalled(); }); describe('replaceRoomAbacAttributeByKey', () => { @@ -625,7 +633,10 @@ describe('AbacService (unit)', () => { expect(mockInsertAbacAttributeIfNotExistsById).toHaveBeenCalledWith('r1', 'dept', ['eng']); expect(mockUpdateAbacAttributeValuesArrayFilteredById).not.toHaveBeenCalled(); - expect((service as any).onRoomAttributesChanged).toHaveBeenCalledWith('r1', updatedDoc.abacAttributes); + expect((service as any).onRoomAttributesChanged).toHaveBeenCalledWith( + expect.objectContaining({ _id: 'r1' }), + updatedDoc.abacAttributes, + ); }); it('replaces existing key (calls update and hook)', async () => { @@ -638,7 +649,10 @@ describe('AbacService (unit)', () => { expect(mockUpdateAbacAttributeValuesArrayFilteredById).toHaveBeenCalledWith('r1', 'dept', ['eng', 'sales']); expect(mockInsertAbacAttributeIfNotExistsById).not.toHaveBeenCalled(); - expect((service as any).onRoomAttributesChanged).toHaveBeenCalledWith('r1', updatedDoc.abacAttributes); + expect((service as any).onRoomAttributesChanged).toHaveBeenCalledWith( + expect.objectContaining({ _id: 'r1' }), + updatedDoc.abacAttributes, + ); }); it('validates definitions and rejects invalid value', async () => { @@ -706,7 +720,10 @@ describe('AbacService (unit)', () => { await service.addRoomAbacAttributeByKey('r1', 'dept', ['eng', 'sales']); expect(mockInsertAbacAttributeIfNotExistsById).toHaveBeenCalledWith('r1', 'dept', ['eng', 'sales']); - expect((service as any).onRoomAttributesChanged).toHaveBeenCalledWith('r1', updatedDoc.abacAttributes); + expect((service as any).onRoomAttributesChanged).toHaveBeenCalledWith( + expect.objectContaining({ _id: 'r1' }), + updatedDoc.abacAttributes, + ); }); it('inserts new attribute and calls hook with constructed list when DB returns undefined', async () => { @@ -718,7 +735,10 @@ describe('AbacService (unit)', () => { await service.addRoomAbacAttributeByKey('r1', 'dept', ['eng']); expect(mockInsertAbacAttributeIfNotExistsById).toHaveBeenCalledWith('r1', 'dept', ['eng']); - expect((service as any).onRoomAttributesChanged).toHaveBeenCalledWith('r1', [...existing, { key: 'dept', values: ['eng'] }]); + expect((service as any).onRoomAttributesChanged).toHaveBeenCalledWith(expect.objectContaining({ _id: 'r1' }), [ + ...existing, + { key: 'dept', values: ['eng'] }, + ]); }); it('rejects when provided value not allowed by definition', async () => { diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index e0b32c8ba144e..3106bdc713cec 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -1,13 +1,25 @@ -import { ServiceClass } from '@rocket.chat/core-services'; +import { Room, ServiceClass } from '@rocket.chat/core-services'; import type { IAbacService } from '@rocket.chat/core-services'; -import type { IAbacAttribute, IAbacAttributeDefinition } from '@rocket.chat/core-typings'; -import { Rooms, AbacAttributes } from '@rocket.chat/models'; +import type { IAbacAttribute, IAbacAttributeDefinition, IRoom, AtLeast } from '@rocket.chat/core-typings'; +import { Logger } from '@rocket.chat/logger'; +import { Rooms, AbacAttributes, Users } from '@rocket.chat/models'; import { escapeRegExp } from '@rocket.chat/string-helpers'; import type { Document, UpdateFilter } from 'mongodb'; +import pLimit from 'p-limit'; + +// 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; + + constructor() { + super(); + this.logger = new Logger('AbacService'); + } + async addAbacAttribute(attribute: IAbacAttributeDefinition): Promise { if (!attribute.values.length) { throw new Error('error-invalid-attribute-values'); @@ -102,6 +114,10 @@ export class AbacService extends ServiceClass implements IAbacService { try { await AbacAttributes.updateOne({ _id }, { $set: modifier }); + this.logger.debug({ + msg: 'Abac attribute updated', + ...update, + }); } catch (e) { if (e instanceof Error && e.message.includes('E11000')) { throw new Error('error-duplicate-attribute-key'); @@ -154,7 +170,9 @@ export class AbacService extends ServiceClass implements IAbacService { } async setRoomAbacAttributes(rid: string, attributes: Record): Promise { - const room = await Rooms.findOneByIdAndType(rid, 'p', { projection: { abacAttributes: 1 } }); + const room = await Rooms.findOneByIdAndType>(rid, 'p', { + projection: { abacAttributes: 1, t: 1, teamMain: 1 }, + }); if (!room) { throw new Error('error-room-not-found'); } @@ -163,14 +181,35 @@ export class AbacService extends ServiceClass implements IAbacService { await this.ensureAttributeDefinitionsExist(normalized); - const previous: IAbacAttributeDefinition[] = room.abacAttributes || []; - const removed = this.computeAttributesRemoval(previous, normalized); - const updated = await Rooms.setAbacAttributesById(rid, normalized); - if (removed) { - await this.onRoomAttributesChanged(rid, (updated?.abacAttributes as IAbacAttributeDefinition[] | undefined) ?? normalized); + const previous: IAbacAttributeDefinition[] = room.abacAttributes || []; + if (this.didAttributesChange(previous, normalized)) { + await this.onRoomAttributesChanged(room, (updated?.abacAttributes as IAbacAttributeDefinition[] | undefined) ?? normalized); + } + } + + private didAttributesChange(current: IAbacAttributeDefinition[], next: IAbacAttributeDefinition[]) { + let added = false; + const prevMap = new Map(current.map((a) => [a.key, new Set(a.values)])); + for (const { key, values } of next) { + const prevValues = prevMap.get(key); + if (!prevValues) { + added = true; + break; + } + for (const v of values) { + if (!prevValues.has(v)) { + added = true; + break; + } + } + if (added) { + break; + } } + + return added; } private validateAndNormalizeAttributes(attributes: Record): IAbacAttributeDefinition[] { @@ -220,30 +259,10 @@ export class AbacService extends ServiceClass implements IAbacService { } } - private computeAttributesRemoval(previous: IAbacAttributeDefinition[], next: IAbacAttributeDefinition[]): boolean { - if (!next.length) { - return previous.length > 0; - } - - const newMap = new Map>(next.map((a) => [a.key, new Set(a.values)])); - - for (const prev of previous) { - const current = newMap.get(prev.key); - if (!current) { - return true; - } - for (const val of prev.values) { - if (!current.has(val)) { - return true; - } - } - } - - return false; - } - async updateRoomAbacAttributeValues(rid: string, key: string, values: string[]): Promise { - const room = await Rooms.findOneByIdAndType(rid, 'p', { projection: { abacAttributes: 1 } }); + const room = await Rooms.findOneByIdAndType>(rid, 'p', { + projection: { abacAttributes: 1, t: 1, teamMain: 1 }, + }); if (!room) { throw new Error('error-room-not-found'); } @@ -259,6 +278,9 @@ export class AbacService extends ServiceClass implements IAbacService { if (isNewKey) { await Rooms.updateSingleAbacAttributeValuesById(rid, key, values); + const next = [...previous, { key, values }]; + + await this.onRoomAttributesChanged(room, next); return; } @@ -268,19 +290,21 @@ export class AbacService extends ServiceClass implements IAbacService { return; } - const valuesSet = new Set(values); - const removed = prevValues.some((v) => !valuesSet.has(v)); - await Rooms.updateAbacAttributeValuesArrayFilteredById(rid, key, values); - if (removed) { + if (this.wereAttributeValuesAdded(prevValues, values)) { const next = previous.map((a, i) => (i === existingIndex ? { key, values } : a)); - await this.onRoomAttributesChanged(rid, next); + await this.onRoomAttributesChanged(room, next); } } + private wereAttributeValuesAdded(prevValues: string[], newValues: string[]) { + const prevSet = new Set(prevValues); + return newValues.some((v) => !prevSet.has(v)); + } + async removeRoomAbacAttribute(rid: string, key: string): Promise { - const room = await Rooms.findOneByIdAndType(rid, 'p', { projection: { abacAttributes: 1 } }); + const room = await Rooms.findOneByIdAndType>(rid, 'p', { projection: { abacAttributes: 1 } }); if (!room) { throw new Error('error-room-not-found'); } @@ -291,17 +315,20 @@ export class AbacService extends ServiceClass implements IAbacService { return; } - const next = previous.filter((a) => a.key !== key); - await Rooms.removeAbacAttributeByRoomIdAndKey(rid, key); - - await this.onRoomAttributesChanged(rid, next); + this.logger.debug({ + msg: 'Room ABAC attribute removed', + rid, + key, + }); } async addRoomAbacAttributeByKey(rid: string, key: string, values: string[]): Promise { await this.ensureAttributeDefinitionsExist([{ key, values }]); - const room = await Rooms.findOneByIdAndType(rid, 'p', { projection: { abacAttributes: 1 } }); + const room = await Rooms.findOneByIdAndType>(rid, 'p', { + projection: { abacAttributes: 1, t: 1, teamMain: 1 }, + }); if (!room) { throw new Error('error-room-not-found'); } @@ -316,13 +343,17 @@ export class AbacService extends ServiceClass implements IAbacService { } const updated = await Rooms.insertAbacAttributeIfNotExistsById(rid, key, values); - await this.onRoomAttributesChanged(rid, updated?.abacAttributes || [...previous, { key, values }]); + const next = updated?.abacAttributes || [...previous, { key, values }]; + + await this.onRoomAttributesChanged(room, next); } async replaceRoomAbacAttributeByKey(rid: string, key: string, values: string[]): Promise { await this.ensureAttributeDefinitionsExist([{ key, values }]); - const room = await Rooms.findOneByIdAndType(rid, 'p', { projection: { abacAttributes: 1 } }); + const room = await Rooms.findOneByIdAndType>(rid, 'p', { + projection: { abacAttributes: 1, t: 1, teamMain: 1 }, + }); if (!room) { throw new Error('error-room-not-found'); } @@ -331,8 +362,12 @@ export class AbacService extends ServiceClass implements IAbacService { if (exists) { const updated = await Rooms.updateAbacAttributeValuesArrayFilteredById(rid, key, values); + const prevValues = room.abacAttributes?.find((a) => a.key === key)?.values ?? []; + + if (this.wereAttributeValuesAdded(prevValues, values)) { + await this.onRoomAttributesChanged(room, updated?.abacAttributes || []); + } - await this.onRoomAttributesChanged(rid, updated?.abacAttributes || []); return; } @@ -341,11 +376,85 @@ export class AbacService extends ServiceClass implements IAbacService { } const updated = await Rooms.insertAbacAttributeIfNotExistsById(rid, key, values); - await this.onRoomAttributesChanged(rid, updated?.abacAttributes || []); + + await this.onRoomAttributesChanged(room, updated?.abacAttributes || []); + } + + private buildNonCompliantConditions(newAttributes: IAbacAttributeDefinition[]) { + return newAttributes.map(({ key, values }) => ({ + abacAttributes: { + $not: { + $elemMatch: { + key, + values: { $all: values }, + }, + }, + }, + })); } - protected async onRoomAttributesChanged(_rid: string, _newAttributes: IAbacAttributeDefinition[]): Promise { - // Intentionally left blank for adding code later. For now, its no-op + protected async onRoomAttributesChanged( + room: AtLeast, + newAttributes: IAbacAttributeDefinition[], + ): Promise { + 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({ + msg: 'Room ABAC attributes removed. Room is not abac managed anymore', + rid, + }); + + return; + } + + try { + const nonCompliantConditions = this.buildNonCompliantConditions(newAttributes); + + if (!nonCompliantConditions.length) { + return; + } + + const query = { + __rooms: rid, + $or: nonCompliantConditions, + }; + + 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(() => + Room.removeUserFromRoom(rid, doc, { + skipAppPreEvents: true, + customSystemMessage: 'abac-removed-user-from-room' as const, + }), + ), + ); + } + + this.logger.debug({ + msg: 'Room ABAC attributes changed', + rid, + newAttributes, + usersToRemove, + }); + + if (!usersToRemove.length) { + return; + } + + await Promise.all(userRemovalPromises); + } catch (err) { + this.logger.error({ + msg: 'Failed to re-evaluate room subscriptions after ABAC attributes changed', + rid, + err, + }); + } } } diff --git a/ee/packages/abac/src/user-auto-removal.spec.ts b/ee/packages/abac/src/user-auto-removal.spec.ts new file mode 100644 index 0000000000000..73d6b569d8b47 --- /dev/null +++ b/ee/packages/abac/src/user-auto-removal.spec.ts @@ -0,0 +1,411 @@ +import type { IAbacAttributeDefinition, IRoom, IUser } from '@rocket.chat/core-typings'; +import { registerServiceModels } from '@rocket.chat/models'; +import type { Collection, Db } from 'mongodb'; +import { MongoClient } from 'mongodb'; +import { MongoMemoryServer } from 'mongodb-memory-server'; + +import { AbacService } from './index'; + +jest.mock('@rocket.chat/core-services', () => ({ + ServiceClass: class {}, + Room: { + // Mimic the DB side-effects of removing a user from a room (no apps/system messages) + removeUserFromRoom: async (roomId: string, user: any) => { + const { Subscriptions } = await import('@rocket.chat/models'); + await Subscriptions.removeByRoomIdAndUserId(roomId, user._id); + }, + }, +})); + +describe('AbacService integration (onRoomAttributesChanged)', () => { + let mongo: MongoMemoryServer; + let client: MongoClient; + let db: Db; + let service: AbacService; + + let roomsCol: Collection; + let usersCol: Collection; + let defsCol: Collection; + const rid = 'r1'; + + const insertDefinitions = async (defs: { key: string; values: string[] }[]) => { + const svc = new AbacService(); + await Promise.all( + defs.map((def) => + svc.addAbacAttribute({ key: def.key, values: def.values }).catch((e: any) => { + if (e instanceof Error && e.message === 'error-duplicate-attribute-key') { + return; + } + throw e; + }), + ), + ); + }; + + const insertRoom = async (abacAttributes: IAbacAttributeDefinition[] = []) => { + await roomsCol.insertOne({ + _id: rid, + t: 'p', + name: 'Test Room', + abacAttributes, + } as any); + }; + + const insertUsers = async ( + users: Array<{ + _id: string; + abacAttributes?: IAbacAttributeDefinition[]; + member?: boolean; + extraRooms?: string[]; + }>, + ) => { + await usersCol.insertMany( + users.map((u) => ({ + _id: u._id, + username: u._id, + type: 'user', + roles: [], + active: true, + createdAt: new Date(), + _updatedAt: new Date(), + abacAttributes: u.abacAttributes, + __rooms: [...(u.member ? [rid] : []), ...(u.extraRooms || [])], + })), + ); + }; + + let debugSpy: jest.SpyInstance; + beforeAll(async () => { + mongo = await MongoMemoryServer.create(); + client = await MongoClient.connect(mongo.getUri(), {}); + db = client.db('abac_integration'); + + // Hack to register the models in here with a custom database without having to call every model by one + registerServiceModels(db as any); + + service = new AbacService(); + debugSpy = jest.spyOn((service as any).logger, 'debug').mockImplementation(() => undefined); + + roomsCol = db.collection('rocketchat_room'); + usersCol = db.collection('users'); + defsCol = db.collection('abac_attributes'); + }, 30_000); + + afterAll(async () => { + await client.close(); + await mongo.stop(); + }); + + beforeEach(async () => { + await Promise.all([roomsCol.deleteMany({}), usersCol.deleteMany({}), defsCol.deleteMany({})]); + debugSpy.mockClear(); + }); + + describe('setRoomAbacAttributes - new key addition', () => { + it('logs users that do not satisfy newly added attribute key or its values and actually removes them', async () => { + await insertDefinitions([{ key: 'dept', values: ['eng', 'sales', 'hr'] }]); + await insertRoom([]); + await insertUsers([ + { _id: 'u1', member: true, abacAttributes: [{ key: 'dept', values: ['eng', 'sales'] }] }, // compliant + { _id: 'u2', member: true, abacAttributes: [{ key: 'dept', values: ['eng'] }] }, // missing sales + { _id: 'u3', member: true, abacAttributes: [{ key: 'location', values: ['emea'] }] }, // missing dept key + { _id: 'u4', member: true, abacAttributes: [{ key: 'dept', values: ['eng', 'sales', 'hr'] }] }, // superset + { _id: 'u5', member: false, abacAttributes: [{ key: 'dept', values: ['eng', 'sales'] }] }, // not in room + ]); + + const debugSpy = (service as any).logger.debug as jest.Mock; + const changeSpy = jest.spyOn(service as any, 'onRoomAttributesChanged'); + + await service.setRoomAbacAttributes(rid, { dept: ['eng', 'sales'] }); + + // Assert the protected hook received the full room object (first arg) instead of just the id + expect(changeSpy).toHaveBeenCalledTimes(1); + expect(changeSpy.mock.calls[0][0]).toMatchObject({ _id: rid }); + expect(Array.isArray((changeSpy as any).mock.calls[0][0].abacAttributes)).toBe(true); + + const evaluationCalls = debugSpy.mock.calls.map((c) => c[0]).filter((arg) => arg && arg.msg === 'Room ABAC attributes changed'); + + expect(evaluationCalls.length).toBe(1); + const payload = evaluationCalls[0]; + expect(payload.rid).toBe(rid); + expect(payload.newAttributes).toEqual([{ key: 'dept', values: ['eng', 'sales'] }]); + expect(payload.usersToRemove.sort()).toEqual(['u2', 'u3']); // only non compliant + + // Assert membership actually updated + const remaining = await usersCol + .find({ _id: { $in: ['u1', 'u2', 'u3', 'u4'] } }, { projection: { __rooms: 1 } }) + .toArray() + .then((docs) => Object.fromEntries(docs.map((d) => [d._id, d.__rooms || []]))); + expect(remaining.u1).toContain(rid); + expect(remaining.u4).toContain(rid); + expect(remaining.u2).not.toContain(rid); + expect(remaining.u3).not.toContain(rid); + }); + + it('handles duplicate values in room attributes equivalently to unique set (logs non compliant and removes them)', async () => { + await insertDefinitions([{ key: 'dept', values: ['eng', 'sales'] }]); + await insertRoom([]); + await insertUsers([ + { _id: 'u1', member: true, abacAttributes: [{ key: 'dept', values: ['eng', 'sales'] }] }, + { _id: 'u2', member: true, abacAttributes: [{ key: 'dept', values: ['eng'] }] }, // non compliant (missing sales) + ]); + + const debugSpy = (service as any).logger.debug as jest.Mock; + await service.setRoomAbacAttributes(rid, { dept: ['eng', 'eng', 'sales'] }); + + const evaluationCalls = debugSpy.mock.calls + .map((c: any[]) => c[0]) + .filter((arg: any) => arg && arg.msg === 'Room ABAC attributes changed'); + expect(evaluationCalls.length).toBe(1); + expect(evaluationCalls[0].usersToRemove.sort()).toEqual(['u2']); + + const u1 = await usersCol.findOne({ _id: 'u1' }, { projection: { __rooms: 1 } }); + const u2 = await usersCol.findOne({ _id: 'u2' }, { projection: { __rooms: 1 } }); + expect(u1?.__rooms || []).toContain(rid); + expect(u2?.__rooms || []).not.toContain(rid); + }); + }); + + describe('updateRoomAbacAttributeValues - new value addition', () => { + it('logs users missing newly added value while retaining compliant ones and removes the missing ones', async () => { + await insertDefinitions([{ key: 'dept', values: ['eng', 'sales'] }]); + await insertRoom([{ key: 'dept', values: ['eng'] }]); + await insertUsers([ + { _id: 'u1', member: true, abacAttributes: [{ key: 'dept', values: ['eng', 'sales'] }] }, // already superset + { _id: 'u2', member: true, abacAttributes: [{ key: 'dept', values: ['eng'] }] }, // missing new value + { _id: 'u3', member: true, abacAttributes: [{ key: 'dept', values: ['eng', 'sales', 'hr'] }] }, // superset + ]); + + const debugSpy = (service as any).logger.debug as jest.Mock; + await service.updateRoomAbacAttributeValues(rid, 'dept', ['eng', 'sales']); + + const evaluationCalls = debugSpy.mock.calls + .map((c: any[]) => c[0]) + .filter((arg: any) => arg && arg.msg === 'Room ABAC attributes changed'); + expect(evaluationCalls.length).toBe(1); + expect(evaluationCalls[0].usersToRemove.sort()).toEqual(['u2']); + + const users = await usersCol + .find({ _id: { $in: ['u1', 'u2', 'u3'] } }, { projection: { __rooms: 1 } }) + .toArray() + .then((docs) => Object.fromEntries(docs.map((d) => [d._id, d.__rooms || []]))); + expect(users.u1).toContain(rid); + expect(users.u3).toContain(rid); + expect(users.u2).not.toContain(rid); + }); + + it('produces no evaluation log when only removing values from existing attribute', async () => { + await insertDefinitions([{ key: 'dept', values: ['eng', 'sales'] }]); + await insertRoom([{ key: 'dept', values: ['eng', 'sales'] }]); + await insertUsers([ + { _id: 'u1', member: true, abacAttributes: [{ key: 'dept', values: ['eng'] }] }, + { _id: 'u2', member: true, abacAttributes: [{ key: 'dept', values: ['eng', 'sales'] }] }, + ]); + + const debugSpy = (service as any).logger.debug as jest.Mock; + await service.updateRoomAbacAttributeValues(rid, 'dept', ['eng']); // removal only + + const evaluationCalls = debugSpy.mock.calls + .map((c: any[]) => c[0]) + .filter((arg: any) => arg && arg.msg === 'Re-evaluating room subscriptions'); + expect(evaluationCalls.length).toBe(0); + + // nobody removed because removal only does not trigger reevaluation + const u1 = await usersCol.findOne({ _id: 'u1' }, { projection: { __rooms: 1 } }); + const u2 = await usersCol.findOne({ _id: 'u2' }, { projection: { __rooms: 1 } }); + expect(u1?.__rooms || []).toContain(rid); + expect(u2?.__rooms || []).toContain(rid); + }); + }); + + describe('setRoomAbacAttributes - multi-attribute addition', () => { + it('enforces all attributes (AND semantics) removing users failing any', async () => { + await insertDefinitions([ + { key: 'dept', values: ['eng', 'sales', 'hr'] }, + { key: 'region', values: ['emea', 'apac'] }, + ]); + await insertRoom([{ key: 'dept', values: ['eng'] }]); + + await insertUsers([ + { + _id: 'u1', + member: true, + abacAttributes: [ + { key: 'dept', values: ['eng', 'sales'] }, + { key: 'region', values: ['emea'] }, + ], + }, // compliant after expansion + { + _id: 'u2', + member: true, + abacAttributes: [{ key: 'dept', values: ['eng'] }], // missing region + }, + { + _id: 'u3', + member: true, + abacAttributes: [{ key: 'region', values: ['emea'] }], // missing dept key + }, + { + _id: 'u4', + member: true, + abacAttributes: [ + { key: 'dept', values: ['eng', 'sales', 'hr'] }, + { key: 'region', values: ['emea', 'apac'] }, + ], + }, // superset across both + { + _id: 'u5', + member: true, + abacAttributes: [ + { key: 'dept', values: ['eng', 'sales'] }, + { key: 'region', values: ['apac'] }, + ], + }, + ]); + + const debugSpy = (service as any).logger.debug as jest.Mock; + await service.setRoomAbacAttributes(rid, { + dept: ['eng', 'sales'], + region: ['emea'], + }); + + const evaluationCalls = debugSpy.mock.calls + .map((c: any[]) => c[0]) + .filter((arg: any) => arg && arg.msg === 'Room ABAC attributes changed'); + expect(evaluationCalls.length).toBe(1); + expect(evaluationCalls[0].usersToRemove.sort()).toEqual(['u2', 'u3', 'u5']); + + const memberships = await usersCol + .find({ _id: { $in: ['u1', 'u2', 'u3', 'u4', 'u5'] } }, { projection: { __rooms: 1 } }) + .toArray() + .then((docs) => Object.fromEntries(docs.map((d) => [d._id, d.__rooms || []]))); + expect(memberships.u1).toContain(rid); + expect(memberships.u4).toContain(rid); + expect(memberships.u2).not.toContain(rid); + expect(memberships.u3).not.toContain(rid); + expect(memberships.u5).not.toContain(rid); + }); + }); + + describe('Idempotency & no-op behavior', () => { + it('does not remove anyone when calling with identical attribute set twice', async () => { + await insertDefinitions([{ key: 'dept', values: ['eng', 'sales'] }]); + await insertRoom([]); + await insertUsers([ + { _id: 'u1', member: true, abacAttributes: [{ key: 'dept', values: ['eng', 'sales'] }] }, + { _id: 'u2', member: true, abacAttributes: [{ key: 'dept', values: ['eng'] }] }, // will be removed on first pass + ]); + + const debugSpy = (service as any).logger.debug as jest.Mock; + await service.setRoomAbacAttributes(rid, { dept: ['eng', 'sales'] }); + const firstEval = debugSpy.mock.calls.map((c: any[]) => c[0]).filter((a: any) => a && a.msg === 'Room ABAC attributes changed'); + expect(firstEval.length).toBe(1); + expect(firstEval[0].usersToRemove.sort()).toEqual(['u2']); + + let u1 = await usersCol.findOne({ _id: 'u1' }, { projection: { __rooms: 1 } }); + let u2 = await usersCol.findOne({ _id: 'u2' }, { projection: { __rooms: 1 } }); + expect(u1?.__rooms || []).toContain(rid); + expect(u2?.__rooms || []).not.toContain(rid); + + // Reset mock counts for clarity + debugSpy.mockClear(); + + await service.setRoomAbacAttributes(rid, { dept: ['eng', 'sales'] }); + const secondEval = debugSpy.mock.calls.map((c: any[]) => c[0]).filter((a: any) => a && a.msg === 'Room ABAC attributes changed'); + expect(secondEval.length).toBe(0); + + u1 = await usersCol.findOne({ _id: 'u1' }, { projection: { __rooms: 1 } }); + u2 = await usersCol.findOne({ _id: 'u2' }, { projection: { __rooms: 1 } }); + expect(u1?.__rooms || []).toContain(rid); + expect(u2?.__rooms || []).not.toContain(rid); + }); + }); + + describe('Superset and missing attribute edge cases', () => { + it('keeps user with superset values and removes user missing one required value', async () => { + await insertDefinitions([{ key: 'dept', values: ['eng', 'sales', 'hr'] }]); + await insertRoom([]); + await insertUsers([ + { _id: 'u1', member: true, abacAttributes: [{ key: 'dept', values: ['eng', 'sales', 'hr'] }] }, + { _id: 'u2', member: true, abacAttributes: [{ key: 'dept', values: ['eng', 'hr'] }] }, // missing sales + ]); + + const debugSpy = (service as any).logger.debug as jest.Mock; + await service.setRoomAbacAttributes(rid, { dept: ['eng', 'sales', 'hr'] }); + + const evaluationCalls = debugSpy.mock.calls + .map((c: any[]) => c[0]) + .filter((arg: any) => arg && arg.msg === 'Room ABAC attributes changed'); + expect(evaluationCalls.length).toBe(1); + expect(evaluationCalls[0].usersToRemove.sort()).toEqual(['u2']); + + const u1 = await usersCol.findOne({ _id: 'u1' }, { projection: { __rooms: 1 } }); + const u2 = await usersCol.findOne({ _id: 'u2' }, { projection: { __rooms: 1 } }); + expect(u1?.__rooms || []).toContain(rid); + expect(u2?.__rooms || []).not.toContain(rid); + }); + + it('removes user missing attribute key entirely', async () => { + await insertDefinitions([{ key: 'region', values: ['emea', 'apac'] }]); + await insertRoom([]); + await insertUsers([ + { _id: 'u1', member: true, abacAttributes: [{ key: 'region', values: ['emea'] }] }, + { _id: 'u2', member: true, abacAttributes: [{ key: 'dept', values: ['eng'] }] }, // missing region + { _id: 'u3', member: true }, // no abacAttributes field + ]); + + const debugSpy = (service as any).logger.debug as jest.Mock; + await service.setRoomAbacAttributes(rid, { region: ['emea'] }); + + const evaluationCalls = debugSpy.mock.calls + .map((c: any[]) => c[0]) + .filter((arg: any) => arg && arg.msg === 'Room ABAC attributes changed'); + expect(evaluationCalls.length).toBe(1); + expect(evaluationCalls[0].usersToRemove.sort()).toEqual(['u2', 'u3']); + + const memberships = await usersCol + .find({ _id: { $in: ['u1', 'u2', 'u3'] } }, { projection: { __rooms: 1 } }) + .toArray() + .then((docs) => Object.fromEntries(docs.map((d) => [d._id, d.__rooms || []]))); + expect(memberships.u1).toContain(rid); + expect(memberships.u2).not.toContain(rid); + expect(memberships.u3).not.toContain(rid); + }); + }); + + describe('Large member set performance sanity (lightweight)', () => { + it('removes only expected fraction in a larger population', async () => { + await insertDefinitions([{ key: 'dept', values: ['eng', 'sales'] }]); + await insertRoom([]); + + const bulk: Parameters[0] = []; + for (let i = 0; i < 300; i++) { + // Half compliant with both values, half only 'eng' + const values = i % 2 === 0 ? ['eng', 'sales'] : ['eng']; + bulk.push({ + _id: `u${i}`, + member: true, + abacAttributes: [{ key: 'dept', values }], + }); + } + await insertUsers(bulk); + + const debugSpy = (service as any).logger.debug as jest.Mock; + await service.setRoomAbacAttributes(rid, { dept: ['eng', 'sales'] }); + + const evaluationCalls = debugSpy.mock.calls + .map((c: any[]) => c[0]) + .filter((arg: any) => arg && arg.msg === 'Room ABAC attributes changed'); + expect(evaluationCalls.length).toBe(1); + const removed = evaluationCalls[0].usersToRemove; + expect(removed.length).toBe(150); + expect(removed).toContain('u1'); + expect(removed).toContain('u299'); + expect(removed).not.toContain('u0'); + expect(removed).not.toContain('u298'); + + const remainingCount = await usersCol.countDocuments({ __rooms: rid }); + expect(remainingCount).toBe(150); + }); + }); +}); diff --git a/packages/apps-engine/src/definition/messages/MessageType.ts b/packages/apps-engine/src/definition/messages/MessageType.ts index 4b2734c1d3657..308d1bacaca34 100644 --- a/packages/apps-engine/src/definition/messages/MessageType.ts +++ b/packages/apps-engine/src/definition/messages/MessageType.ts @@ -144,4 +144,6 @@ export type MessageType = /** Sent when a leader was removed */ | 'leader-removed' /** Sent when a user was added to a room */ - | 'discussion-created'; + | 'discussion-created' + // ** Sent when a user was removed from an abac room */ + | 'abac-removed-user-from-room'; diff --git a/packages/core-services/src/types/IRoomService.ts b/packages/core-services/src/types/IRoomService.ts index ecb850d45ef2b..9e74a6d022599 100644 --- a/packages/core-services/src/types/IRoomService.ts +++ b/packages/core-services/src/types/IRoomService.ts @@ -1,4 +1,4 @@ -import type { AtLeast, IRoom, ISubscription, IUser } from '@rocket.chat/core-typings'; +import type { AtLeast, IRoom, ISubscription, IUser, MessageTypesValues } from '@rocket.chat/core-typings'; export interface ISubscriptionExtraData { open: boolean; @@ -35,9 +35,13 @@ export interface IRoomService { createAsHidden?: boolean; }, ): Promise; - removeUserFromRoom(roomId: string, user: IUser, options?: { byUser: Pick }): Promise; performUserRemoval(room: IRoom, user: IUser, options?: { byUser?: IUser }): Promise; performAcceptRoomInvite(room: IRoom, subscription: ISubscription, user: IUser): Promise; + removeUserFromRoom( + roomId: string, + user: IUser, + options?: { byUser?: Pick; skipAppPreEvents?: boolean; customSystemMessage?: MessageTypesValues }, + ): Promise; getValidRoomName(displayName: string, roomId?: string, options?: { allowDuplicates?: boolean }): Promise; saveRoomTopic( roomId: string, diff --git a/packages/core-typings/src/IMessage/IMessage.ts b/packages/core-typings/src/IMessage/IMessage.ts index 381ab53b03d4d..15ca5f76b1500 100644 --- a/packages/core-typings/src/IMessage/IMessage.ts +++ b/packages/core-typings/src/IMessage/IMessage.ts @@ -106,6 +106,7 @@ const MessageTypes = [ 'new-leader', 'leader-removed', 'discussion-created', + 'abac-removed-user-from-room', ...TeamMessageTypesValues, ...LivechatMessageTypesValues, ...VoipMessageTypesValues, diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index b1a011a71305c..f260f8ead0c73 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -14,6 +14,7 @@ "ABAC": "Attribute Based Access Control (ABAC)", "ABAC_Enabled": "Enable Attribute Based Access Control (ABAC)", "abac-management": "Manage ABAC configuration", + "abac_removed_user_from_the_room": "was removed by ABAC", "AI_Actions": "AI actions", "API": "API", "API_Add_Personal_Access_Token": "Add new Personal Access Token", diff --git a/packages/message-types/src/registrations/common.ts b/packages/message-types/src/registrations/common.ts index 88566517c818a..67f45939945ea 100644 --- a/packages/message-types/src/registrations/common.ts +++ b/packages/message-types/src/registrations/common.ts @@ -212,4 +212,10 @@ export default (instance: MessageTypes) => { system: true, text: (t) => t('Pinned_a_message'), }); + + instance.registerType({ + id: 'abac-removed-user-from-room', + system: true, + text: (t) => t('abac_removed_user_from_the_room'), + }); }; diff --git a/yarn.lock b/yarn.lock index 0e1c49ed0269e..37a32659d73c7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4064,6 +4064,20 @@ __metadata: languageName: node linkType: hard +"@jest/console@npm:30.0.5": + version: 30.0.5 + resolution: "@jest/console@npm:30.0.5" + dependencies: + "@jest/types": "npm:30.0.5" + "@types/node": "npm:*" + chalk: "npm:^4.1.2" + jest-message-util: "npm:30.0.5" + jest-util: "npm:30.0.5" + slash: "npm:^3.0.0" + checksum: 10/df991610228b3544c5d93282d144f211960c526f2699a9f25bf6e9f76fbc1e7fbcdf7df994da6b55f44f5459aafee3e78a4d323d59f6ac3ca614ccd07841060f + languageName: node + linkType: hard + "@jest/console@npm:30.2.0": version: 30.2.0 resolution: "@jest/console@npm:30.2.0" @@ -4092,6 +4106,47 @@ __metadata: languageName: node linkType: hard +"@jest/core@npm:30.0.5": + version: 30.0.5 + resolution: "@jest/core@npm:30.0.5" + dependencies: + "@jest/console": "npm:30.0.5" + "@jest/pattern": "npm:30.0.1" + "@jest/reporters": "npm:30.0.5" + "@jest/test-result": "npm:30.0.5" + "@jest/transform": "npm:30.0.5" + "@jest/types": "npm:30.0.5" + "@types/node": "npm:*" + ansi-escapes: "npm:^4.3.2" + chalk: "npm:^4.1.2" + ci-info: "npm:^4.2.0" + exit-x: "npm:^0.2.2" + graceful-fs: "npm:^4.2.11" + jest-changed-files: "npm:30.0.5" + jest-config: "npm:30.0.5" + jest-haste-map: "npm:30.0.5" + jest-message-util: "npm:30.0.5" + jest-regex-util: "npm:30.0.1" + jest-resolve: "npm:30.0.5" + jest-resolve-dependencies: "npm:30.0.5" + jest-runner: "npm:30.0.5" + jest-runtime: "npm:30.0.5" + jest-snapshot: "npm:30.0.5" + jest-util: "npm:30.0.5" + jest-validate: "npm:30.0.5" + jest-watcher: "npm:30.0.5" + micromatch: "npm:^4.0.8" + pretty-format: "npm:30.0.5" + slash: "npm:^3.0.0" + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + checksum: 10/8299628ce0e2552361a5ddd5b4df3f0999c5f6eda00e16ae8e80f9640127776ea38eaa2893821606ccb8e6dc16855f18212cbc98213bd908625002824febc2bc + languageName: node + linkType: hard + "@jest/core@npm:30.2.0": version: 30.2.0 resolution: "@jest/core@npm:30.2.0" @@ -4220,6 +4275,18 @@ __metadata: languageName: node linkType: hard +"@jest/environment@npm:30.0.5": + version: 30.0.5 + resolution: "@jest/environment@npm:30.0.5" + dependencies: + "@jest/fake-timers": "npm:30.0.5" + "@jest/types": "npm:30.0.5" + "@types/node": "npm:*" + jest-mock: "npm:30.0.5" + checksum: 10/b7104cd1dbb5d7e0ed250df959fd98cdc6cbc05d2f0b8a4bf0d8d24121d74e263b94df3fb0d967a382e2ac0003adcbd44a121fbffb2e03f30a26aa7117c52c76 + languageName: node + linkType: hard + "@jest/environment@npm:30.2.0": version: 30.2.0 resolution: "@jest/environment@npm:30.2.0" @@ -4271,6 +4338,16 @@ __metadata: languageName: node linkType: hard +"@jest/expect@npm:30.0.5": + version: 30.0.5 + resolution: "@jest/expect@npm:30.0.5" + dependencies: + expect: "npm:30.0.5" + jest-snapshot: "npm:30.0.5" + checksum: 10/e51954d86941b05641f1a0aa0fb9ec10ea82008feabdd085c7b05a97d3a84cc5f9fc5cd7f15822bcde0648f233717911d2f2891b3870ac3caf95d10d42b00da8 + languageName: node + linkType: hard + "@jest/expect@npm:30.2.0": version: 30.2.0 resolution: "@jest/expect@npm:30.2.0" @@ -4291,6 +4368,20 @@ __metadata: languageName: node linkType: hard +"@jest/fake-timers@npm:30.0.5": + version: 30.0.5 + resolution: "@jest/fake-timers@npm:30.0.5" + dependencies: + "@jest/types": "npm:30.0.5" + "@sinonjs/fake-timers": "npm:^13.0.0" + "@types/node": "npm:*" + jest-message-util: "npm:30.0.5" + jest-mock: "npm:30.0.5" + jest-util: "npm:30.0.5" + checksum: 10/5c3b3ad1c940c24b64f77b9ba953b627a8f648bc4dbdba03f497cc9257dc220fd7efedd2f855d9e49c84079e1ff2cbb0e1d1a9e8beb460a0ce7c07ff0ac348fc + languageName: node + linkType: hard + "@jest/fake-timers@npm:30.2.0": version: 30.2.0 resolution: "@jest/fake-timers@npm:30.2.0" @@ -4333,6 +4424,18 @@ __metadata: languageName: node linkType: hard +"@jest/globals@npm:30.0.5": + version: 30.0.5 + resolution: "@jest/globals@npm:30.0.5" + dependencies: + "@jest/environment": "npm:30.0.5" + "@jest/expect": "npm:30.0.5" + "@jest/types": "npm:30.0.5" + jest-mock: "npm:30.0.5" + checksum: 10/44091f5d8386bf5cadd7d36e2fb36b0794b2dd1e0c866d4cecceaf12f9304bb139544a597b1d1edf4c8158baa5684042bcfda4bc9a5603bd2c41c17509c4151b + languageName: node + linkType: hard + "@jest/globals@npm:30.2.0": version: 30.2.0 resolution: "@jest/globals@npm:30.2.0" @@ -4367,6 +4470,42 @@ __metadata: languageName: node linkType: hard +"@jest/reporters@npm:30.0.5": + version: 30.0.5 + resolution: "@jest/reporters@npm:30.0.5" + dependencies: + "@bcoe/v8-coverage": "npm:^0.2.3" + "@jest/console": "npm:30.0.5" + "@jest/test-result": "npm:30.0.5" + "@jest/transform": "npm:30.0.5" + "@jest/types": "npm:30.0.5" + "@jridgewell/trace-mapping": "npm:^0.3.25" + "@types/node": "npm:*" + chalk: "npm:^4.1.2" + collect-v8-coverage: "npm:^1.0.2" + exit-x: "npm:^0.2.2" + glob: "npm:^10.3.10" + graceful-fs: "npm:^4.2.11" + istanbul-lib-coverage: "npm:^3.0.0" + istanbul-lib-instrument: "npm:^6.0.0" + istanbul-lib-report: "npm:^3.0.0" + istanbul-lib-source-maps: "npm:^5.0.0" + istanbul-reports: "npm:^3.1.3" + jest-message-util: "npm:30.0.5" + jest-util: "npm:30.0.5" + jest-worker: "npm:30.0.5" + slash: "npm:^3.0.0" + string-length: "npm:^4.0.2" + v8-to-istanbul: "npm:^9.0.1" + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + checksum: 10/8272e6dbe26fc1252fc54fe147be188a13ff577a96f523964b16b3fede87a6f648949256423537720a2ac179f9e5050c349afb995922a9874f4cf4480d15021e + languageName: node + linkType: hard + "@jest/reporters@npm:30.2.0": version: 30.2.0 resolution: "@jest/reporters@npm:30.2.0" @@ -4458,6 +4597,18 @@ __metadata: languageName: node linkType: hard +"@jest/snapshot-utils@npm:30.0.5": + version: 30.0.5 + resolution: "@jest/snapshot-utils@npm:30.0.5" + dependencies: + "@jest/types": "npm:30.0.5" + chalk: "npm:^4.1.2" + graceful-fs: "npm:^4.2.11" + natural-compare: "npm:^1.4.0" + checksum: 10/f132296d8851c562f6c44d78ea29c7c216d0498e24f45b5f6c1113a2f1c5de61841e398cb90cfaf36dc9370a95d4e9c7ccbc0f88e348aa835d81f3523da7f002 + languageName: node + linkType: hard + "@jest/snapshot-utils@npm:30.2.0": version: 30.2.0 resolution: "@jest/snapshot-utils@npm:30.2.0" @@ -4492,6 +4643,18 @@ __metadata: languageName: node linkType: hard +"@jest/test-result@npm:30.0.5": + version: 30.0.5 + resolution: "@jest/test-result@npm:30.0.5" + dependencies: + "@jest/console": "npm:30.0.5" + "@jest/types": "npm:30.0.5" + "@types/istanbul-lib-coverage": "npm:^2.0.6" + collect-v8-coverage: "npm:^1.0.2" + checksum: 10/41682497c98f5b8b2b9e81e3ce3a540418bdca7bce358b4dddf3c63abdb90d57476d89042ebc98e0acd5ea870ace17e1ed3b4b45df05e32cdaa970c1e4aca0d9 + languageName: node + linkType: hard + "@jest/test-result@npm:30.2.0": version: 30.2.0 resolution: "@jest/test-result@npm:30.2.0" @@ -4516,6 +4679,18 @@ __metadata: languageName: node linkType: hard +"@jest/test-sequencer@npm:30.0.5": + version: 30.0.5 + resolution: "@jest/test-sequencer@npm:30.0.5" + dependencies: + "@jest/test-result": "npm:30.0.5" + graceful-fs: "npm:^4.2.11" + jest-haste-map: "npm:30.0.5" + slash: "npm:^3.0.0" + checksum: 10/f73ce9513d858861602c74f5e04124f2b9e3718faed9efaa1b7de1ffbab1d388e9698e3e10b80f0d8f4cf87d3fd3e67f2b39ab9e6f1429d3779f7bad8fac98de + languageName: node + linkType: hard + "@jest/test-sequencer@npm:30.2.0": version: 30.2.0 resolution: "@jest/test-sequencer@npm:30.2.0" @@ -4540,6 +4715,29 @@ __metadata: languageName: node linkType: hard +"@jest/transform@npm:30.0.5": + version: 30.0.5 + resolution: "@jest/transform@npm:30.0.5" + dependencies: + "@babel/core": "npm:^7.27.4" + "@jest/types": "npm:30.0.5" + "@jridgewell/trace-mapping": "npm:^0.3.25" + babel-plugin-istanbul: "npm:^7.0.0" + chalk: "npm:^4.1.2" + convert-source-map: "npm:^2.0.0" + fast-json-stable-stringify: "npm:^2.1.0" + graceful-fs: "npm:^4.2.11" + jest-haste-map: "npm:30.0.5" + jest-regex-util: "npm:30.0.1" + jest-util: "npm:30.0.5" + micromatch: "npm:^4.0.8" + pirates: "npm:^4.0.7" + slash: "npm:^3.0.0" + write-file-atomic: "npm:^5.0.1" + checksum: 10/2b3e0bc39aa6ff0c521f9fff4724e3ca1d720cd51f6fd3a97a18d832f66b3f7d021f38fb2d26053461a32577ef187fe5e2d636050be419aefd0d06d23ea1d35a + languageName: node + linkType: hard + "@jest/transform@npm:30.2.0": version: 30.2.0 resolution: "@jest/transform@npm:30.2.0" @@ -8082,14 +8280,16 @@ __metadata: "@rocket.chat/core-services": "workspace:^" "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/eslint-config": "workspace:^" + "@rocket.chat/logger": "workspace:^" "@rocket.chat/models": "workspace:^" - "@rocket.chat/string-helpers": "npm:^0.32.0" "@rocket.chat/tsconfig": "workspace:*" "@types/jest": "npm:~30.0.0" "@types/node": "npm:~22.16.1" eslint: "npm:~8.45.0" jest: "npm:~30.0.5" mongodb: "npm:6.10.0" + mongodb-memory-server: "npm:^10.1.4" + p-limit: "npm:3.1.0" typescript: "npm:~5.9.2" languageName: unknown linkType: soft @@ -10192,13 +10392,6 @@ __metadata: languageName: node linkType: hard -"@rocket.chat/string-helpers@npm:^0.32.0": - version: 0.32.0 - resolution: "@rocket.chat/string-helpers@npm:0.32.0" - checksum: 10/4f503a42e9a93ea9e6e85da320f125da5af5da1693e6782dee828ffc6c0d05e6ec76d75f7efc342d0738a4d37d67a729486a4d96b109642d70610660beb38e21 - languageName: node - linkType: hard - "@rocket.chat/styled@npm:^0.32.0, @rocket.chat/styled@npm:~0.32.0": version: 0.32.0 resolution: "@rocket.chat/styled@npm:0.32.0" @@ -13935,7 +14128,7 @@ __metadata: languageName: node linkType: hard -"@types/node@npm:~22.16.5": +"@types/node@npm:~22.16.1, @types/node@npm:~22.16.5": version: 22.16.5 resolution: "@types/node@npm:22.16.5" dependencies: @@ -16097,7 +16290,7 @@ __metadata: languageName: node linkType: hard -"async-mutex@npm:~0.5.0": +"async-mutex@npm:^0.5.0, async-mutex@npm:~0.5.0": version: 0.5.0 resolution: "async-mutex@npm:0.5.0" dependencies: @@ -16291,6 +16484,23 @@ __metadata: languageName: node linkType: hard +"babel-jest@npm:30.0.5": + version: 30.0.5 + resolution: "babel-jest@npm:30.0.5" + dependencies: + "@jest/transform": "npm:30.0.5" + "@types/babel__core": "npm:^7.20.5" + babel-plugin-istanbul: "npm:^7.0.0" + babel-preset-jest: "npm:30.0.1" + chalk: "npm:^4.1.2" + graceful-fs: "npm:^4.2.11" + slash: "npm:^3.0.0" + peerDependencies: + "@babel/core": ^7.11.0 + checksum: 10/39a36b86484e8d545ff0f83c81fccd4952e6fc68b98015fd72a03ba2a5650edde20412fdf92328200a5cbdd0aed6f1c2982269f590fb0e5f131e0a2533436c81 + languageName: node + linkType: hard + "babel-jest@npm:30.2.0, babel-jest@npm:~30.2.0": version: 30.2.0 resolution: "babel-jest@npm:30.2.0" @@ -16357,7 +16567,7 @@ __metadata: languageName: node linkType: hard -"babel-plugin-istanbul@npm:^7.0.1, babel-plugin-istanbul@npm:~7.0.1": +"babel-plugin-istanbul@npm:^7.0.0, babel-plugin-istanbul@npm:^7.0.1, babel-plugin-istanbul@npm:~7.0.1": version: 7.0.1 resolution: "babel-plugin-istanbul@npm:7.0.1" dependencies: @@ -16370,6 +16580,17 @@ __metadata: languageName: node linkType: hard +"babel-plugin-jest-hoist@npm:30.0.1": + version: 30.0.1 + resolution: "babel-plugin-jest-hoist@npm:30.0.1" + dependencies: + "@babel/template": "npm:^7.27.2" + "@babel/types": "npm:^7.27.3" + "@types/babel__core": "npm:^7.20.5" + checksum: 10/4d8d0eb3726fb16b85322449fff15fa48404ef92dae48f9b0c956f6d504208e604e4e40fe71665433cb21f35be0faf5b2b11732330f67b3add66728edcfbcb93 + languageName: node + linkType: hard + "babel-plugin-jest-hoist@npm:30.2.0": version: 30.2.0 resolution: "babel-plugin-jest-hoist@npm:30.2.0" @@ -16463,7 +16684,7 @@ __metadata: languageName: node linkType: hard -"babel-preset-current-node-syntax@npm:^1.2.0": +"babel-preset-current-node-syntax@npm:^1.1.0, babel-preset-current-node-syntax@npm:^1.2.0": version: 1.2.0 resolution: "babel-preset-current-node-syntax@npm:1.2.0" dependencies: @@ -16488,6 +16709,18 @@ __metadata: languageName: node linkType: hard +"babel-preset-jest@npm:30.0.1": + version: 30.0.1 + resolution: "babel-preset-jest@npm:30.0.1" + dependencies: + babel-plugin-jest-hoist: "npm:30.0.1" + babel-preset-current-node-syntax: "npm:^1.1.0" + peerDependencies: + "@babel/core": ^7.11.0 + checksum: 10/fa37b0fa11baffd983f42663c7a4db61d9b10704bd061333950c3d2a191457930e68e172a93f6675d85cd6a1315fd6954143bda5709a3ba38ef7bd87a13d0aa6 + languageName: node + linkType: hard + "babel-preset-jest@npm:30.2.0": version: 30.2.0 resolution: "babel-preset-jest@npm:30.2.0" @@ -17170,6 +17403,13 @@ __metadata: languageName: node linkType: hard +"buffer-crc32@npm:~0.2.3": + version: 0.2.13 + resolution: "buffer-crc32@npm:0.2.13" + checksum: 10/06252347ae6daca3453b94e4b2f1d3754a3b146a111d81c68924c22d91889a40623264e95e67955b1cb4a68cbedf317abeabb5140a9766ed248973096db5ce1c + languageName: node + linkType: hard + "buffer-equal-constant-time@npm:1.0.1": version: 1.0.1 resolution: "buffer-equal-constant-time@npm:1.0.1" @@ -21592,6 +21832,20 @@ __metadata: languageName: node linkType: hard +"expect@npm:30.0.5, expect@npm:^30.0.0": + version: 30.0.5 + resolution: "expect@npm:30.0.5" + dependencies: + "@jest/expect-utils": "npm:30.0.5" + "@jest/get-type": "npm:30.0.1" + jest-matcher-utils: "npm:30.0.5" + jest-message-util: "npm:30.0.5" + jest-mock: "npm:30.0.5" + jest-util: "npm:30.0.5" + checksum: 10/48ee8a444bfb7c6b23ca9b416a43f6c418ab698b1c3f59848d711d13b362a393dcc7d30b97ce73e0a15959a98644f51a600e6d76e20091aa7016441899b1e9c4 + languageName: node + linkType: hard + "expect@npm:30.2.0": version: 30.2.0 resolution: "expect@npm:30.2.0" @@ -21619,20 +21873,6 @@ __metadata: languageName: node linkType: hard -"expect@npm:^30.0.0": - version: 30.0.5 - resolution: "expect@npm:30.0.5" - dependencies: - "@jest/expect-utils": "npm:30.0.5" - "@jest/get-type": "npm:30.0.1" - jest-matcher-utils: "npm:30.0.5" - jest-message-util: "npm:30.0.5" - jest-mock: "npm:30.0.5" - jest-util: "npm:30.0.5" - checksum: 10/48ee8a444bfb7c6b23ca9b416a43f6c418ab698b1c3f59848d711d13b362a393dcc7d30b97ce73e0a15959a98644f51a600e6d76e20091aa7016441899b1e9c4 - languageName: node - linkType: hard - "expiry-map@npm:^2.0.0": version: 2.0.0 resolution: "expiry-map@npm:2.0.0" @@ -22048,7 +22288,7 @@ __metadata: languageName: node linkType: hard -"find-cache-dir@npm:^3.2.0, find-cache-dir@npm:^3.3.1": +"find-cache-dir@npm:^3.2.0, find-cache-dir@npm:^3.3.1, find-cache-dir@npm:^3.3.2": version: 3.3.2 resolution: "find-cache-dir@npm:3.3.2" dependencies: @@ -24909,6 +25149,17 @@ __metadata: languageName: node linkType: hard +"jest-changed-files@npm:30.0.5": + version: 30.0.5 + resolution: "jest-changed-files@npm:30.0.5" + dependencies: + execa: "npm:^5.1.1" + jest-util: "npm:30.0.5" + p-limit: "npm:^3.1.0" + checksum: 10/cc2df02d1c05465da4ba05dc6d0868fee69a7389ffa784f5ee2680a915886359d618b291105d46b061e74225d7d999c03701dda56e9f8df04ef815e05bff621b + languageName: node + linkType: hard + "jest-changed-files@npm:30.2.0": version: 30.2.0 resolution: "jest-changed-files@npm:30.2.0" @@ -24931,6 +25182,34 @@ __metadata: languageName: node linkType: hard +"jest-circus@npm:30.0.5": + version: 30.0.5 + resolution: "jest-circus@npm:30.0.5" + dependencies: + "@jest/environment": "npm:30.0.5" + "@jest/expect": "npm:30.0.5" + "@jest/test-result": "npm:30.0.5" + "@jest/types": "npm:30.0.5" + "@types/node": "npm:*" + chalk: "npm:^4.1.2" + co: "npm:^4.6.0" + dedent: "npm:^1.6.0" + is-generator-fn: "npm:^2.1.0" + jest-each: "npm:30.0.5" + jest-matcher-utils: "npm:30.0.5" + jest-message-util: "npm:30.0.5" + jest-runtime: "npm:30.0.5" + jest-snapshot: "npm:30.0.5" + jest-util: "npm:30.0.5" + p-limit: "npm:^3.1.0" + pretty-format: "npm:30.0.5" + pure-rand: "npm:^7.0.0" + slash: "npm:^3.0.0" + stack-utils: "npm:^2.0.6" + checksum: 10/ccbfa6a95cbc3dee0f82650c9c6c483c53aac6892d0e167536f1791806b4834e79081f25b7048a5ac890c64df8d9863fca914c259835de4556de5572ed4e95c7 + languageName: node + linkType: hard + "jest-circus@npm:30.2.0": version: 30.2.0 resolution: "jest-circus@npm:30.2.0" @@ -24987,6 +25266,31 @@ __metadata: languageName: node linkType: hard +"jest-cli@npm:30.0.5": + version: 30.0.5 + resolution: "jest-cli@npm:30.0.5" + dependencies: + "@jest/core": "npm:30.0.5" + "@jest/test-result": "npm:30.0.5" + "@jest/types": "npm:30.0.5" + chalk: "npm:^4.1.2" + exit-x: "npm:^0.2.2" + import-local: "npm:^3.2.0" + jest-config: "npm:30.0.5" + jest-util: "npm:30.0.5" + jest-validate: "npm:30.0.5" + yargs: "npm:^17.7.2" + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + bin: + jest: ./bin/jest.js + checksum: 10/228c3b525b2b64513e41cf3afb7cee7195c4574a10d9c05af13ad33e3a56661cf670d2fedc60954e6c3fab6f73a8cf775e47ef1471d06273eedd42c5c6adeeee + languageName: node + linkType: hard + "jest-cli@npm:30.2.0": version: 30.2.0 resolution: "jest-cli@npm:30.2.0" @@ -25038,6 +25342,49 @@ __metadata: languageName: node linkType: hard +"jest-config@npm:30.0.5": + version: 30.0.5 + resolution: "jest-config@npm:30.0.5" + dependencies: + "@babel/core": "npm:^7.27.4" + "@jest/get-type": "npm:30.0.1" + "@jest/pattern": "npm:30.0.1" + "@jest/test-sequencer": "npm:30.0.5" + "@jest/types": "npm:30.0.5" + babel-jest: "npm:30.0.5" + chalk: "npm:^4.1.2" + ci-info: "npm:^4.2.0" + deepmerge: "npm:^4.3.1" + glob: "npm:^10.3.10" + graceful-fs: "npm:^4.2.11" + jest-circus: "npm:30.0.5" + jest-docblock: "npm:30.0.1" + jest-environment-node: "npm:30.0.5" + jest-regex-util: "npm:30.0.1" + jest-resolve: "npm:30.0.5" + jest-runner: "npm:30.0.5" + jest-util: "npm:30.0.5" + jest-validate: "npm:30.0.5" + micromatch: "npm:^4.0.8" + parse-json: "npm:^5.2.0" + pretty-format: "npm:30.0.5" + slash: "npm:^3.0.0" + strip-json-comments: "npm:^3.1.1" + peerDependencies: + "@types/node": "*" + esbuild-register: ">=3.4.0" + ts-node: ">=9.0.0" + peerDependenciesMeta: + "@types/node": + optional: true + esbuild-register: + optional: true + ts-node: + optional: true + checksum: 10/3cb313650adfa34d9a383883b7e8eba89df907f38bbdf321db6d151af099479371a8f33143d09e83faf0cea843d37ca9c45edc92e7e15dae8fd72b57c96a1dc6 + languageName: node + linkType: hard + "jest-config@npm:30.2.0": version: 30.2.0 resolution: "jest-config@npm:30.2.0" @@ -25155,6 +25502,15 @@ __metadata: languageName: node linkType: hard +"jest-docblock@npm:30.0.1": + version: 30.0.1 + resolution: "jest-docblock@npm:30.0.1" + dependencies: + detect-newline: "npm:^3.1.0" + checksum: 10/92ebee39282e764cd64bbfffe4a1bbae323e3b01684028c7206aada198314522a8ebe6892660d2ddeeb9a4b8d270a90da8af0fc654502a428e412867d732a459 + languageName: node + linkType: hard + "jest-docblock@npm:30.2.0": version: 30.2.0 resolution: "jest-docblock@npm:30.2.0" @@ -25173,6 +25529,19 @@ __metadata: languageName: node linkType: hard +"jest-each@npm:30.0.5": + version: 30.0.5 + resolution: "jest-each@npm:30.0.5" + dependencies: + "@jest/get-type": "npm:30.0.1" + "@jest/types": "npm:30.0.5" + chalk: "npm:^4.1.2" + jest-util: "npm:30.0.5" + pretty-format: "npm:30.0.5" + checksum: 10/457512eda80141f99b6c6d350261eb440d545e4a5c357687bc1fdf1719c2d1f41829c8b8f5d1710be02f5e555572d2bad9526e5acb85f901b1066d2ca3dcd2ba + languageName: node + linkType: hard + "jest-each@npm:30.2.0": version: 30.2.0 resolution: "jest-each@npm:30.2.0" @@ -25217,6 +25586,21 @@ __metadata: languageName: node linkType: hard +"jest-environment-node@npm:30.0.5": + version: 30.0.5 + resolution: "jest-environment-node@npm:30.0.5" + dependencies: + "@jest/environment": "npm:30.0.5" + "@jest/fake-timers": "npm:30.0.5" + "@jest/types": "npm:30.0.5" + "@types/node": "npm:*" + jest-mock: "npm:30.0.5" + jest-util: "npm:30.0.5" + jest-validate: "npm:30.0.5" + checksum: 10/2f0a59370660753ae3e4852a7c905fefab00c4b758e1d19c85b1affe715d40e9563bfa1cccef06a116f610c93fcce2b1a94f4ced99fefea060606a2c10964921 + languageName: node + linkType: hard + "jest-environment-node@npm:30.2.0, jest-environment-node@npm:~30.2.0": version: 30.2.0 resolution: "jest-environment-node@npm:30.2.0" @@ -25263,6 +25647,28 @@ __metadata: languageName: node linkType: hard +"jest-haste-map@npm:30.0.5": + version: 30.0.5 + resolution: "jest-haste-map@npm:30.0.5" + dependencies: + "@jest/types": "npm:30.0.5" + "@types/node": "npm:*" + anymatch: "npm:^3.1.3" + fb-watchman: "npm:^2.0.2" + fsevents: "npm:^2.3.3" + graceful-fs: "npm:^4.2.11" + jest-regex-util: "npm:30.0.1" + jest-util: "npm:30.0.5" + jest-worker: "npm:30.0.5" + micromatch: "npm:^4.0.8" + walker: "npm:^1.0.8" + dependenciesMeta: + fsevents: + optional: true + checksum: 10/3539359589c94a6300c0696fbcfc3df4aad6ee0580cb7873c97a2ae2a009a40e49174b1f25c864302203407e0876e6287fca44b3bbbf7e2c29675436935a347c + languageName: node + linkType: hard + "jest-haste-map@npm:30.2.0": version: 30.2.0 resolution: "jest-haste-map@npm:30.2.0" @@ -25320,6 +25726,16 @@ __metadata: languageName: node linkType: hard +"jest-leak-detector@npm:30.0.5": + version: 30.0.5 + resolution: "jest-leak-detector@npm:30.0.5" + dependencies: + "@jest/get-type": "npm:30.0.1" + pretty-format: "npm:30.0.5" + checksum: 10/60ba8c0afb0a20c0cdd8665469aba7f6663d2e94b01db18174db4986b1f50c0f74e979fa1e70ab78c9215ec8e48e6f43de6b0cdd3b3546c53f47b5ea92e343f0 + languageName: node + linkType: hard + "jest-leak-detector@npm:30.2.0": version: 30.2.0 resolution: "jest-leak-detector@npm:30.2.0" @@ -25549,6 +25965,16 @@ __metadata: languageName: node linkType: hard +"jest-resolve-dependencies@npm:30.0.5": + version: 30.0.5 + resolution: "jest-resolve-dependencies@npm:30.0.5" + dependencies: + jest-regex-util: "npm:30.0.1" + jest-snapshot: "npm:30.0.5" + checksum: 10/8d7d94d96424a8d12e12245a0ed1242262e47587c06fed93aa4c876d549cdd26709dd583775f20b42bbf64e7ab63ba004d560df74989efd8b4cd2e6fbde6acbf + languageName: node + linkType: hard + "jest-resolve-dependencies@npm:30.2.0": version: 30.2.0 resolution: "jest-resolve-dependencies@npm:30.2.0" @@ -25569,6 +25995,22 @@ __metadata: languageName: node linkType: hard +"jest-resolve@npm:30.0.5": + version: 30.0.5 + resolution: "jest-resolve@npm:30.0.5" + dependencies: + chalk: "npm:^4.1.2" + graceful-fs: "npm:^4.2.11" + jest-haste-map: "npm:30.0.5" + jest-pnp-resolver: "npm:^1.2.3" + jest-util: "npm:30.0.5" + jest-validate: "npm:30.0.5" + slash: "npm:^3.0.0" + unrs-resolver: "npm:^1.7.11" + checksum: 10/714c5d93a8ea9f2304ecb97fd3dee8634851836606784d0e02ae033da5abc8af7436d495248f2abcb6bab315952881447740cc272c53da128df573676247db01 + languageName: node + linkType: hard + "jest-resolve@npm:30.2.0": version: 30.2.0 resolution: "jest-resolve@npm:30.2.0" @@ -25602,6 +26044,36 @@ __metadata: languageName: node linkType: hard +"jest-runner@npm:30.0.5": + version: 30.0.5 + resolution: "jest-runner@npm:30.0.5" + dependencies: + "@jest/console": "npm:30.0.5" + "@jest/environment": "npm:30.0.5" + "@jest/test-result": "npm:30.0.5" + "@jest/transform": "npm:30.0.5" + "@jest/types": "npm:30.0.5" + "@types/node": "npm:*" + chalk: "npm:^4.1.2" + emittery: "npm:^0.13.1" + exit-x: "npm:^0.2.2" + graceful-fs: "npm:^4.2.11" + jest-docblock: "npm:30.0.1" + jest-environment-node: "npm:30.0.5" + jest-haste-map: "npm:30.0.5" + jest-leak-detector: "npm:30.0.5" + jest-message-util: "npm:30.0.5" + jest-resolve: "npm:30.0.5" + jest-runtime: "npm:30.0.5" + jest-util: "npm:30.0.5" + jest-watcher: "npm:30.0.5" + jest-worker: "npm:30.0.5" + p-limit: "npm:^3.1.0" + source-map-support: "npm:0.5.13" + checksum: 10/fd6cf9eff7c4ba256fe7cef4348543ff2baea1ca7784ea21fd5f1f060d2ba7125bc70b17d6b1ac0bad450e88e375a344c6e620e28b9222ec3e12f57d3d9a9d5e + languageName: node + linkType: hard + "jest-runner@npm:30.2.0": version: 30.2.0 resolution: "jest-runner@npm:30.2.0" @@ -25661,6 +26133,36 @@ __metadata: languageName: node linkType: hard +"jest-runtime@npm:30.0.5": + version: 30.0.5 + resolution: "jest-runtime@npm:30.0.5" + dependencies: + "@jest/environment": "npm:30.0.5" + "@jest/fake-timers": "npm:30.0.5" + "@jest/globals": "npm:30.0.5" + "@jest/source-map": "npm:30.0.1" + "@jest/test-result": "npm:30.0.5" + "@jest/transform": "npm:30.0.5" + "@jest/types": "npm:30.0.5" + "@types/node": "npm:*" + chalk: "npm:^4.1.2" + cjs-module-lexer: "npm:^2.1.0" + collect-v8-coverage: "npm:^1.0.2" + glob: "npm:^10.3.10" + graceful-fs: "npm:^4.2.11" + jest-haste-map: "npm:30.0.5" + jest-message-util: "npm:30.0.5" + jest-mock: "npm:30.0.5" + jest-regex-util: "npm:30.0.1" + jest-resolve: "npm:30.0.5" + jest-snapshot: "npm:30.0.5" + jest-util: "npm:30.0.5" + slash: "npm:^3.0.0" + strip-bom: "npm:^4.0.0" + checksum: 10/e62825f5b73e6df259c097a8f46ca032fcfba74f7783e12eeaadae3a9a70b8d15b6718c267ed54fca1267916396d986f295dfdb95bbdfadea486b8998f01476e + languageName: node + linkType: hard + "jest-runtime@npm:30.2.0": version: 30.2.0 resolution: "jest-runtime@npm:30.2.0" @@ -25730,6 +26232,35 @@ __metadata: languageName: node linkType: hard +"jest-snapshot@npm:30.0.5": + version: 30.0.5 + resolution: "jest-snapshot@npm:30.0.5" + dependencies: + "@babel/core": "npm:^7.27.4" + "@babel/generator": "npm:^7.27.5" + "@babel/plugin-syntax-jsx": "npm:^7.27.1" + "@babel/plugin-syntax-typescript": "npm:^7.27.1" + "@babel/types": "npm:^7.27.3" + "@jest/expect-utils": "npm:30.0.5" + "@jest/get-type": "npm:30.0.1" + "@jest/snapshot-utils": "npm:30.0.5" + "@jest/transform": "npm:30.0.5" + "@jest/types": "npm:30.0.5" + babel-preset-current-node-syntax: "npm:^1.1.0" + chalk: "npm:^4.1.2" + expect: "npm:30.0.5" + graceful-fs: "npm:^4.2.11" + jest-diff: "npm:30.0.5" + jest-matcher-utils: "npm:30.0.5" + jest-message-util: "npm:30.0.5" + jest-util: "npm:30.0.5" + pretty-format: "npm:30.0.5" + semver: "npm:^7.7.2" + synckit: "npm:^0.11.8" + checksum: 10/954d42b201b76bf08f42dc942426176b2d1223aa44fb01260c90d43545bd9bf90ae4f195800d5e83c0b0ad980633a67a60898f190b26ec48c0deb1d63693eeb0 + languageName: node + linkType: hard + "jest-snapshot@npm:30.2.0": version: 30.2.0 resolution: "jest-snapshot@npm:30.2.0" @@ -25829,6 +26360,20 @@ __metadata: languageName: node linkType: hard +"jest-validate@npm:30.0.5": + version: 30.0.5 + resolution: "jest-validate@npm:30.0.5" + dependencies: + "@jest/get-type": "npm:30.0.1" + "@jest/types": "npm:30.0.5" + camelcase: "npm:^6.3.0" + chalk: "npm:^4.1.2" + leven: "npm:^3.1.0" + pretty-format: "npm:30.0.5" + checksum: 10/5f595dae0edb3f8161343e4d07d6f15030a816fcc51aacfea7fb476b78a94d4daf9a06bc416b7458920a4e39266e24ee2cd8565e8465e487cf0e3cb388092fc1 + languageName: node + linkType: hard + "jest-validate@npm:30.2.0": version: 30.2.0 resolution: "jest-validate@npm:30.2.0" @@ -25874,6 +26419,22 @@ __metadata: languageName: node linkType: hard +"jest-watcher@npm:30.0.5": + version: 30.0.5 + resolution: "jest-watcher@npm:30.0.5" + dependencies: + "@jest/test-result": "npm:30.0.5" + "@jest/types": "npm:30.0.5" + "@types/node": "npm:*" + ansi-escapes: "npm:^4.3.2" + chalk: "npm:^4.1.2" + emittery: "npm:^0.13.1" + jest-util: "npm:30.0.5" + string-length: "npm:^4.0.2" + checksum: 10/e61caeca70ab6fc5608ccf0f4e72add9d56f0bdb0f2a65ee1c9e9c5bbc1b2ffd23e168c505f880d97efb284b8893a51e165c9edd75e30504fee3aa563fc8d4b6 + languageName: node + linkType: hard + "jest-watcher@npm:30.2.0": version: 30.2.0 resolution: "jest-watcher@npm:30.2.0" @@ -25916,6 +26477,19 @@ __metadata: languageName: node linkType: hard +"jest-worker@npm:30.0.5": + version: 30.0.5 + resolution: "jest-worker@npm:30.0.5" + dependencies: + "@types/node": "npm:*" + "@ungap/structured-clone": "npm:^1.3.0" + jest-util: "npm:30.0.5" + merge-stream: "npm:^2.0.0" + supports-color: "npm:^8.1.1" + checksum: 10/04d9a58ddb210a2efe8ad3f46cf54190a5e29edfe4f11847a51fb753be1b4cda7db52b5a786fae833774506b3b75bee9ad7ef26bef81d85914fbcd63555a1c1a + languageName: node + linkType: hard + "jest-worker@npm:30.2.0": version: 30.2.0 resolution: "jest-worker@npm:30.2.0" @@ -25982,6 +26556,25 @@ __metadata: languageName: node linkType: hard +"jest@npm:~30.0.5": + version: 30.0.5 + resolution: "jest@npm:30.0.5" + dependencies: + "@jest/core": "npm:30.0.5" + "@jest/types": "npm:30.0.5" + import-local: "npm:^3.2.0" + jest-cli: "npm:30.0.5" + peerDependencies: + node-notifier: ^8.0.1 || ^9.0.0 || ^10.0.0 + peerDependenciesMeta: + node-notifier: + optional: true + bin: + jest: ./bin/jest.js + checksum: 10/76f0c7f5d43d42a1bc515f683ba4b8978940e5fb0663af8dd0506fc7da6be4d4847f2e1cb8a846632cd62099949c63be69f2f810350a973b029af2d7155ae03a + languageName: node + linkType: hard + "jest@npm:~30.2.0": version: 30.2.0 resolution: "jest@npm:30.2.0" @@ -28216,6 +28809,36 @@ __metadata: languageName: node linkType: hard +"mongodb-memory-server-core@npm:10.2.3": + version: 10.2.3 + resolution: "mongodb-memory-server-core@npm:10.2.3" + dependencies: + async-mutex: "npm:^0.5.0" + camelcase: "npm:^6.3.0" + debug: "npm:^4.4.1" + find-cache-dir: "npm:^3.3.2" + follow-redirects: "npm:^1.15.9" + https-proxy-agent: "npm:^7.0.6" + mongodb: "npm:^6.9.0" + new-find-package-json: "npm:^2.0.0" + semver: "npm:^7.7.2" + tar-stream: "npm:^3.1.7" + tslib: "npm:^2.8.1" + yauzl: "npm:^3.2.0" + checksum: 10/eaf497167ed17fdbc464df5c26d8a9fa42e7acd3ca40c0646b6e0f27d92e31a37248191caadbefdc88c2b91ff7906465f11fd4c120e9e2c56805130723c00c24 + languageName: node + linkType: hard + +"mongodb-memory-server@npm:^10.1.4": + version: 10.2.3 + resolution: "mongodb-memory-server@npm:10.2.3" + dependencies: + mongodb-memory-server-core: "npm:10.2.3" + tslib: "npm:^2.8.1" + checksum: 10/72167e4ca7b9c188aeededa2f4920f7a92d9a18ff6dcc4f5549cefe6ec2eebccf045a72fc0ce3b0e529d055f2f6dc38cf59dca792c3d3a3c62c2ea1d81faf936 + languageName: node + linkType: hard + "mongodb@npm:6.10.0": version: 6.10.0 resolution: "mongodb@npm:6.10.0" @@ -28420,6 +29043,15 @@ __metadata: languageName: node linkType: hard +"new-find-package-json@npm:^2.0.0": + version: 2.0.0 + resolution: "new-find-package-json@npm:2.0.0" + dependencies: + debug: "npm:^4.3.4" + checksum: 10/5488ead794bd506894ddd8f3ac6240615e625ce56241ed6ff41a5ff46bdf495a81881bef6d25a3aa16d25f742e86e5629c2d052cd2f60530db3a85b2b1bd146c + languageName: node + linkType: hard + "nise@npm:^6.1.1": version: 6.1.1 resolution: "nise@npm:6.1.1" @@ -29223,6 +29855,15 @@ __metadata: languageName: node linkType: hard +"p-limit@npm:3.1.0, p-limit@npm:^3.0.1, p-limit@npm:^3.0.2, p-limit@npm:^3.1.0": + version: 3.1.0 + resolution: "p-limit@npm:3.1.0" + dependencies: + yocto-queue: "npm:^0.1.0" + checksum: 10/7c3690c4dbf62ef625671e20b7bdf1cbc9534e83352a2780f165b0d3ceba21907e77ad63401708145ca4e25bfc51636588d89a8c0aeb715e6c37d1c066430360 + languageName: node + linkType: hard + "p-limit@npm:^2.0.0, p-limit@npm:^2.2.0": version: 2.3.0 resolution: "p-limit@npm:2.3.0" @@ -29232,15 +29873,6 @@ __metadata: languageName: node linkType: hard -"p-limit@npm:^3.0.1, p-limit@npm:^3.0.2, p-limit@npm:^3.1.0": - version: 3.1.0 - resolution: "p-limit@npm:3.1.0" - dependencies: - yocto-queue: "npm:^0.1.0" - checksum: 10/7c3690c4dbf62ef625671e20b7bdf1cbc9534e83352a2780f165b0d3ceba21907e77ad63401708145ca4e25bfc51636588d89a8c0aeb715e6c37d1c066430360 - languageName: node - linkType: hard - "p-limit@npm:^4.0.0": version: 4.0.0 resolution: "p-limit@npm:4.0.0" @@ -29796,6 +30428,13 @@ __metadata: languageName: node linkType: hard +"pend@npm:~1.2.0": + version: 1.2.0 + resolution: "pend@npm:1.2.0" + checksum: 10/6c72f5243303d9c60bd98e6446ba7d30ae29e3d56fdb6fae8767e8ba6386f33ee284c97efe3230a0d0217e2b1723b8ab490b1bbf34fcbb2180dbc8a9de47850d + languageName: node + linkType: hard + "performance-now@npm:^2.1.0": version: 2.1.0 resolution: "performance-now@npm:2.1.0" @@ -35017,7 +35656,7 @@ __metadata: languageName: node linkType: hard -"tar-stream@npm:^3.0.0, tar-stream@npm:^3.1.5": +"tar-stream@npm:^3.0.0, tar-stream@npm:^3.1.5, tar-stream@npm:^3.1.7": version: 3.1.7 resolution: "tar-stream@npm:3.1.7" dependencies: @@ -35719,6 +36358,13 @@ __metadata: languageName: node linkType: hard +"tslib@npm:^2.8.1": + version: 2.8.1 + resolution: "tslib@npm:2.8.1" + checksum: 10/3e2e043d5c2316461cb54e5c7fe02c30ef6dccb3384717ca22ae5c6b5bc95232a6241df19c622d9c73b809bea33b187f6dbc73030963e29950c2141bc32a79f7 + languageName: node + linkType: hard + "tsscmp@npm:^1.0.6": version: 1.0.6 resolution: "tsscmp@npm:1.0.6" @@ -36075,7 +36721,7 @@ __metadata: languageName: node linkType: hard -"typescript@npm:~5.9.3": +"typescript@npm:~5.9.2, typescript@npm:~5.9.3": version: 5.9.3 resolution: "typescript@npm:5.9.3" bin: @@ -36085,7 +36731,7 @@ __metadata: languageName: node linkType: hard -"typescript@patch:typescript@npm%3A~5.9.3#optional!builtin": +"typescript@patch:typescript@npm%3A~5.9.2#optional!builtin, typescript@patch:typescript@npm%3A~5.9.3#optional!builtin": version: 5.9.3 resolution: "typescript@patch:typescript@npm%3A5.9.3#optional!builtin::version=5.9.3&hash=5786d5" bin: @@ -37829,6 +38475,16 @@ __metadata: languageName: node linkType: hard +"yauzl@npm:^3.2.0": + version: 3.2.0 + resolution: "yauzl@npm:3.2.0" + dependencies: + buffer-crc32: "npm:~0.2.3" + pend: "npm:~1.2.0" + checksum: 10/a3cd2bfcf7590673bb35750f2a4e5107e3cc939d32d98a072c0673fe42329e390f471b4a53dbbd72512229099b18aa3b79e6ddb87a73b3a17446080c903a2c4b + languageName: node + linkType: hard + "yn@npm:3.1.1": version: 3.1.1 resolution: "yn@npm:3.1.1" From 4591ab9e6ac1d014d7575a74ef15d9752c601b04 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 22 Oct 2025 12:07:12 -0600 Subject: [PATCH 058/125] chore: Prevent default room & team channels from becoming abac managed (and viceversa) (#37264) Co-authored-by: Tasso --- apps/meteor/app/api/server/v1/teams.ts | 8 + .../server/methods/saveRoomSettings.ts | 9 +- .../functions/addUserToDefaultChannels.ts | 5 + apps/meteor/tests/end-to-end/api/abac.ts | 223 +++++++++++++++++- ee/packages/abac/src/index.spec.ts | 66 ++++++ ee/packages/abac/src/index.ts | 64 ++++- 6 files changed, 360 insertions(+), 15 deletions(-) diff --git a/apps/meteor/app/api/server/v1/teams.ts b/apps/meteor/app/api/server/v1/teams.ts index 9ce577522a2d9..f1893def4a7a2 100644 --- a/apps/meteor/app/api/server/v1/teams.ts +++ b/apps/meteor/app/api/server/v1/teams.ts @@ -20,6 +20,7 @@ import { eraseRoom } from '../../../../server/lib/eraseRoom'; import { canAccessRoomAsync } from '../../../authorization/server'; import { hasPermissionAsync, hasAtLeastOnePermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { removeUserFromRoom } from '../../../lib/server/functions/removeUserFromRoom'; +import { settings } from '../../../settings/server'; import { API } from '../api'; import { getPaginationItems } from '../helpers/getPaginationItems'; import { eraseTeam } from '../lib/eraseTeam'; @@ -235,6 +236,13 @@ API.v1.addRoute( } const canUpdateAny = !!(await hasPermissionAsync(this.userId, 'view-all-team-channels', team.roomId)); + if (settings.get('ABAC_Enabled') && isDefault) { + const room = await Rooms.findOneByIdAndType(roomId, 'p', { projection: { abacAttributes: 1 } }); + if (room?.abacAttributes?.length) { + return API.v1.failure('error-room-is-abac-managed'); + } + } + const room = await Team.updateRoom(this.userId, roomId, isDefault, canUpdateAny); return API.v1.success({ room }); diff --git a/apps/meteor/app/channel-settings/server/methods/saveRoomSettings.ts b/apps/meteor/app/channel-settings/server/methods/saveRoomSettings.ts index 7cbdca852cd6d..c2b6b44e99144 100644 --- a/apps/meteor/app/channel-settings/server/methods/saveRoomSettings.ts +++ b/apps/meteor/app/channel-settings/server/methods/saveRoomSettings.ts @@ -11,6 +11,7 @@ import { roomCoordinator } from '../../../../server/lib/rooms/roomCoordinator'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { setRoomAvatar } from '../../../lib/server/functions/setRoomAvatar'; import { notifyOnRoomChangedById } from '../../../lib/server/lib/notifyListener'; +import { settings } from '../../../settings/server'; import { saveReactWhenReadOnly } from '../functions/saveReactWhenReadOnly'; import { saveRoomAnnouncement } from '../functions/saveRoomAnnouncement'; import { saveRoomCustomFields } from '../functions/saveRoomCustomFields'; @@ -62,13 +63,19 @@ const hasRetentionPolicy = (room: IRoom & { retention?: any }): room is IRoomWit 'retention' in room && room.retention !== undefined; const validators: RoomSettingsValidators = { - async default({ userId }) { + async default({ userId, room, value }) { if (!(await hasPermissionAsync(userId, 'view-room-administration'))) { throw new Meteor.Error('error-action-not-allowed', 'Viewing room administration is not allowed', { method: 'saveRoomSettings', action: 'Viewing_room_administration', }); } + if (settings.get('ABAC_Enabled') && value && room?.abacAttributes?.length) { + throw new Meteor.Error('error-action-not-allowed', 'Setting an ABAC managed room as default is not allowed', { + method: 'saveRoomSettings', + action: 'Viewing_room_administration', + }); + } }, async featured({ userId }) { if (!(await hasPermissionAsync(userId, 'view-room-administration'))) { diff --git a/apps/meteor/app/lib/server/functions/addUserToDefaultChannels.ts b/apps/meteor/app/lib/server/functions/addUserToDefaultChannels.ts index a74964cc05a00..89f4e5e352755 100644 --- a/apps/meteor/app/lib/server/functions/addUserToDefaultChannels.ts +++ b/apps/meteor/app/lib/server/functions/addUserToDefaultChannels.ts @@ -5,6 +5,7 @@ import { Subscriptions } from '@rocket.chat/models'; import { getDefaultChannels } from './getDefaultChannels'; import { callbacks } from '../../../../server/lib/callbacks'; import { getSubscriptionAutotranslateDefaultConfig } from '../../../../server/lib/getSubscriptionAutotranslateDefaultConfig'; +import { settings } from '../../../settings/server'; import { getDefaultSubscriptionPref } from '../../../utils/lib/getDefaultSubscriptionPref'; import { notifyOnSubscriptionChangedById } from '../lib/notifyListener'; @@ -13,6 +14,10 @@ export const addUserToDefaultChannels = async function (user: IUser, silenced?: const defaultRooms = await getDefaultChannels(); for await (const room of defaultRooms) { + if (settings.get('ABAC_Enabled') && room?.abacAttributes?.length) { + continue; + } + if (!(await Subscriptions.findOneByRoomIdAndUserId(room._id, user._id, { projection: { _id: 1 } }))) { const autoTranslateConfig = getSubscriptionAutotranslateDefaultConfig(user); diff --git a/apps/meteor/tests/end-to-end/api/abac.ts b/apps/meteor/tests/end-to-end/api/abac.ts index 0b506c6875649..5a7e4480b1dde 100644 --- a/apps/meteor/tests/end-to-end/api/abac.ts +++ b/apps/meteor/tests/end-to-end/api/abac.ts @@ -6,6 +6,7 @@ import { before, after, describe, it } from 'mocha'; import { getCredentials, request, credentials } from '../../data/api-data'; import { updatePermission, updateSetting } from '../../data/permissions.helper'; import { createRoom, deleteRoom } from '../../data/rooms.helper'; +import { deleteTeam } from '../../data/teams.helper'; import { password } from '../../data/user'; import { createUser, deleteUser, login } from '../../data/users.helper'; import { IS_EE } from '../../e2e/config/constants'; @@ -396,6 +397,152 @@ import { IS_EE } from '../../e2e/config/constants'; }); }); + describe('Default and Team Default Room Restrictions', () => { + let privateDefaultRoomId: string; + let teamId: string; + let teamPrivateRoomId: string; + let teamDefaultRoomId: string; + const localAbacKey = `default_team_test_${Date.now()}`; + let mainRoomIdSaveSettings: string; + const teamName = `abac-team-${Date.now()}`; + const teamNameMainRoom = `abac-team-main-save-settings-${Date.now()}`; + + before('create team main room for rooms.saveRoomSettings default restriction test', async () => { + const createTeamMain = await request + .post(`${v1}/teams.create`) + .set(credentials) + .send({ name: teamNameMainRoom, type: 1 }) + .expect(200); + + mainRoomIdSaveSettings = createTeamMain.body.team?.roomId; + + await request.post(`${v1}/rooms.saveRoomSettings`).set(credentials).send({ rid: mainRoomIdSaveSettings, default: true }).expect(200); + }); + + before('create local ABAC attribute definition for tests', async () => { + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: localAbacKey, values: ['red', 'green'] }) + .expect(200); + }); + + before('create private room and try to set it as default', async () => { + const res = await createRoom({ + type: 'p', + name: `abac-default-room-${Date.now()}`, + }); + privateDefaultRoomId = res.body.group._id; + + await request.post(`${v1}/rooms.saveRoomSettings`).set(credentials).send({ rid: privateDefaultRoomId, default: true }).expect(200); + }); + + before('create private team, private room inside it and set as team default', async () => { + const createTeamRes = await request.post(`${v1}/teams.create`).set(credentials).send({ name: teamName, type: 0 }).expect(200); + teamId = createTeamRes.body.team._id; + + const roomRes = await createRoom({ + type: 'p', + name: `abac-team-room-${Date.now()}`, + extraData: { teamId }, + }); + teamPrivateRoomId = roomRes.body.group._id; + + const setDefaultRes = await request + .post(`${v1}/teams.updateRoom`) + .set(credentials) + .send({ teamId, roomId: teamPrivateRoomId, isDefault: true }) + .expect(200); + + if (setDefaultRes.body?.room?.teamDefault) { + teamDefaultRoomId = teamPrivateRoomId; + } + }); + + it('should fail adding ABAC attribute to private default room', async () => { + await request + .post(`${v1}/abac/room/${privateDefaultRoomId}/attributes/${localAbacKey}`) + .set(credentials) + .send({ values: ['red'] }) + .expect(400) + .expect((res) => { + expect(res.body.success).to.be.false; + expect(res.body.error).to.include('error-cannot-convert-default-room-to-abac'); + }); + }); + + it('should fail adding ABAC attribute to team default private room', async () => { + await request + .post(`${v1}/abac/room/${teamDefaultRoomId}/attributes/${localAbacKey}`) + .set(credentials) + .send({ values: ['red'] }) + .expect(400) + .expect((res) => { + expect(res.body.success).to.be.false; + expect(res.body.error).to.include('error-cannot-convert-default-room-to-abac'); + }); + }); + + it('should allow adding ABAC attribute after removing default flag from private room', async () => { + await request.post(`${v1}/rooms.saveRoomSettings`).set(credentials).send({ rid: privateDefaultRoomId, default: false }).expect(200); + + await request + .post(`${v1}/abac/room/${privateDefaultRoomId}/attributes/${localAbacKey}`) + .set(credentials) + .send({ values: ['red'] }) + .expect(200) + .expect((res) => { + expect(res.body.success).to.be.true; + }); + }); + + it('should allow adding ABAC attribute after removing team default flag', async () => { + await request + .post(`${v1}/teams.updateRoom`) + .set(credentials) + .send({ teamId, roomId: teamDefaultRoomId, isDefault: false }) + .expect(200); + + await request + .post(`${v1}/abac/room/${teamDefaultRoomId}/attributes/${localAbacKey}`) + .set(credentials) + .send({ values: ['green'] }) + .expect(200) + .expect((res) => { + expect(res.body.success).to.be.true; + }); + }); + + it('should enforce restriction on team main room when default using rooms.saveRoomSettings', async () => { + await request + .post(`${v1}/abac/room/${mainRoomIdSaveSettings}/attributes/${localAbacKey}`) + .set(credentials) + .send({ values: ['red'] }) + .expect(400) + .expect((res) => { + expect(res.body.success).to.be.false; + expect(res.body.error).to.include('error-cannot-convert-default-room-to-abac'); + }); + + await request.post(`${v1}/rooms.saveRoomSettings`).set(credentials).send({ rid: mainRoomIdSaveSettings, default: false }).expect(200); + + await request + .post(`${v1}/abac/room/${mainRoomIdSaveSettings}/attributes/${localAbacKey}`) + .set(credentials) + .send({ values: ['red'] }) + .expect(200) + .expect((res) => { + expect(res.body.success).to.be.true; + }); + }); + + after(async () => { + await deleteRoom({ type: 'p', roomId: privateDefaultRoomId }); + await deleteTeam(credentials, teamName); + await deleteTeam(credentials, teamNameMainRoom); + }); + }); + describe('Usage & Deletion', () => { it('POST add room usage for attribute (re-add after clearing) and expect delete while in use to fail', async () => { await request @@ -447,8 +594,82 @@ import { IS_EE } from '../../e2e/config/constants'; }); }); + describe('ABAC Managed Room Default Conversion Restrictions', () => { + const conversionAttrKey = `conversion_test_${Date.now()}`; + const teamName = `abac-conversion-team-${Date.now()}`; + let abacRoomId: string; + let teamIdForConversion: string; + let teamRoomId: string; + + before('create attribute definition and ABAC-managed private room', async () => { + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: conversionAttrKey, values: ['alpha', 'beta'] }) + .expect(200); + + const roomRes = await createRoom({ + type: 'p', + name: `abac-conversion-room-${Date.now()}`, + }); + abacRoomId = roomRes.body.group._id; + + await request + .post(`${v1}/abac/room/${abacRoomId}/attributes/${conversionAttrKey}`) + .set(credentials) + .send({ values: ['alpha'] }) + .expect(200); + }); + + before('create team, private room inside, add ABAC attribute', async () => { + // Public team + const teamRes = await request.post(`${v1}/teams.create`).set(credentials).send({ name: teamName, type: 0 }).expect(200); + teamIdForConversion = teamRes.body.team._id; + + const teamRoomRes = await createRoom({ + type: 'p', + name: `abac-team-conversion-room-${Date.now()}`, + extraData: { teamId: teamIdForConversion }, + }); + teamRoomId = teamRoomRes.body.group._id; + + await request + .post(`${v1}/abac/room/${teamRoomId}/attributes/${conversionAttrKey}`) + .set(credentials) + .send({ values: ['beta'] }) + .expect(200); + }); + + after(async () => { + await Promise.all([deleteTeam(credentials, teamName), deleteRoom({ type: 'p', roomId: abacRoomId })]); + }); + + it('should fail converting ABAC-managed private room into default room', async () => { + await request + .post(`${v1}/rooms.saveRoomSettings`) + .set(credentials) + .send({ rid: abacRoomId, default: true }) + .expect(400) + .expect((res) => { + expect(res.body.success).to.be.false; + expect(res.body.error).to.include('Setting an ABAC managed room as default is not allowed [error-action-not-allowed]'); + }); + }); + + it('should fail converting ABAC-managed team room into team default room', async () => { + await request + .post(`${v1}/teams.updateRoom`) + .set(credentials) + .send({ teamId: teamIdForConversion, roomId: teamRoomId, isDefault: true }) + .expect(400) + .expect((res) => { + expect(res.body.success).to.be.false; + expect(res.body.error).to.include('error-room-is-abac-managed'); + }); + }); + }); + describe('Extended Validations & Edge Cases', () => { - // eslint-disable-next-line @typescript-eslint/no-unused-vars let secondAttributeId: string; const firstKey = `${initialKey}_first`; const secondKey = `${initialKey}_second`; diff --git a/ee/packages/abac/src/index.spec.ts b/ee/packages/abac/src/index.spec.ts index f16a1c83bf222..ef397b80d0628 100644 --- a/ee/packages/abac/src/index.spec.ts +++ b/ee/packages/abac/src/index.spec.ts @@ -389,6 +389,18 @@ describe('AbacService (unit)', () => { expect(mockSetAbacAttributesById).not.toHaveBeenCalled(); }); + it('throws error-cannot-convert-default-room-to-abac when room is default', async () => { + mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [], default: true }); + await expect(service.setRoomAbacAttributes('r1', { dept: ['eng'] })).rejects.toThrow('error-cannot-convert-default-room-to-abac'); + expect(mockSetAbacAttributesById).not.toHaveBeenCalled(); + }); + + it('throws error-cannot-convert-default-room-to-abac when room is teamDefault', async () => { + mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [], teamDefault: true }); + await expect(service.setRoomAbacAttributes('r1', { dept: ['eng'] })).rejects.toThrow('error-cannot-convert-default-room-to-abac'); + expect(mockSetAbacAttributesById).not.toHaveBeenCalled(); + }); + it('throws error-invalid-attribute-key for invalid key format', async () => { mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [] }); await expect(service.setRoomAbacAttributes('r1', { 'bad key': ['v'] } as any)).rejects.toThrow('error-invalid-attribute-key'); @@ -515,6 +527,20 @@ describe('AbacService (unit)', () => { await expect(service.updateRoomAbacAttributeValues('missing', 'dept', ['eng'])).rejects.toThrow('error-room-not-found'); }); + it('throws error-cannot-convert-default-room-to-abac when room is default', async () => { + mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [], default: true }); + await expect(service.updateRoomAbacAttributeValues('r1', 'dept', ['eng'])).rejects.toThrow( + 'error-cannot-convert-default-room-to-abac', + ); + }); + + it('throws error-cannot-convert-default-room-to-abac when room is teamDefault', async () => { + mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [], teamDefault: true }); + await expect(service.updateRoomAbacAttributeValues('r1', 'dept', ['eng'])).rejects.toThrow( + 'error-cannot-convert-default-room-to-abac', + ); + }); + it('throws error-invalid-attribute-values if adding new key exceeds max attributes', async () => { const existing = Array.from({ length: 10 }, (_, i) => ({ key: `k${i}`, values: ['x'] })); mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: existing }); @@ -572,6 +598,18 @@ describe('AbacService (unit)', () => { expect(mockRemoveAbacAttributeByRoomIdAndKey).not.toHaveBeenCalled(); }); + it('throws error-cannot-convert-default-room-to-abac when room is default', async () => { + mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [], default: true }); + await expect((service as any).removeRoomAbacAttribute('r1', 'dept')).rejects.toThrow('error-cannot-convert-default-room-to-abac'); + expect(mockRemoveAbacAttributeByRoomIdAndKey).not.toHaveBeenCalled(); + }); + + it('throws error-cannot-convert-default-room-to-abac when room is teamDefault', async () => { + mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [], teamDefault: true }); + await expect((service as any).removeRoomAbacAttribute('r1', 'dept')).rejects.toThrow('error-cannot-convert-default-room-to-abac'); + expect(mockRemoveAbacAttributeByRoomIdAndKey).not.toHaveBeenCalled(); + }); + it('returns early (no update, no hook) when attribute key not present', async () => { mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [{ key: 'other', values: ['x'] }] }); await (service as any).removeRoomAbacAttribute('r1', 'dept'); @@ -614,6 +652,20 @@ describe('AbacService (unit)', () => { await expect((service as any).replaceRoomAbacAttributeByKey('missing', 'dept', ['eng'])).rejects.toThrow('error-room-not-found'); }); + it('throws error-cannot-convert-default-room-to-abac when room is default', async () => { + mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [], default: true }); + await expect((service as any).replaceRoomAbacAttributeByKey('r1', 'dept', ['eng'])).rejects.toThrow( + 'error-cannot-convert-default-room-to-abac', + ); + }); + + it('throws error-cannot-convert-default-room-to-abac when room is teamDefault', async () => { + mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [], teamDefault: true }); + await expect((service as any).replaceRoomAbacAttributeByKey('r1', 'dept', ['eng'])).rejects.toThrow( + 'error-cannot-convert-default-room-to-abac', + ); + }); + it('throws error-invalid-attribute-values if adding new key exceeds max attributes', async () => { const existing = Array.from({ length: 10 }, (_, i) => ({ key: `k${i}`, values: ['x'] })); mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: existing }); @@ -684,6 +736,20 @@ describe('AbacService (unit)', () => { expect(mockInsertAbacAttributeIfNotExistsById).not.toHaveBeenCalled(); }); + it('throws error-cannot-convert-default-room-to-abac when room is default', async () => { + mockAbacFind.mockReturnValueOnce({ toArray: async () => [{ key: 'dept', values: ['eng'] }] }); + mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [], default: true }); + await expect(service.addRoomAbacAttributeByKey('r1', 'dept', ['eng'])).rejects.toThrow('error-cannot-convert-default-room-to-abac'); + expect(mockInsertAbacAttributeIfNotExistsById).not.toHaveBeenCalled(); + }); + + it('throws error-cannot-convert-default-room-to-abac when room is teamDefault', async () => { + mockAbacFind.mockReturnValueOnce({ toArray: async () => [{ key: 'dept', values: ['eng'] }] }); + mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [], teamDefault: true }); + await expect(service.addRoomAbacAttributeByKey('r1', 'dept', ['eng'])).rejects.toThrow('error-cannot-convert-default-room-to-abac'); + expect(mockInsertAbacAttributeIfNotExistsById).not.toHaveBeenCalled(); + }); + it('throws error-attribute-definition-not-found when attribute definition missing', async () => { // No definitions returned mockAbacFind.mockReturnValueOnce({ toArray: async () => [] }); diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index 3106bdc713cec..7677f243bf3df 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -170,12 +170,19 @@ export class AbacService extends ServiceClass implements IAbacService { } async setRoomAbacAttributes(rid: string, attributes: Record): Promise { - const room = await Rooms.findOneByIdAndType>(rid, 'p', { - projection: { abacAttributes: 1, t: 1, teamMain: 1 }, - }); + const room = await Rooms.findOneByIdAndType>( + rid, + 'p', + { + projection: { abacAttributes: 1, t: 1, teamMain: 1, teamDefault: 1, default: 1 }, + }, + ); if (!room) { throw new Error('error-room-not-found'); } + if (room.default || room.teamDefault) { + throw new Error('error-cannot-convert-default-room-to-abac'); + } const normalized = this.validateAndNormalizeAttributes(attributes); @@ -260,12 +267,21 @@ export class AbacService extends ServiceClass implements IAbacService { } async updateRoomAbacAttributeValues(rid: string, key: string, values: string[]): Promise { - const room = await Rooms.findOneByIdAndType>(rid, 'p', { - projection: { abacAttributes: 1, t: 1, teamMain: 1 }, - }); + const room = await Rooms.findOneByIdAndType>( + rid, + 'p', + { + projection: { abacAttributes: 1, t: 1, teamMain: 1, teamDefault: 1, default: 1 }, + }, + ); if (!room) { throw new Error('error-room-not-found'); } + + if (room.default || room.teamDefault) { + throw new Error('error-cannot-convert-default-room-to-abac'); + } + const previous: IAbacAttributeDefinition[] = room.abacAttributes || []; const existingIndex = previous.findIndex((a) => a.key === key); @@ -304,11 +320,17 @@ export class AbacService extends ServiceClass implements IAbacService { } async removeRoomAbacAttribute(rid: string, key: string): Promise { - const room = await Rooms.findOneByIdAndType>(rid, 'p', { projection: { abacAttributes: 1 } }); + const room = await Rooms.findOneByIdAndType>(rid, 'p', { + projection: { abacAttributes: 1, default: 1, teamDefault: 1 }, + }); if (!room) { throw new Error('error-room-not-found'); } + if (room.default || room.teamDefault) { + throw new Error('error-cannot-convert-default-room-to-abac'); + } + const previous: IAbacAttributeDefinition[] = room.abacAttributes || []; const exists = previous.some((a) => a.key === key); if (!exists) { @@ -326,13 +348,21 @@ export class AbacService extends ServiceClass implements IAbacService { async addRoomAbacAttributeByKey(rid: string, key: string, values: string[]): Promise { await this.ensureAttributeDefinitionsExist([{ key, values }]); - const room = await Rooms.findOneByIdAndType>(rid, 'p', { - projection: { abacAttributes: 1, t: 1, teamMain: 1 }, - }); + const room = await Rooms.findOneByIdAndType>( + rid, + 'p', + { + projection: { abacAttributes: 1, t: 1, teamMain: 1, teamDefault: 1, default: 1 }, + }, + ); if (!room) { throw new Error('error-room-not-found'); } + if (room.default || room.teamDefault) { + throw new Error('error-cannot-convert-default-room-to-abac'); + } + const previous: IAbacAttributeDefinition[] = room.abacAttributes || []; if (previous.some((a) => a.key === key)) { throw new Error('error-duplicate-attribute-key'); @@ -351,13 +381,21 @@ export class AbacService extends ServiceClass implements IAbacService { async replaceRoomAbacAttributeByKey(rid: string, key: string, values: string[]): Promise { await this.ensureAttributeDefinitionsExist([{ key, values }]); - const room = await Rooms.findOneByIdAndType>(rid, 'p', { - projection: { abacAttributes: 1, t: 1, teamMain: 1 }, - }); + const room = await Rooms.findOneByIdAndType>( + rid, + 'p', + { + projection: { abacAttributes: 1, t: 1, teamMain: 1, teamDefault: 1, default: 1 }, + }, + ); if (!room) { throw new Error('error-room-not-found'); } + if (room.default || room.teamDefault) { + throw new Error('error-cannot-convert-default-room-to-abac'); + } + const exists = room?.abacAttributes?.some((a) => a.key === key); if (exists) { From d92dea188551cf2d1aa30b9ac26e8b2f6995a323 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Fri, 24 Oct 2025 13:03:38 -0600 Subject: [PATCH 059/125] feat: Prevent LDAP sync from adding users to abac rooms/teams (#37299) --- apps/meteor/ee/server/lib/ldap/Manager.ts | 23 ++++++++++++++++++- .../model-typings/src/models/IRoomsModel.ts | 1 + packages/models/src/models/Rooms.ts | 10 ++++++++ 3 files changed, 33 insertions(+), 1 deletion(-) diff --git a/apps/meteor/ee/server/lib/ldap/Manager.ts b/apps/meteor/ee/server/lib/ldap/Manager.ts index 5baf73e6f93b5..b15dfc6d34335 100644 --- a/apps/meteor/ee/server/lib/ldap/Manager.ts +++ b/apps/meteor/ee/server/lib/ldap/Manager.ts @@ -358,6 +358,11 @@ export class LDAPEEManager extends LDAPManager { return; } + if (settings.get('ABAC_Enabled') && room?.abacAttributes?.length) { + logger.error({ msg: 'Cannot add user to channel. Channel is ABAC managed', userChannelName }); + continue; + } + if (room.teamMain) { logger.error(`Can't add user to channel ${userChannelName} because it is a team.`); } else { @@ -430,7 +435,23 @@ export class LDAPEEManager extends LDAPManager { }); const currentTeamIds = currentTeams?.map(({ teamId }) => teamId); const teamsToRemove = currentTeamIds?.filter((teamId) => notInTeamIds.includes(teamId)); - const teamsToAdd = inTeamIds.filter((teamId) => !currentTeamIds?.includes(teamId)); + let teamsToAdd = inTeamIds.filter((teamId) => !currentTeamIds?.includes(teamId)); + + if (settings.get('ABAC_Enabled')) { + const roomsWithAbacAttributes = await Rooms.findPrivateRoomsByIdsWithAbacAttributes( + allTeams.filter((t) => teamsToAdd.includes(t._id)).map((t) => t.roomId), + { projection: { teamId: 1 } }, + ) + .map((r) => r.teamId) + .toArray(); + + logger.debug({ msg: 'Some teams will be ignored from sync because they are abac managed', roomsWithAbacAttributes }); + + teamsToAdd = teamsToAdd.filter((teamId) => !roomsWithAbacAttributes.includes(teamId)); + if (!teamsToAdd.length) { + return; + } + } await Team.insertMemberOnTeams(user._id, teamsToAdd); if (teamsToRemove) { diff --git a/packages/model-typings/src/models/IRoomsModel.ts b/packages/model-typings/src/models/IRoomsModel.ts index d25d38cf0b762..bde58d7962586 100644 --- a/packages/model-typings/src/models/IRoomsModel.ts +++ b/packages/model-typings/src/models/IRoomsModel.ts @@ -219,6 +219,7 @@ export interface IRoomsModel extends IBaseModel { findByIds(rids: string[], options?: FindOptions): FindCursor; findByType(type: IRoom['t'], options?: FindOptions): FindCursor; findByTypeInIds(type: IRoom['t'], ids: string[], options?: FindOptions): FindCursor; + findPrivateRoomsByIdsWithAbacAttributes(ids: string[], options?: FindOptions): FindCursor; findBySubscriptionUserId(userId: string, options?: FindOptions): Promise>; findBySubscriptionUserIdUpdatedAfter(userId: string, updatedAfter: Date, options?: FindOptions): Promise>; findByNameAndTypeNotDefault( diff --git a/packages/models/src/models/Rooms.ts b/packages/models/src/models/Rooms.ts index 23130e1d56d0a..f590504aae3e8 100644 --- a/packages/models/src/models/Rooms.ts +++ b/packages/models/src/models/Rooms.ts @@ -1277,6 +1277,16 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { return this.find(query, options); } + findPrivateRoomsByIdsWithAbacAttributes(ids: Array, options: FindOptions = {}): FindCursor { + const query: Filter = { + _id: { $in: ids }, + t: 'p', + abacAttributes: { $exists: true, $ne: [] }, + }; + + return this.find(query, options); + } + async findBySubscriptionUserId(userId: IUser['_id'], options: FindOptions = {}): Promise> { const data = (await Subscriptions.findByUserId(userId, { projection: { rid: 1 } }).toArray()).map((item) => item.rid); From ac010a62453292d2d4ced5c554aa44239faca3ce Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Fri, 24 Oct 2025 14:02:19 -0600 Subject: [PATCH 060/125] chore: Unset attributes instead of leaving empty array (#37301) --- ee/packages/abac/src/index.ts | 11 +++++++++++ packages/model-typings/src/models/IRoomsModel.ts | 1 + packages/models/src/models/Rooms.ts | 4 ++++ 3 files changed, 16 insertions(+) diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index 7677f243bf3df..b11c7992d72b5 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -184,6 +184,11 @@ export class AbacService extends ServiceClass implements IAbacService { throw new Error('error-cannot-convert-default-room-to-abac'); } + if (!Object.keys(attributes).length) { + await Rooms.unsetAbacAttributesById(rid); + return; + } + const normalized = this.validateAndNormalizeAttributes(attributes); await this.ensureAttributeDefinitionsExist(normalized); @@ -337,6 +342,12 @@ export class AbacService extends ServiceClass implements IAbacService { return; } + // if is the last attribute, just remove all + if (previous.length === 1) { + await Rooms.unsetAbacAttributesById(rid); + return; + } + await Rooms.removeAbacAttributeByRoomIdAndKey(rid, key); this.logger.debug({ msg: 'Room ABAC attribute removed', diff --git a/packages/model-typings/src/models/IRoomsModel.ts b/packages/model-typings/src/models/IRoomsModel.ts index bde58d7962586..d1d60009319c0 100644 --- a/packages/model-typings/src/models/IRoomsModel.ts +++ b/packages/model-typings/src/models/IRoomsModel.ts @@ -320,6 +320,7 @@ export interface IRoomsModel extends IBaseModel { countDistinctFederationRoomsExcluding(serverNames?: string[]): Promise; updateAbacConfigurationById(rid: IRoom['_id'], abac: boolean): Promise; setAbacAttributesById(rid: IRoom['_id'], attributes: NonNullable): Promise; + unsetAbacAttributesById(rid: IRoom['_id']): Promise; updateSingleAbacAttributeValuesById(rid: IRoom['_id'], key: string, values: string[]): Promise; insertAbacAttributeIfNotExistsById(rid: IRoom['_id'], key: string, values: string[]): Promise; updateAbacAttributeValuesArrayFilteredById(rid: IRoom['_id'], key: string, values: string[]): Promise; diff --git a/packages/models/src/models/Rooms.ts b/packages/models/src/models/Rooms.ts index f590504aae3e8..297391db494a5 100644 --- a/packages/models/src/models/Rooms.ts +++ b/packages/models/src/models/Rooms.ts @@ -1980,6 +1980,10 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { return this.findOneAndUpdate({ _id }, { $set: { abacAttributes: attributes } }, { returnDocument: 'after' }); } + unsetAbacAttributesById(_id: IRoom['_id']): Promise { + return this.updateOne({ _id }, { $unset: { abacAttributes: 1 } }); + } + updateSingleAbacAttributeValuesById(_id: IRoom['_id'], key: string, values: string[]): Promise { const query: Filter = { _id, 'abacAttributes.key': key }; From f10bafe3831536ae3878392f23db31e3eac119e0 Mon Sep 17 00:00:00 2001 From: Tasso Date: Fri, 24 Oct 2025 23:13:44 -0300 Subject: [PATCH 061/125] chore: Re-route endpoints from `abac/room/*` to `abac/rooms/*` --- apps/meteor/ee/server/api/abac/index.ts | 10 +-- apps/meteor/tests/end-to-end/api/abac.ts | 78 +++++++++++++----------- 2 files changed, 46 insertions(+), 42 deletions(-) diff --git a/apps/meteor/ee/server/api/abac/index.ts b/apps/meteor/ee/server/api/abac/index.ts index 2d5e0b7be23da..40e5f1336252a 100644 --- a/apps/meteor/ee/server/api/abac/index.ts +++ b/apps/meteor/ee/server/api/abac/index.ts @@ -21,7 +21,7 @@ import { settings } from '../../../../app/settings/server'; const abacEndpoints = API.v1 .post( - 'abac/room/:rid/attributes', + 'abac/rooms/:rid/attributes', { authRequired: true, permissionsRequired: ['abac-management'], @@ -49,7 +49,7 @@ const abacEndpoints = API.v1 }, ) .delete( - 'abac/room/:rid/attributes', + 'abac/rooms/:rid/attributes', { authRequired: true, permissionsRequired: ['abac-management'], @@ -72,7 +72,7 @@ const abacEndpoints = API.v1 ) // add an abac attribute by key .post( - 'abac/room/:rid/attributes/:key', + 'abac/rooms/:rid/attributes/:key', { authRequired: true, permissionsRequired: ['abac-management'], @@ -99,7 +99,7 @@ const abacEndpoints = API.v1 ) // edit a room attribute .put( - 'abac/room/:rid/attributes/:key', + 'abac/rooms/:rid/attributes/:key', { authRequired: true, permissionsRequired: ['abac-management'], @@ -126,7 +126,7 @@ const abacEndpoints = API.v1 ) // delete a room attribute .delete( - 'abac/room/:rid/attributes/:key', + 'abac/rooms/:rid/attributes/:key', { authRequired: true, permissionsRequired: ['abac-management'], diff --git a/apps/meteor/tests/end-to-end/api/abac.ts b/apps/meteor/tests/end-to-end/api/abac.ts index 5a7e4480b1dde..90ddd307db2fe 100644 --- a/apps/meteor/tests/end-to-end/api/abac.ts +++ b/apps/meteor/tests/end-to-end/api/abac.ts @@ -282,7 +282,7 @@ import { IS_EE } from '../../e2e/config/constants'; it('POST room attribute should fail with duplicate values', async () => { await request - .post(`${v1}/abac/room/${testRoom._id}/attributes/${updatedKey}`) + .post(`${v1}/abac/rooms/${testRoom._id}/attributes/${updatedKey}`) .set(credentials) .send({ values: ['dup', 'dup'] }) .expect(400) @@ -293,7 +293,7 @@ import { IS_EE } from '../../e2e/config/constants'; it('POST room attribute should add values and reflect usage/inUse=true', async () => { await request - .post(`${v1}/abac/room/${testRoom._id}/attributes/${updatedKey}`) + .post(`${v1}/abac/rooms/${testRoom._id}/attributes/${updatedKey}`) .set(credentials) .send({ values: ['cyan'] }) .expect(200) @@ -326,7 +326,7 @@ import { IS_EE } from '../../e2e/config/constants'; it('PUT room attribute should replace values and update usage map accordingly', async () => { await request - .put(`${v1}/abac/room/${testRoom._id}/attributes/${updatedKey}`) + .put(`${v1}/abac/rooms/${testRoom._id}/attributes/${updatedKey}`) .set(credentials) .send({ values: ['magenta', 'yellow'] }) .expect(200) @@ -357,7 +357,7 @@ import { IS_EE } from '../../e2e/config/constants'; it('DELETE room attribute key should succeed and clear usage/inUse=false', async () => { await request - .delete(`${v1}/abac/room/${testRoom._id}/attributes/${updatedKey}`) + .delete(`${v1}/abac/rooms/${testRoom._id}/attributes/${updatedKey}`) .set(credentials) .expect(200) .expect((res) => { @@ -386,7 +386,7 @@ import { IS_EE } from '../../e2e/config/constants'; await updateSetting('ABAC_Enabled', false); await request - .delete(`${v1}/abac/room/${testRoom._id}/attributes`) + .delete(`${v1}/abac/rooms/${testRoom._id}/attributes`) .set(credentials) .expect(200) .expect((res) => { @@ -461,7 +461,7 @@ import { IS_EE } from '../../e2e/config/constants'; it('should fail adding ABAC attribute to private default room', async () => { await request - .post(`${v1}/abac/room/${privateDefaultRoomId}/attributes/${localAbacKey}`) + .post(`${v1}/abac/rooms/${privateDefaultRoomId}/attributes/${localAbacKey}`) .set(credentials) .send({ values: ['red'] }) .expect(400) @@ -473,7 +473,7 @@ import { IS_EE } from '../../e2e/config/constants'; it('should fail adding ABAC attribute to team default private room', async () => { await request - .post(`${v1}/abac/room/${teamDefaultRoomId}/attributes/${localAbacKey}`) + .post(`${v1}/abac/rooms/${teamDefaultRoomId}/attributes/${localAbacKey}`) .set(credentials) .send({ values: ['red'] }) .expect(400) @@ -487,7 +487,7 @@ import { IS_EE } from '../../e2e/config/constants'; await request.post(`${v1}/rooms.saveRoomSettings`).set(credentials).send({ rid: privateDefaultRoomId, default: false }).expect(200); await request - .post(`${v1}/abac/room/${privateDefaultRoomId}/attributes/${localAbacKey}`) + .post(`${v1}/abac/rooms/${privateDefaultRoomId}/attributes/${localAbacKey}`) .set(credentials) .send({ values: ['red'] }) .expect(200) @@ -504,7 +504,7 @@ import { IS_EE } from '../../e2e/config/constants'; .expect(200); await request - .post(`${v1}/abac/room/${teamDefaultRoomId}/attributes/${localAbacKey}`) + .post(`${v1}/abac/rooms/${teamDefaultRoomId}/attributes/${localAbacKey}`) .set(credentials) .send({ values: ['green'] }) .expect(200) @@ -515,7 +515,7 @@ import { IS_EE } from '../../e2e/config/constants'; it('should enforce restriction on team main room when default using rooms.saveRoomSettings', async () => { await request - .post(`${v1}/abac/room/${mainRoomIdSaveSettings}/attributes/${localAbacKey}`) + .post(`${v1}/abac/rooms/${mainRoomIdSaveSettings}/attributes/${localAbacKey}`) .set(credentials) .send({ values: ['red'] }) .expect(400) @@ -527,7 +527,7 @@ import { IS_EE } from '../../e2e/config/constants'; await request.post(`${v1}/rooms.saveRoomSettings`).set(credentials).send({ rid: mainRoomIdSaveSettings, default: false }).expect(200); await request - .post(`${v1}/abac/room/${mainRoomIdSaveSettings}/attributes/${localAbacKey}`) + .post(`${v1}/abac/rooms/${mainRoomIdSaveSettings}/attributes/${localAbacKey}`) .set(credentials) .send({ values: ['red'] }) .expect(200) @@ -546,7 +546,7 @@ import { IS_EE } from '../../e2e/config/constants'; describe('Usage & Deletion', () => { it('POST add room usage for attribute (re-add after clearing) and expect delete while in use to fail', async () => { await request - .post(`${v1}/abac/room/${testRoom._id}/attributes/${updatedKey}`) + .post(`${v1}/abac/rooms/${testRoom._id}/attributes/${updatedKey}`) .set(credentials) .send({ values: ['cyan'] }) .expect(200); @@ -572,7 +572,7 @@ import { IS_EE } from '../../e2e/config/constants'; }); // Remove room attribute again - await request.delete(`${v1}/abac/room/${testRoom._id}/attributes/${updatedKey}`).set(credentials).expect(200); + await request.delete(`${v1}/abac/rooms/${testRoom._id}/attributes/${updatedKey}`).set(credentials).expect(200); await request .get(`${v1}/abac/attributes/${updatedKey}/is-in-use`) @@ -615,7 +615,7 @@ import { IS_EE } from '../../e2e/config/constants'; abacRoomId = roomRes.body.group._id; await request - .post(`${v1}/abac/room/${abacRoomId}/attributes/${conversionAttrKey}`) + .post(`${v1}/abac/rooms/${abacRoomId}/attributes/${conversionAttrKey}`) .set(credentials) .send({ values: ['alpha'] }) .expect(200); @@ -634,7 +634,7 @@ import { IS_EE } from '../../e2e/config/constants'; teamRoomId = teamRoomRes.body.group._id; await request - .post(`${v1}/abac/room/${teamRoomId}/attributes/${conversionAttrKey}`) + .post(`${v1}/abac/rooms/${teamRoomId}/attributes/${conversionAttrKey}`) .set(credentials) .send({ values: ['beta'] }) .expect(200); @@ -827,13 +827,13 @@ import { IS_EE } from '../../e2e/config/constants'; it('POST bulk room attributes should fail when more than 10 distinct attribute keys provided', async () => { const tooMany: Record = {}; for (let i = 0; i < 11; i++) tooMany[`${bulkAttrPrefix}_${i}`] = ['v1']; - await request.post(`${v1}/abac/room/${testRoom._id}/attributes`).set(credentials).send({ attributes: tooMany }).expect(400); + await request.post(`${v1}/abac/rooms/${testRoom._id}/attributes`).set(credentials).send({ attributes: tooMany }).expect(400); }); it('POST bulk room attributes should fail when one attribute has >10 values', async () => { const bigValues = Array.from({ length: 11 }, (_, i) => `v${i}`); await request - .post(`${v1}/abac/room/${testRoom._id}/attributes`) + .post(`${v1}/abac/rooms/${testRoom._id}/attributes`) .set(credentials) .send({ attributes: { [`${bulkAttrPrefix}_k1`]: bigValues } }) .expect(400); @@ -841,7 +841,7 @@ import { IS_EE } from '../../e2e/config/constants'; it('POST bulk room attributes should fail when using a value not in attribute definition', async () => { await request - .post(`${v1}/abac/room/${testRoom._id}/attributes`) + .post(`${v1}/abac/rooms/${testRoom._id}/attributes`) .set(credentials) .send({ attributes: { [secondKey]: ['alpha', 'delta'] } }) .expect(400); @@ -849,7 +849,7 @@ import { IS_EE } from '../../e2e/config/constants'; it('POST bulk room attributes should succeed when providing valid subset for existing definitions', async () => { await request - .post(`${v1}/abac/room/${testRoom._id}/attributes`) + .post(`${v1}/abac/rooms/${testRoom._id}/attributes`) .set(credentials) .send({ attributes: { @@ -863,12 +863,16 @@ import { IS_EE } from '../../e2e/config/constants'; describe('Room attribute key/value validation edge cases', () => { it('POST single room attribute should fail with >10 values', async () => { const eleven = Array.from({ length: 11 }, (_, i) => `x${i}`); - await request.post(`${v1}/abac/room/${testRoom._id}/attributes/${secondKey}`).set(credentials).send({ values: eleven }).expect(400); + await request + .post(`${v1}/abac/rooms/${testRoom._id}/attributes/${secondKey}`) + .set(credentials) + .send({ values: eleven }) + .expect(400); }); it('POST single room attribute should fail when value not allowed by definition', async () => { await request - .post(`${v1}/abac/room/${testRoom._id}/attributes/${secondKey}`) + .post(`${v1}/abac/rooms/${testRoom._id}/attributes/${secondKey}`) .set(credentials) .send({ values: ['alpha', 'zzz'] }) .expect(400); @@ -876,7 +880,7 @@ import { IS_EE } from '../../e2e/config/constants'; it('PUT single room attribute should fail with value not in definition', async () => { await request - .put(`${v1}/abac/room/${testRoom._id}/attributes/${secondKey}`) + .put(`${v1}/abac/rooms/${testRoom._id}/attributes/${secondKey}`) .set(credentials) .send({ values: ['gamma', 'invalid'] }) .expect(400); @@ -886,7 +890,7 @@ import { IS_EE } from '../../e2e/config/constants'; describe('Room attribute limits (max 10 attribute keys)', () => { const tempAttrKeys: string[] = []; it('Reset room attributes before limit test and populate with 10 keys', async () => { - await request.delete(`${v1}/abac/room/${testRoom._id}/attributes`).set(credentials).expect(200); + await request.delete(`${v1}/abac/rooms/${testRoom._id}/attributes`).set(credentials).expect(200); const timestamp = Date.now(); const keys = Array.from({ length: 10 }, (_, i) => `limitk_${timestamp}_${i}`); @@ -900,7 +904,7 @@ import { IS_EE } from '../../e2e/config/constants'; .expect(200) .then(() => request - .post(`${v1}/abac/room/${testRoom._id}/attributes/${k}`) + .post(`${v1}/abac/rooms/${testRoom._id}/attributes/${k}`) .set(credentials) .send({ values: ['v1'] }) .expect(200), @@ -918,7 +922,7 @@ import { IS_EE } from '../../e2e/config/constants'; .expect(200); await request - .post(`${v1}/abac/room/${testRoom._id}/attributes/${extraKey}`) + .post(`${v1}/abac/rooms/${testRoom._id}/attributes/${extraKey}`) .set(credentials) .send({ values: ['v1'] }) .expect(400); @@ -926,24 +930,24 @@ import { IS_EE } from '../../e2e/config/constants'; }); describe('Permission & Auth extended checks', () => { - it('POST /abac/room/:rid/attributes/:key should return 403 for unauthorized user', async () => { + it('POST /abac/rooms/:rid/attributes/:key should return 403 for unauthorized user', async () => { await request - .post(`${v1}/abac/room/${testRoom._id}/attributes/${secondKey}`) + .post(`${v1}/abac/rooms/${testRoom._id}/attributes/${secondKey}`) .set(unauthorizedCredentials) .send({ values: ['alpha'] }) .expect(403); }); - it('PUT /abac/room/:rid/attributes/:key should return 403 for unauthorized user', async () => { + it('PUT /abac/rooms/:rid/attributes/:key should return 403 for unauthorized user', async () => { await request - .put(`${v1}/abac/room/${testRoom._id}/attributes/${secondKey}`) + .put(`${v1}/abac/rooms/${testRoom._id}/attributes/${secondKey}`) .set(unauthorizedCredentials) .send({ values: ['alpha'] }) .expect(403); }); - it('DELETE /abac/room/:rid/attributes/:key should return 403 for unauthorized user', async () => { - await request.delete(`${v1}/abac/room/${testRoom._id}/attributes/${secondKey}`).set(unauthorizedCredentials).expect(403); + it('DELETE /abac/rooms/:rid/attributes/:key should return 403 for unauthorized user', async () => { + await request.delete(`${v1}/abac/rooms/${testRoom._id}/attributes/${secondKey}`).set(unauthorizedCredentials).expect(403); }); it('GET /abac/attributes/:key/is-in-use should return 403 for unauthorized user', async () => { @@ -1018,9 +1022,9 @@ import { IS_EE } from '../../e2e/config/constants'; }); }); - it('POST /abac/room/:rid/attributes/:key should fail while disabled', async () => { + it('POST /abac/rooms/:rid/attributes/:key should fail while disabled', async () => { await request - .post(`${v1}/abac/room/${testRoom._id}/attributes/${secondKey}`) + .post(`${v1}/abac/rooms/${testRoom._id}/attributes/${secondKey}`) .set(credentials) .send({ values: ['alpha'] }) .expect(400) @@ -1029,9 +1033,9 @@ import { IS_EE } from '../../e2e/config/constants'; }); }); - it('PUT /abac/room/:rid/attributes/:key should fail while disabled', async () => { + it('PUT /abac/rooms/:rid/attributes/:key should fail while disabled', async () => { await request - .put(`${v1}/abac/room/${testRoom._id}/attributes/${secondKey}`) + .put(`${v1}/abac/rooms/${testRoom._id}/attributes/${secondKey}`) .set(credentials) .send({ values: ['alpha'] }) .expect(400) @@ -1040,9 +1044,9 @@ import { IS_EE } from '../../e2e/config/constants'; }); }); - it('POST /abac/room/:rid/attributes (bulk replace) should fail while disabled', async () => { + it('POST /abac/rooms/:rid/attributes (bulk replace) should fail while disabled', async () => { await request - .post(`${v1}/abac/room/${testRoom._id}/attributes`) + .post(`${v1}/abac/rooms/${testRoom._id}/attributes`) .set(credentials) .send({ attributes: { [secondKey]: ['alpha'] } }) .expect(400) From 070b66cdf5e38ab8510f9eab3ffe4e7a7344cc2b Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 27 Oct 2025 13:17:20 -0600 Subject: [PATCH 062/125] feat: Prevent ABAC managed rooms becoming public while ABAC is active (#37303) --- .../server/methods/saveRoomSettings.ts | 14 ++ apps/meteor/tests/end-to-end/api/abac.ts | 176 ++++++++++++++++++ 2 files changed, 190 insertions(+) diff --git a/apps/meteor/app/channel-settings/server/methods/saveRoomSettings.ts b/apps/meteor/app/channel-settings/server/methods/saveRoomSettings.ts index c2b6b44e99144..e6b94aea3d259 100644 --- a/apps/meteor/app/channel-settings/server/methods/saveRoomSettings.ts +++ b/apps/meteor/app/channel-settings/server/methods/saveRoomSettings.ts @@ -105,6 +105,13 @@ const validators: RoomSettingsValidators = { }); } + if (settings.get('ABAC_Enabled') && room.t === 'p' && value !== 'p' && room?.abacAttributes?.length) { + throw new Meteor.Error('error-action-not-allowed', 'Changing an ABAC managed private room to public is not allowed', { + method: 'saveRoomSettings', + action: 'Change_Room_Type', + }); + } + if (!room.teamId) { return; } @@ -123,6 +130,13 @@ const validators: RoomSettingsValidators = { action: 'Change_Room_Type', }); } + + if (settings.get('ABAC_Enabled') && team?.type === TEAM_TYPE.PRIVATE && value !== 'p' && room?.abacAttributes?.length) { + throw new Meteor.Error('error-action-not-allowed', 'Changing an ABAC managed private team room to public is not allowed', { + method: 'saveRoomSettings', + action: 'Change_Room_Type', + }); + } }, async encrypted({ userId, value, room, rid }) { if (value !== room.encrypted) { diff --git a/apps/meteor/tests/end-to-end/api/abac.ts b/apps/meteor/tests/end-to-end/api/abac.ts index 90ddd307db2fe..89db4899cd844 100644 --- a/apps/meteor/tests/end-to-end/api/abac.ts +++ b/apps/meteor/tests/end-to-end/api/abac.ts @@ -1059,5 +1059,181 @@ import { IS_EE } from '../../e2e/config/constants'; await updateSetting('ABAC_Enabled', true); }); }); + + describe('ABAC Room Type Conversion', () => { + const attrKey = `type_conversion_${Date.now()}`; + + let roomNoAttr: string; + let roomWithAttr: string; + let roomWithAttrAbacDisabled: string; + + before(async () => { + await updateSetting('ABAC_Enabled', true); + + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: attrKey, values: ['val1', 'val2'] }) + .expect(200); + + roomNoAttr = (await createRoom({ type: 'p', name: `abac-type-room-no-attr-${Date.now()}` })).body.group._id; + + roomWithAttr = (await createRoom({ type: 'p', name: `abac-type-room-with-attr-${Date.now()}` })).body.group._id; + await request + .post(`${v1}/abac/room/${roomWithAttr}/attributes/${attrKey}`) + .set(credentials) + .send({ values: ['val1'] }) + .expect(200); + + roomWithAttrAbacDisabled = (await createRoom({ type: 'p', name: `abac-type-room-with-attr-disabled-${Date.now()}` })).body.group + ._id; + await request + .post(`${v1}/abac/room/${roomWithAttrAbacDisabled}/attributes/${attrKey}`) + .set(credentials) + .send({ values: ['val2'] }) + .expect(200); + }); + + after(async () => { + await updateSetting('ABAC_Enabled', false); + await Promise.all([ + deleteRoom({ type: 'c', roomId: roomNoAttr }), + deleteRoom({ type: 'p', roomId: roomWithAttr }), + deleteRoom({ type: 'c', roomId: roomWithAttrAbacDisabled }), + ]); + }); + + it('should convert private room without ABAC attributes to public', async () => { + await request + .post(`${v1}/rooms.saveRoomSettings`) + .set(credentials) + .send({ rid: roomNoAttr, roomType: 'c' }) + .expect(200) + .expect((res) => { + expect(res.body.success).to.be.true; + }); + }); + + it('should fail converting ABAC managed private room to public', async () => { + await request + .post(`${v1}/rooms.saveRoomSettings`) + .set(credentials) + .send({ rid: roomWithAttr, roomType: 'c' }) + .expect(400) + .expect((res) => { + expect(res.body.success).to.be.false; + expect(res.body.error).to.include('Changing an ABAC managed private room to public is not allowed'); + }); + }); + + it('should allow converting ABAC managed private room to public when ABAC disabled', async () => { + await updateSetting('ABAC_Enabled', false); + + await request + .post(`${v1}/rooms.saveRoomSettings`) + .set(credentials) + .send({ rid: roomWithAttrAbacDisabled, roomType: 'c' }) + .expect(200) + .expect((res) => { + expect(res.body.success).to.be.true; + }); + }); + }); + + describe('ABAC Team Type Conversion', () => { + const attrKeyTeam = `team_type_conversion_${Date.now()}`; + const teamNameWithAttr = `abac-team-with-attr-${Date.now()}`; + const teamNameWithAttrAbacDisabled = `abac-team-with-attr-disabled-${Date.now()}`; + + let teamNameNoAttr: string; + let mainRoomIdNoAttr: string; + let mainRoomIdWithAttr: string; + let mainRoomIdWithAttrAbacDisabled: string; + + before(async () => { + await updateSetting('ABAC_Enabled', true); + + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: attrKeyTeam, values: ['alpha', 'beta'] }) + .expect(200); + + teamNameNoAttr = `abac-team-no-attr-${Date.now()}`; + const teamNoAttrRes = await request.post(`${v1}/teams.create`).set(credentials).send({ name: teamNameNoAttr, type: 1 }).expect(200); + mainRoomIdNoAttr = teamNoAttrRes.body.team.roomId; + + const teamWithAttrRes = await request + .post(`${v1}/teams.create`) + .set(credentials) + .send({ name: teamNameWithAttr, type: 1 }) + .expect(200); + mainRoomIdWithAttr = teamWithAttrRes.body.team.roomId; + await request + .post(`${v1}/abac/room/${mainRoomIdWithAttr}/attributes/${attrKeyTeam}`) + .set(credentials) + .send({ values: ['alpha'] }) + .expect(200); + + const teamWithAttrDisRes = await request + .post(`${v1}/teams.create`) + .set(credentials) + .send({ name: teamNameWithAttrAbacDisabled, type: 1 }) + .expect(200); + mainRoomIdWithAttrAbacDisabled = teamWithAttrDisRes.body.team.roomId; + await request + .post(`${v1}/abac/room/${mainRoomIdWithAttrAbacDisabled}/attributes/${attrKeyTeam}`) + .set(credentials) + .send({ values: ['beta'] }) + .expect(200); + }); + + after(async () => { + await updateSetting('ABAC_Enabled', false); + await Promise.all([ + deleteTeam(credentials, teamNameNoAttr), + deleteTeam(credentials, teamNameWithAttr), + deleteTeam(credentials, teamNameWithAttrAbacDisabled), + ]); + }); + + it('should convert private team (main room) without ABAC attributes to public', async () => { + await request + .post(`${v1}/rooms.saveRoomSettings`) + .set(credentials) + .send({ rid: mainRoomIdNoAttr, roomType: 'c' }) + .expect(200) + .expect((res) => { + expect(res.body.success).to.be.true; + }); + }); + + it('should fail converting private team (main room) with ABAC attributes to public', async () => { + await request + .post(`${v1}/rooms.saveRoomSettings`) + .set(credentials) + .send({ rid: mainRoomIdWithAttr, roomType: 'c' }) + .expect(400) + .expect((res) => { + expect(res.body.success).to.be.false; + // Ideally this should say "Changing an ABAC managed private team to public is not allowed" but the room check is done before the team check + // And it fails there + expect(res.body.error).to.include('Changing an ABAC managed private room to public is not allowed'); + }); + }); + + it('should allow converting private team (main room) with ABAC attributes to public when ABAC disabled', async () => { + await updateSetting('ABAC_Enabled', false); + + await request + .post(`${v1}/rooms.saveRoomSettings`) + .set(credentials) + .send({ rid: mainRoomIdWithAttrAbacDisabled, roomType: 'c' }) + .expect(200) + .expect((res) => { + expect(res.body.success).to.be.true; + }); + }); + }); }); }); From c4ce5433eda79b10a52b33f693b49f743a33862c Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 28 Oct 2025 11:02:09 -0600 Subject: [PATCH 063/125] fix: tests using room instead of rooms --- apps/meteor/tests/end-to-end/api/abac.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/meteor/tests/end-to-end/api/abac.ts b/apps/meteor/tests/end-to-end/api/abac.ts index 89db4899cd844..de34be6ad7d65 100644 --- a/apps/meteor/tests/end-to-end/api/abac.ts +++ b/apps/meteor/tests/end-to-end/api/abac.ts @@ -1080,7 +1080,7 @@ import { IS_EE } from '../../e2e/config/constants'; roomWithAttr = (await createRoom({ type: 'p', name: `abac-type-room-with-attr-${Date.now()}` })).body.group._id; await request - .post(`${v1}/abac/room/${roomWithAttr}/attributes/${attrKey}`) + .post(`${v1}/abac/rooms/${roomWithAttr}/attributes/${attrKey}`) .set(credentials) .send({ values: ['val1'] }) .expect(200); @@ -1088,7 +1088,7 @@ import { IS_EE } from '../../e2e/config/constants'; roomWithAttrAbacDisabled = (await createRoom({ type: 'p', name: `abac-type-room-with-attr-disabled-${Date.now()}` })).body.group ._id; await request - .post(`${v1}/abac/room/${roomWithAttrAbacDisabled}/attributes/${attrKey}`) + .post(`${v1}/abac/rooms/${roomWithAttrAbacDisabled}/attributes/${attrKey}`) .set(credentials) .send({ values: ['val2'] }) .expect(200); @@ -1170,7 +1170,7 @@ import { IS_EE } from '../../e2e/config/constants'; .expect(200); mainRoomIdWithAttr = teamWithAttrRes.body.team.roomId; await request - .post(`${v1}/abac/room/${mainRoomIdWithAttr}/attributes/${attrKeyTeam}`) + .post(`${v1}/abac/rooms/${mainRoomIdWithAttr}/attributes/${attrKeyTeam}`) .set(credentials) .send({ values: ['alpha'] }) .expect(200); @@ -1182,7 +1182,7 @@ import { IS_EE } from '../../e2e/config/constants'; .expect(200); mainRoomIdWithAttrAbacDisabled = teamWithAttrDisRes.body.team.roomId; await request - .post(`${v1}/abac/room/${mainRoomIdWithAttrAbacDisabled}/attributes/${attrKeyTeam}`) + .post(`${v1}/abac/rooms/${mainRoomIdWithAttrAbacDisabled}/attributes/${attrKeyTeam}`) .set(credentials) .send({ values: ['beta'] }) .expect(200); From 73289b19168c6617684b0a2297ffd86b194b13ff Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 29 Oct 2025 07:55:01 -0600 Subject: [PATCH 064/125] feat: Prevent invite links from being generated on abac rooms (#37325) --- .../server/functions/findOrCreateInvite.ts | 7 + .../server/functions/validateInviteToken.ts | 9 + apps/meteor/tests/end-to-end/api/abac.ts | 179 +++++++++++++++++- 3 files changed, 193 insertions(+), 2 deletions(-) diff --git a/apps/meteor/app/invites/server/functions/findOrCreateInvite.ts b/apps/meteor/app/invites/server/functions/findOrCreateInvite.ts index 052445a1ebc99..fa905cc345c6d 100644 --- a/apps/meteor/app/invites/server/functions/findOrCreateInvite.ts +++ b/apps/meteor/app/invites/server/functions/findOrCreateInvite.ts @@ -63,6 +63,13 @@ export const findOrCreateInvite = async (userId: string, invite: Pick { if (!token || typeof token !== 'string') { throw new Meteor.Error('error-invalid-token', 'The invite token is invalid.', { @@ -25,6 +27,13 @@ export const validateInviteToken = async (token: string) => { }); } + if (settings.get('ABAC_Enabled') && room?.abacAttributes?.length) { + throw new Meteor.Error('error-invalid-room', 'Room is ABAC managed', { + method: 'validateInviteToken', + field: 'rid', + }); + } + if (inviteData.expires && new Date(inviteData.expires).getTime() <= Date.now()) { throw new Meteor.Error('error-invite-expired', 'The invite token has expired.', { method: 'validateInviteToken', diff --git a/apps/meteor/tests/end-to-end/api/abac.ts b/apps/meteor/tests/end-to-end/api/abac.ts index de34be6ad7d65..fea3da2b08c5b 100644 --- a/apps/meteor/tests/end-to-end/api/abac.ts +++ b/apps/meteor/tests/end-to-end/api/abac.ts @@ -1,7 +1,8 @@ import type { Credentials } from '@rocket.chat/api-client'; -import type { IRoom, IUser } from '@rocket.chat/core-typings'; +import type { IAbacAttributeDefinition, IRoom, IUser } from '@rocket.chat/core-typings'; import { expect } from 'chai'; import { before, after, describe, it } from 'mocha'; +import { MongoClient } from 'mongodb'; import { getCredentials, request, credentials } from '../../data/api-data'; import { updatePermission, updateSetting } from '../../data/permissions.helper'; @@ -9,7 +10,20 @@ import { createRoom, deleteRoom } from '../../data/rooms.helper'; import { deleteTeam } from '../../data/teams.helper'; import { password } from '../../data/user'; import { createUser, deleteUser, login } from '../../data/users.helper'; -import { IS_EE } from '../../e2e/config/constants'; +import { IS_EE, URL_MONGODB } from '../../e2e/config/constants'; + +// NOTE: This manipulates the DB directly to add ABAC attributes to a user +// The idea is to avoid having to go through LDAP to add info to the user +let connection: MongoClient; +const addAbacAttributesToUserDirectly = async (userId: string, abacAttributes: IAbacAttributeDefinition[]) => { + await connection.db().collection('users').updateOne( + { + // @ts-expect-error - collection types for _id + _id: userId, + }, + { $set: { abacAttributes } }, + ); +}; (IS_EE ? describe : describe.skip)('[ABAC] (Enterprise Only)', function () { this.retries(0); @@ -28,6 +42,8 @@ import { IS_EE } from '../../e2e/config/constants'; before((done) => getCredentials(done)); before(async () => { + connection = await MongoClient.connect(URL_MONGODB); + await updatePermission('abac-management', ['admin']); await updateSetting('ABAC_Enabled', true); @@ -40,6 +56,9 @@ import { IS_EE } from '../../e2e/config/constants'; after(async () => { await deleteRoom({ type: 'p', roomId: testRoom._id }); await deleteUser(unauthorizedUser); + await updateSetting('ABAC_Enabled', false); + + await connection.close(); }); const v1 = '/api/v1'; @@ -1235,5 +1254,161 @@ import { IS_EE } from '../../e2e/config/constants'; }); }); }); + + describe('Invite links & ABAC management', () => { + const inviteAttrKey = `invite_attr_${Date.now()}`; + const validateAttrKey = `invite_val_attr_${Date.now()}`; + let managedRoomId: string; + let plainRoomId: string; + let plainRoomInviteToken: string; + const createdInviteIds: string[] = []; + + before(async () => { + await updatePermission('create-invite-links', ['admin']); + await updateSetting('ABAC_Enabled', true); + }); + + it('should create an invite link for a private room without ABAC attributes when ABAC is enabled', async () => { + const plainRoom = (await createRoom({ type: 'p', name: `invite-plain-${Date.now()}` })).body.group; + plainRoomId = plainRoom._id; + + await request + .post(`${v1}/findOrCreateInvite`) + .set(credentials) + .send({ rid: plainRoomId, days: 1, maxUses: 0 }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('rid', plainRoomId); + expect(res.body).to.have.property('days', 1); + expect(res.body).to.have.property('maxUses', 0); + plainRoomInviteToken = res.body._id; + createdInviteIds.push(plainRoomInviteToken); + }); + }); + + it('validateInviteToken should return valid=true for token from non-ABAC managed room', async () => { + await request + .post(`${v1}/validateInviteToken`) + .send({ token: plainRoomInviteToken }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('valid', true); + }); + }); + + it('validateInviteToken should return valid=false for random invalid token', async () => { + await request + .post(`${v1}/validateInviteToken`) + .send({ token: 'invalid123' }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('valid', false); + }); + }); + + it('validateInviteToken should return valid=false after room becomes ABAC managed', async () => { + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: validateAttrKey, values: ['one'] }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await addAbacAttributesToUserDirectly(credentials['X-User-Id'], [{ key: validateAttrKey, values: ['one'] }]); + + await request + .post(`${v1}/abac/rooms/${plainRoomId}/attributes/${validateAttrKey}`) + .set(credentials) + .send({ values: ['one'] }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .post(`${v1}/validateInviteToken`) + .send({ token: plainRoomInviteToken }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('valid', false); + }); + }); + + it('validateInviteToken should return valid=true again after disabling ABAC', async () => { + await updateSetting('ABAC_Enabled', false); + + await request + .post(`${v1}/validateInviteToken`) + .send({ token: plainRoomInviteToken }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('valid', true); + }); + + await updateSetting('ABAC_Enabled', true); + }); + + it('should fail creating an invite link for an ABAC managed room while ABAC is enabled', async () => { + const managedRoom = (await createRoom({ type: 'p', name: `invite-managed-${Date.now()}` })).body.group; + managedRoomId = managedRoom._id; + + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: inviteAttrKey, values: ['one'] }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await addAbacAttributesToUserDirectly(credentials['X-User-Id'], [{ key: inviteAttrKey, values: ['one'] }]); + + await request + .post(`${v1}/abac/rooms/${managedRoomId}/attributes/${inviteAttrKey}`) + .set(credentials) + .send({ values: ['one'] }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .post(`${v1}/findOrCreateInvite`) + .set(credentials) + .send({ rid: managedRoomId, days: 1, maxUses: 0 }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-invalid-room'); + expect(res.body).to.have.property('error').that.includes('Room is ABAC managed'); + }); + }); + + it('should allow creating an invite link for previously ABAC managed room after disabling ABAC', async () => { + await updateSetting('ABAC_Enabled', false); + + await request + .post(`${v1}/findOrCreateInvite`) + .set(credentials) + .send({ rid: managedRoomId, days: 1, maxUses: 0 }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('rid', managedRoomId); + createdInviteIds.push(res.body._id); + }); + }); + + after(async () => { + await Promise.all(createdInviteIds.map((id) => request.delete(`${v1}/removeInvite/${id}`).set(credentials))); + }); + }); }); }); From ca6d156f13de62b9a6fd5acffa86da8987504e8f Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Thu, 30 Oct 2025 09:00:45 -0600 Subject: [PATCH 065/125] feat: Register abac service inside authz (#37333) --- apps/meteor/ee/server/startup/services.ts | 2 +- ee/apps/authorization-service/Dockerfile | 3 +++ ee/apps/authorization-service/package.json | 1 + ee/apps/authorization-service/src/service.ts | 6 ++++++ yarn.lock | 1 + 5 files changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/meteor/ee/server/startup/services.ts b/apps/meteor/ee/server/startup/services.ts index a09d6c6f11b5c..5e33f9e17c771 100644 --- a/apps/meteor/ee/server/startup/services.ts +++ b/apps/meteor/ee/server/startup/services.ts @@ -17,9 +17,9 @@ api.registerService(new LicenseService()); api.registerService(new MessageReadsService()); api.registerService(new OmnichannelEE()); api.registerService(new VoipFreeSwitchService()); -api.registerService(new AbacService()); // when not running micro services we want to start up the instance intercom if (!isRunningMs()) { + api.registerService(new AbacService()); api.registerService(new InstanceService()); } diff --git a/ee/apps/authorization-service/Dockerfile b/ee/apps/authorization-service/Dockerfile index 90afd4fcee6d2..362ee4717ee5c 100644 --- a/ee/apps/authorization-service/Dockerfile +++ b/ee/apps/authorization-service/Dockerfile @@ -69,6 +69,9 @@ COPY ./packages/ui-kit/dist packages/ui-kit/dist COPY ./packages/http-router/package.json packages/http-router/package.json COPY ./packages/http-router/dist packages/http-router/dist +COPY ./ee/packages/abac/package.json ee/packages/abac/package.json +COPY ./ee/packages/abac/dist ee/packages/abac/dist + COPY ./ee/apps/${SERVICE}/dist . COPY ./package.json . diff --git a/ee/apps/authorization-service/package.json b/ee/apps/authorization-service/package.json index 0841caeeb73c5..97cc876f36050 100644 --- a/ee/apps/authorization-service/package.json +++ b/ee/apps/authorization-service/package.json @@ -19,6 +19,7 @@ "typecheck": "tsc --noEmit --skipLibCheck -p tsconfig.json" }, "dependencies": { + "@rocket.chat/abac": "workspace:^", "@rocket.chat/core-services": "workspace:^", "@rocket.chat/core-typings": "workspace:^", "@rocket.chat/emitter": "~0.31.25", diff --git a/ee/apps/authorization-service/src/service.ts b/ee/apps/authorization-service/src/service.ts index e09d87de6d245..f3b45f0c2a582 100755 --- a/ee/apps/authorization-service/src/service.ts +++ b/ee/apps/authorization-service/src/service.ts @@ -1,3 +1,4 @@ +import { AbacService } from '@rocket.chat/abac'; import { api, getConnection, getTrashCollection } from '@rocket.chat/core-services'; import { registerServiceModels } from '@rocket.chat/models'; import { startBroker } from '@rocket.chat/network-broker'; @@ -20,6 +21,11 @@ const PORT = process.env.PORT || 3034; api.registerService(new Authorization()); + if (!process.env.USE_EXTERNAL_ABAC_SERVICE) { + // Same API as authz service but own core-services proxy + api.registerService(new AbacService()); + } + await api.start(); polka() diff --git a/yarn.lock b/yarn.lock index 37a32659d73c7..46e1660aaef11 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8435,6 +8435,7 @@ __metadata: version: 0.0.0-use.local resolution: "@rocket.chat/authorization-service@workspace:ee/apps/authorization-service" dependencies: + "@rocket.chat/abac": "workspace:^" "@rocket.chat/core-services": "workspace:^" "@rocket.chat/core-typings": "workspace:^" "@rocket.chat/emitter": "npm:~0.31.25" From ea5321652a1b90ebabfec2b73180b89dd8c1880a Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Tue, 4 Nov 2025 13:26:32 -0600 Subject: [PATCH 066/125] feat: Update add/join methods to use abac rules (#37339) --- .../app/lib/server/functions/addUserToRoom.ts | 6 +- .../app/lib/server/lib/beforeAddUserToRoom.ts | 6 + .../app/slashcommands-invite/server/server.ts | 43 ++++-- apps/meteor/ee/server/configuration/abac.ts | 2 + .../server/hooks/abac/beforeAddUserToRoom.ts | 22 +++ apps/meteor/ee/server/hooks/abac/index.ts | 1 + .../meteor/server/methods/addAllUserToRoom.ts | 6 + apps/meteor/tests/end-to-end/api/abac.ts | 72 ++++++++++ ee/packages/abac/src/index.spec.ts | 136 +++++++++++++++++- ee/packages/abac/src/index.ts | 34 ++++- .../core-services/src/types/IAbacService.ts | 1 + 11 files changed, 308 insertions(+), 21 deletions(-) create mode 100644 apps/meteor/app/lib/server/lib/beforeAddUserToRoom.ts create mode 100644 apps/meteor/ee/server/hooks/abac/beforeAddUserToRoom.ts create mode 100644 apps/meteor/ee/server/hooks/abac/index.ts diff --git a/apps/meteor/app/lib/server/functions/addUserToRoom.ts b/apps/meteor/app/lib/server/functions/addUserToRoom.ts index 7651b571723a7..6ebfd84844878 100644 --- a/apps/meteor/app/lib/server/functions/addUserToRoom.ts +++ b/apps/meteor/app/lib/server/functions/addUserToRoom.ts @@ -10,6 +10,7 @@ import { callbacks } from '../../../../server/lib/callbacks'; import { beforeAddUserToRoom } from '../../../../server/lib/callbacks/beforeAddUserToRoom'; import { roomCoordinator } from '../../../../server/lib/rooms/roomCoordinator'; import { settings } from '../../../settings/server'; +import { beforeAddUserToRoom as beforeAddUserToRoomPatch } from '../lib/beforeAddUserToRoom'; import { notifyOnRoomChangedById } from '../lib/notifyListener'; /** @@ -61,7 +62,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); } diff --git a/apps/meteor/app/lib/server/lib/beforeAddUserToRoom.ts b/apps/meteor/app/lib/server/lib/beforeAddUserToRoom.ts new file mode 100644 index 0000000000000..984dc34a22a12 --- /dev/null +++ b/apps/meteor/app/lib/server/lib/beforeAddUserToRoom.ts @@ -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 +}); diff --git a/apps/meteor/app/slashcommands-invite/server/server.ts b/apps/meteor/app/slashcommands-invite/server/server.ts index f325f6b2e085b..62bd283351bf1 100644 --- a/apps/meteor/app/slashcommands-invite/server/server.ts +++ b/apps/meteor/app/slashcommands-invite/server/server.ts @@ -1,4 +1,4 @@ -import { api, FederationMatrix } from '@rocket.chat/core-services'; +import { api, FederationMatrix, isMeteorError } from '@rocket.chat/core-services'; import type { IUser, SlashCommandCallbackParams } from '@rocket.chat/core-typings'; import { validateFederatedUsername } from '@rocket.chat/federation-matrix'; import { Subscriptions, Users, Rooms } from '@rocket.chat/models'; @@ -10,6 +10,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 @@ -105,23 +110,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' }), + }); + } } } }), diff --git a/apps/meteor/ee/server/configuration/abac.ts b/apps/meteor/ee/server/configuration/abac.ts index ae4d67258fda0..138746572a4a4 100644 --- a/apps/meteor/ee/server/configuration/abac.ts +++ b/apps/meteor/ee/server/configuration/abac.ts @@ -7,5 +7,7 @@ Meteor.startup(async () => { await addSettings(); await createPermissions(); + + await import('../hooks/abac'); }); }); diff --git a/apps/meteor/ee/server/hooks/abac/beforeAddUserToRoom.ts b/apps/meteor/ee/server/hooks/abac/beforeAddUserToRoom.ts new file mode 100644 index 0000000000000..9a351b548468e --- /dev/null +++ b/apps/meteor/ee/server/hooks/abac/beforeAddUserToRoom.ts @@ -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); +}); diff --git a/apps/meteor/ee/server/hooks/abac/index.ts b/apps/meteor/ee/server/hooks/abac/index.ts new file mode 100644 index 0000000000000..8f93423047ad8 --- /dev/null +++ b/apps/meteor/ee/server/hooks/abac/index.ts @@ -0,0 +1 @@ +import './beforeAddUserToRoom'; diff --git a/apps/meteor/server/methods/addAllUserToRoom.ts b/apps/meteor/server/methods/addAllUserToRoom.ts index 08c61373fcba6..57f9ea3600b39 100644 --- a/apps/meteor/server/methods/addAllUserToRoom.ts +++ b/apps/meteor/server/methods/addAllUserToRoom.ts @@ -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'; @@ -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); diff --git a/apps/meteor/tests/end-to-end/api/abac.ts b/apps/meteor/tests/end-to-end/api/abac.ts index fea3da2b08c5b..5a8b9b25371c4 100644 --- a/apps/meteor/tests/end-to-end/api/abac.ts +++ b/apps/meteor/tests/end-to-end/api/abac.ts @@ -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); + }); + }); + }); }); diff --git a/ee/packages/abac/src/index.spec.ts b/ee/packages/abac/src/index.spec.ts index ef397b80d0628..1a55e284bfc80 100644 --- a/ee/packages/abac/src/index.spec.ts +++ b/ee/packages/abac/src/index.spec.ts @@ -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: { @@ -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; @@ -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'] }, + }, + }, + }, + }, + ]); + }); + }); }); diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index b11c7992d72b5..4c1084f68b315 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -1,4 +1,4 @@ -import { Room, ServiceClass } from '@rocket.chat/core-services'; +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 { Logger } from '@rocket.chat/logger'; @@ -442,6 +442,38 @@ export class AbacService extends ServiceClass implements IAbacService { })); } + async checkUsernamesMatchAttributes(usernames: string[], attributes: IAbacAttributeDefinition[]): Promise { + if (!usernames.length || !attributes.length) { + return; + } + + const nonComplianceConditions = this.buildNonCompliantConditions(attributes); + const nonCompliantUsersFromList = await Users.find( + { + username: { $in: usernames }, + $or: nonComplianceConditions, + }, + { projection: { username: 1 } }, + ) + .map((u) => u.username as string) + .toArray(); + + const nonCompliantSet = new Set(nonCompliantUsersFromList); + + if (nonCompliantSet.size) { + throw new MeteorError( + 'error-usernames-not-matching-abac-attributes', + 'Some usernames do not comply with the ABAC attributes for the room', + Array.from(nonCompliantSet), + ); + } + + this.logger.debug({ + msg: 'User list complied with ABAC attributes for room', + usernames, + }); + } + protected async onRoomAttributesChanged( room: AtLeast, newAttributes: IAbacAttributeDefinition[], diff --git a/packages/core-services/src/types/IAbacService.ts b/packages/core-services/src/types/IAbacService.ts index 357e9f55b1eab..8dda2f7f17727 100644 --- a/packages/core-services/src/types/IAbacService.ts +++ b/packages/core-services/src/types/IAbacService.ts @@ -17,4 +17,5 @@ export interface IAbacService { removeRoomAbacAttribute(rid: string, key: string): Promise; addRoomAbacAttributeByKey(rid: string, key: string, values: string[]): Promise; replaceRoomAbacAttributeByKey(rid: string, key: string, values: string[]): Promise; + checkUsernamesMatchAttributes(usernames: string[], attributes: IAbacAttributeDefinition[]): Promise; } From f2d00326b11222dbe036ec40583a53f4c7bb53ce Mon Sep 17 00:00:00 2001 From: Martin Schoeler Date: Thu, 6 Nov 2025 11:32:15 -0300 Subject: [PATCH 067/125] feat: add ABAC admin settings (#37139) Co-authored-by: Kevin Aleman Co-authored-by: Tasso --- .../ABACUpsellModal/ABACUpsellModal.spec.tsx | 9 +- apps/meteor/client/lib/links.ts | 4 + .../client/views/admin/ABAC/AdminABACPage.tsx | 47 +++++ .../views/admin/ABAC/AdminABACRoute.tsx | 64 +++++++ .../ABAC/AdminABACSettingToggle.spec.tsx | 142 +++++++++++++++ .../ABAC/AdminABACSettingToggle.stories.tsx | 65 +++++++ .../admin/ABAC/AdminABACSettingToggle.tsx | 95 ++++++++++ .../views/admin/ABAC/AdminABACSettings.tsx | 30 ++++ .../client/views/admin/ABAC/AdminABACTabs.tsx | 24 +++ .../admin/ABAC/AdminABACWarningModal.tsx | 48 +++++ .../AdminABACSettingToggle.spec.tsx.snap | 165 ++++++++++++++++++ apps/meteor/client/views/admin/routes.tsx | 9 + .../meteor/client/views/admin/sidebarItems.ts | 7 + .../RoomMembers/RoomMembersWithData.tsx | 1 - apps/meteor/ee/server/settings/abac.ts | 1 + apps/meteor/tests/mocks/data.ts | 1 + packages/i18n/src/locales/en.i18n.json | 10 +- 17 files changed, 715 insertions(+), 7 deletions(-) create mode 100644 apps/meteor/client/views/admin/ABAC/AdminABACPage.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/AdminABACRoute.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/AdminABACSettingToggle.spec.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/AdminABACSettingToggle.stories.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/AdminABACSettingToggle.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/AdminABACSettings.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/AdminABACTabs.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/AdminABACWarningModal.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/__snapshots__/AdminABACSettingToggle.spec.tsx.snap diff --git a/apps/meteor/client/components/ABAC/ABACUpsellModal/ABACUpsellModal.spec.tsx b/apps/meteor/client/components/ABAC/ABACUpsellModal/ABACUpsellModal.spec.tsx index c2fd14fb4feb9..584b64aece7fb 100644 --- a/apps/meteor/client/components/ABAC/ABACUpsellModal/ABACUpsellModal.spec.tsx +++ b/apps/meteor/client/components/ABAC/ABACUpsellModal/ABACUpsellModal.spec.tsx @@ -4,11 +4,7 @@ import userEvent from '@testing-library/user-event'; import { axe } from 'jest-axe'; import ABACUpsellModal from './ABACUpsellModal'; - -// Mock the hooks used by ABACUpsellModal -jest.mock('../../../hooks/useHasLicenseModule', () => ({ - useHasLicenseModule: jest.fn(() => false), -})); +import { createFakeLicenseInfo } from '../../../../tests/mocks/data'; jest.mock('../../GenericUpsellModal/hooks', () => ({ useUpsellActions: jest.fn(() => ({ @@ -34,6 +30,9 @@ const appRoot = mockAppRoot() Upgrade: 'Upgrade', Cancel: 'Cancel', }) + .withEndpoint('GET', '/v1/licenses.info', async () => ({ + license: createFakeLicenseInfo(), + })) .build(); describe('ABACUpsellModal', () => { diff --git a/apps/meteor/client/lib/links.ts b/apps/meteor/client/lib/links.ts index 4c97d4696fff5..a1e28ddc853d3 100644 --- a/apps/meteor/client/lib/links.ts +++ b/apps/meteor/client/lib/links.ts @@ -31,6 +31,10 @@ export const links = { trial: `${GO_ROCKET_CHAT_PREFIX}/i/docs-trial`, versionSupport: `${GO_ROCKET_CHAT_PREFIX}/i/version-support`, updateProduct: `${GO_ROCKET_CHAT_PREFIX}/i/update-product`, + // TODO: implement abac links when available + abacDocs: `${GO_ROCKET_CHAT_PREFIX}/i/TODO-ABAC-DOCS`, + abacLicenseRenewalUrl: `${GO_ROCKET_CHAT_PREFIX}/i/TODO-ABAC-LICENSE-RENEWAL-URL`, + abacLDAPDocs: `${GO_ROCKET_CHAT_PREFIX}/i/TODO-ABAC-LDAP-DOCS`, }, /** @deprecated use `go.rocket.chat` links */ desktopAppDownload: 'https://rocket.chat/download', diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACPage.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACPage.tsx new file mode 100644 index 0000000000000..d8352181e4df9 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/AdminABACPage.tsx @@ -0,0 +1,47 @@ +import { Box, Button, Callout } from '@rocket.chat/fuselage'; +import { useRouteParameter } from '@rocket.chat/ui-contexts'; +import { Trans, useTranslation } from 'react-i18next'; + +import AdminABACSettings from './AdminABACSettings'; +import AdminABACTabs from './AdminABACTabs'; +import { Page, PageContent, PageHeader } from '../../../components/Page'; +import { useExternalLink } from '../../../hooks/useExternalLink'; +import { links } from '../../../lib/links'; + +type AdminABACPageProps = { + shouldShowWarning: boolean; +}; + +const AdminABACPage = ({ shouldShowWarning }: AdminABACPageProps) => { + const { t } = useTranslation(); + const tab = useRouteParameter('tab'); + const learnMore = useExternalLink(); + + return ( + + + + + + {shouldShowWarning && ( + + + + Renew your license to continue using all{' '} + + ABAC capabilities without restriction. + + + + + )} + + {tab === 'settings' && } + + + ); +}; + +export default AdminABACPage; diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACRoute.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACRoute.tsx new file mode 100644 index 0000000000000..20a1db6239b81 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/AdminABACRoute.tsx @@ -0,0 +1,64 @@ +import { usePermission, useSetModal, useCurrentModal, useRouter, useRouteParameter, useSettingStructure } from '@rocket.chat/ui-contexts'; +import type { ReactElement } from 'react'; +import { memo, useEffect, useLayoutEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +import AdminABACPage from './AdminABACPage'; +import ABACUpsellModal from '../../../components/ABAC/ABACUpsellModal/ABACUpsellModal'; +import { useUpsellActions } from '../../../components/GenericUpsellModal/hooks'; +import PageSkeleton from '../../../components/PageSkeleton'; +import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule'; +import SettingsProvider from '../../../providers/SettingsProvider'; +import NotAuthorizedPage from '../../notAuthorized/NotAuthorizedPage'; +import EditableSettingsProvider from '../settings/EditableSettingsProvider'; + +const AdminABACRoute = (): ReactElement => { + const { t } = useTranslation(); + // TODO: Check what permission is needed to view the ABAC page + const canViewABACPage = usePermission('abac-management'); + const hasABAC = useHasLicenseModule('abac') === true; + const isModalOpen = !!useCurrentModal(); + const tab = useRouteParameter('tab'); + const router = useRouter(); + + // Check if setting exists in the DB to decide if we show warning or upsell + const ABACEnabledSetting = useSettingStructure('ABAC_Enabled'); + + useLayoutEffect(() => { + if (!tab) { + router.navigate({ + name: 'admin-ABAC', + params: { tab: 'settings' }, + }); + } + }, [tab, router]); + + const { shouldShowUpsell, handleManageSubscription } = useUpsellActions(hasABAC); + + const setModal = useSetModal(); + + useEffect(() => { + // WS has never activated ABAC + if (shouldShowUpsell && ABACEnabledSetting === undefined) { + setModal( setModal(null)} onConfirm={handleManageSubscription} />); + } + }, [shouldShowUpsell, setModal, t, handleManageSubscription, ABACEnabledSetting]); + + if (isModalOpen) { + return ; + } + + if (!canViewABACPage || (ABACEnabledSetting === undefined && !hasABAC)) { + return ; + } + + return ( + + + + + + ); +}; + +export default memo(AdminABACRoute); diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACSettingToggle.spec.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACSettingToggle.spec.tsx new file mode 100644 index 0000000000000..f235bce08db84 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/AdminABACSettingToggle.spec.tsx @@ -0,0 +1,142 @@ +import type { ISetting } from '@rocket.chat/apps-engine/definition/settings'; +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { axe } from 'jest-axe'; + +import AdminABACSettingToggle from './AdminABACSettingToggle'; +import EditableSettingsProvider from '../settings/EditableSettingsProvider'; + +const settingStructure = { + packageValue: false, + blocked: false, + public: true, + type: 'boolean', + i18nLabel: 'ABAC_Enabled', + i18nDescription: 'ABAC_Enabled_Description', +} as Partial; + +const baseAppRoot = mockAppRoot() + .wrap((children) => {children}) + .withTranslations('en', 'core', { + ABAC_Enabled: 'Enable ABAC', + ABAC_Enabled_Description: 'Enable Attribute-Based Access Control', + ABAC_Warning_Modal_Title: 'Disable ABAC', + ABAC_Warning_Modal_Confirm_Text: 'Disable', + Cancel: 'Cancel', + }); + +describe('AdminABACSettingToggle', () => { + it('should render the setting toggle when setting exists', () => { + const { baseElement } = render(, { + wrapper: baseAppRoot.withSetting('ABAC_Enabled', true, settingStructure).build(), + }); + expect(baseElement).toMatchSnapshot(); + }); + + it('should show warning modal when disabling ABAC', async () => { + const user = userEvent.setup(); + render(, { + wrapper: baseAppRoot.withSetting('ABAC_Enabled', true, settingStructure).build(), + }); + + const toggle = screen.getByRole('checkbox'); + await waitFor(() => { + expect(toggle).not.toBeDisabled(); + }); + await user.click(toggle); + + await waitFor(() => { + expect(screen.getByText('Disable ABAC')).toBeInTheDocument(); + }); + + // TODO: discover how to automatically unmount all modals after each test + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + await user.click(cancelButton); + }); + + it('should not show warning modal when enabling ABAC', async () => { + const user = userEvent.setup(); + render(, { + wrapper: baseAppRoot.withSetting('ABAC_Enabled', false, settingStructure).build(), + }); + + const toggle = screen.getByRole('checkbox'); + await user.click(toggle); + + // The modal should not appear when enabling ABAC + expect(screen.queryByText('Disable ABAC')).not.toBeInTheDocument(); + }); + + it('should show warning modal when resetting setting', async () => { + const user = userEvent.setup(); + render(, { + wrapper: baseAppRoot.withSetting('ABAC_Enabled', true, settingStructure).build(), + }); + + const resetButton = screen.getByRole('button', { name: /reset/i }); + await user.click(resetButton); + + await waitFor(() => { + expect(screen.getByText('Disable ABAC')).toBeInTheDocument(); + }); + + // TODO: discover how to automatically unmount all modals after each test + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + await user.click(cancelButton); + }); + + it('should have no accessibility violations', async () => { + const { container } = render(, { + wrapper: baseAppRoot.withSetting('ABAC_Enabled', true, settingStructure).build(), + }); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('should handle setting change correctly', async () => { + const user = userEvent.setup(); + render(, { + wrapper: baseAppRoot.withSetting('ABAC_Enabled', false, settingStructure).build(), + }); + + const toggle = await screen.findByRole('checkbox', { busy: false }); + expect(toggle).not.toBeChecked(); + + await user.click(toggle); + expect(toggle).toBeChecked(); + }); + + it('should be disabled when abac license is not installed', () => { + const { baseElement } = render(, { + wrapper: baseAppRoot.withSetting('ABAC_Enabled', true, settingStructure).build(), + }); + + const toggle = screen.getByRole('checkbox'); + expect(toggle).toBeDisabled(); + expect(baseElement).toMatchSnapshot(); + }); + + it('should show skeleton when loading', () => { + const { baseElement } = render(, { + wrapper: baseAppRoot.withSetting('ABAC_Enabled', true, settingStructure).build(), + }); + expect(baseElement).toMatchSnapshot(); + }); + + it('should show reset button when value differs from package value', () => { + render(, { + wrapper: baseAppRoot.withSetting('ABAC_Enabled', true, settingStructure).build(), + }); + + expect(screen.getByRole('button', { name: /reset/i })).toBeInTheDocument(); + }); + + it('should not show reset button when value matches package value', () => { + render(, { + wrapper: baseAppRoot.withSetting('ABAC_Enabled', false, settingStructure).build(), + }); + + expect(screen.queryByRole('button', { name: /reset/i })).not.toBeInTheDocument(); + }); +}); diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACSettingToggle.stories.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACSettingToggle.stories.tsx new file mode 100644 index 0000000000000..6c5626baa0dc1 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/AdminABACSettingToggle.stories.tsx @@ -0,0 +1,65 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import type { Meta, StoryObj } from '@storybook/react'; + +import AdminABACSettingToggle from './AdminABACSettingToggle'; +import EditableSettingsProvider from '../settings/EditableSettingsProvider'; + +const meta: Meta = { + title: 'Admin/ABAC/AdminABACSettingToggle', + component: AdminABACSettingToggle, + parameters: { + layout: 'padded', + }, + decorators: [ + (Story) => { + const AppRoot = mockAppRoot() + .wrap((children) => {children}) + .withTranslations('en', 'core', { + ABAC_Enabled: 'Enable ABAC', + ABAC_Enabled_Description: 'Enable Attribute-Based Access Control', + ABAC_Warning_Modal_Title: 'Disable ABAC', + ABAC_Warning_Modal_Confirm_Text: 'Disable', + Cancel: 'Cancel', + }) + .withSetting('ABAC_Enabled', true, { + packageValue: false, + blocked: false, + public: true, + type: 'boolean', + i18nLabel: 'ABAC_Enabled', + i18nDescription: 'ABAC_Enabled_Description', + }) + .build(); + + return ( + + + + ); + }, + ], + args: { + hasABAC: true, + }, +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = { + args: { + hasABAC: true, + }, +}; + +export const Loading: Story = { + args: { + hasABAC: 'loading', + }, +}; + +export const False: Story = { + args: { + hasABAC: false, + }, +}; diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACSettingToggle.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACSettingToggle.tsx new file mode 100644 index 0000000000000..7d7077e08364c --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/AdminABACSettingToggle.tsx @@ -0,0 +1,95 @@ +import type { SettingValue } from '@rocket.chat/core-typings'; +import { useSetModal, useSettingsDispatch } from '@rocket.chat/ui-contexts'; +import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { EditableSetting } from '../EditableSettingsContext'; +import { useEditableSetting } from '../EditableSettingsContext'; +import AdminABACWarningModal from './AdminABACWarningModal'; +import MemoizedSetting from '../settings/Setting/MemoizedSetting'; +import SettingSkeleton from '../settings/Setting/SettingSkeleton'; + +type AdminABACSettingToggleProps = { + hasABAC: 'loading' | boolean; +}; + +const AdminABACSettingToggle = ({ hasABAC }: AdminABACSettingToggleProps) => { + const setting = useEditableSetting('ABAC_Enabled'); + const setModal = useSetModal(); + const dispatch = useSettingsDispatch(); + const { t } = useTranslation(); + + const [value, setValue] = useState(setting?.value === true); + + useEffect(() => { + setValue(setting?.value === true); + }, [setting]); + + const onChange = useCallback( + (value: boolean) => { + if (!setting) { + return; + } + + const handleChange = (value: boolean, setting: EditableSetting) => { + setValue(value); + dispatch([{ _id: setting._id, value }]); + }; + + if (value === false) { + return setModal( + { + handleChange(value, setting); + setModal(); + }} + onCancel={() => setModal()} + />, + ); + } + handleChange(value, setting); + }, + [dispatch, setModal, setting], + ); + + const onReset = useCallback(() => { + if (!setting) { + return; + } + const value = setting.packageValue as boolean; + setModal( + { + setValue(value); + dispatch([{ _id: setting._id, value }]); + setModal(); + }} + onCancel={() => setModal()} + />, + ); + }, [dispatch, setModal, setting]); + + if (!setting) { + return null; + } + + if (hasABAC === 'loading') { + return ; + } + + return ( + onChange(value === true)} + onResetButtonClick={() => onReset()} + /> + ); +}; +export default AdminABACSettingToggle; diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACSettings.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACSettings.tsx new file mode 100644 index 0000000000000..3741720317692 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/AdminABACSettings.tsx @@ -0,0 +1,30 @@ +import { Box, Callout, Margins } from '@rocket.chat/fuselage'; +import { Trans } from 'react-i18next'; + +import AdminABACSettingToggle from './AdminABACSettingToggle'; +import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule'; +import { links } from '../../../lib/links'; + +const AdminABACSettings = () => { + const hasABAC = useHasLicenseModule('abac'); + return ( + + + + + + + + User attributes are synchronized via LDAP + + Learn more + + + + + + + ); +}; + +export default AdminABACSettings; diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACTabs.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACTabs.tsx new file mode 100644 index 0000000000000..4fc73a491f44e --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/AdminABACTabs.tsx @@ -0,0 +1,24 @@ +import { Tabs, TabsItem } from '@rocket.chat/fuselage'; +import { useRouteParameter, useRouter } from '@rocket.chat/ui-contexts'; +import { useTranslation } from 'react-i18next'; + +const AdminABACTabs = () => { + const { t } = useTranslation(); + const router = useRouter(); + const tab = useRouteParameter('tab'); + const handleTabClick = (tab: string) => { + router.navigate({ + name: 'admin-ABAC', + params: { tab }, + }); + }; + return ( + + handleTabClick('settings')}> + {t('Settings')} + + + ); +}; + +export default AdminABACTabs; diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACWarningModal.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACWarningModal.tsx new file mode 100644 index 0000000000000..a6be0c83dcbcc --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/AdminABACWarningModal.tsx @@ -0,0 +1,48 @@ +import { Box } from '@rocket.chat/fuselage'; +import { GenericModal } from '@rocket.chat/ui-client'; +import { useRouter } from '@rocket.chat/ui-contexts'; +import { Trans, useTranslation } from 'react-i18next'; + +type AdminABACWarningModalProps = { + onConfirm: () => void; + onCancel: () => void; +}; + +const AdminABACWarningModal = ({ onConfirm, onCancel }: AdminABACWarningModalProps) => { + const { t } = useTranslation(); + const router = useRouter(); + const handleNavigate = () => { + onCancel(); + router.navigate({ + name: 'admin-ABAC', + params: { + tab: 'rooms', + }, + }); + }; + + return ( + + + You will not be able to automatically or manually manage users in existing ABAC-managed rooms. To restore a room's default access + control, it must be removed from ABAC management in + + {' '} + ABAC {'>'} Rooms + + . + + + ); +}; + +export default AdminABACWarningModal; diff --git a/apps/meteor/client/views/admin/ABAC/__snapshots__/AdminABACSettingToggle.spec.tsx.snap b/apps/meteor/client/views/admin/ABAC/__snapshots__/AdminABACSettingToggle.spec.tsx.snap new file mode 100644 index 0000000000000..39af2d46aa2f0 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/__snapshots__/AdminABACSettingToggle.spec.tsx.snap @@ -0,0 +1,165 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`AdminABACSettingToggle should be disabled when abac license is not installed 1`] = ` + +
+
+
+
+ + +
+ +