From 395af2888a025e783ff4b978c0a01cee03e4a123 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 22 Apr 2026 16:38:13 -0600 Subject: [PATCH 01/12] Roles as abac attributes on setting --- apps/meteor/app/api/server/v1/roles.ts | 9 +- .../lib/server/functions/saveUser/saveUser.ts | 8 ++ .../app/lib/server/methods/saveSetting.ts | 3 + .../app/lib/server/methods/saveSettings.ts | 5 + .../settings/server/lib/beforeSaveSetting.ts | 6 + apps/meteor/client/hooks/useRolesList.ts | 12 ++ .../ABACRoomsTab/RoomFormAttributeField.tsx | 9 +- .../ABAC/ABACSettingTab/SettingField.tsx | 37 +++--- .../ABAC/ABACSettingTab/SettingsPage.tsx | 1 + .../admin/ABAC/hooks/useAttributeList.ts | 53 ++++++-- apps/meteor/ee/server/api/abac/index.ts | 4 +- apps/meteor/ee/server/api/abac/schemas.ts | 11 +- apps/meteor/ee/server/configuration/abac.ts | 45 +++++++ apps/meteor/ee/server/settings/abac.ts | 9 ++ apps/meteor/server/lib/roles/addUserRoles.ts | 7 ++ .../server/lib/roles/afterUserRolesChanged.ts | 6 + .../server/lib/roles/removeUserFromRoles.ts | 7 ++ ee/packages/abac/src/constants.ts | 3 + ee/packages/abac/src/helper.ts | 22 +++- ee/packages/abac/src/index.ts | 114 +++++++++++++++++- .../subject-attributes-validations.spec.ts | 3 + .../abac/src/user-auto-removal.spec.ts | 6 + .../core-services/src/types/IAbacService.ts | 6 + packages/i18n/src/locales/en.i18n.json | 2 + .../model-typings/src/models/IRoomsModel.ts | 1 + .../model-typings/src/models/IUsersModel.ts | 2 + packages/models/src/models/Rooms.ts | 5 + packages/models/src/models/Users.ts | 40 ++++++ 28 files changed, 399 insertions(+), 37 deletions(-) create mode 100644 apps/meteor/app/settings/server/lib/beforeSaveSetting.ts create mode 100644 apps/meteor/client/hooks/useRolesList.ts create mode 100644 apps/meteor/server/lib/roles/afterUserRolesChanged.ts create mode 100644 ee/packages/abac/src/constants.ts diff --git a/apps/meteor/app/api/server/v1/roles.ts b/apps/meteor/app/api/server/v1/roles.ts index 3972a97ee906a..c159241a1f321 100644 --- a/apps/meteor/app/api/server/v1/roles.ts +++ b/apps/meteor/app/api/server/v1/roles.ts @@ -1,4 +1,4 @@ -import { api, Authorization } from '@rocket.chat/core-services'; +import { Abac, api, Authorization } from '@rocket.chat/core-services'; import type { IRole, IUserInRole } from '@rocket.chat/core-typings'; import { Roles, Users } from '@rocket.chat/models'; import { @@ -233,6 +233,13 @@ const rolesRoutes = API.v1 throw new Meteor.Error('error-role-in-use', "Cannot delete role because it's in use"); } + if (await Abac.isRoleInUseByAbacRooms(role._id)) { + throw new Meteor.Error( + 'error-abac-role-in-use-by-room', + `Cannot delete role "${role.name}": it is referenced by ABAC room policies using the RC-user-role attribute.`, + ); + } + await Roles.removeById(role._id); void notifyOnRoleChanged(role, 'removed'); diff --git a/apps/meteor/app/lib/server/functions/saveUser/saveUser.ts b/apps/meteor/app/lib/server/functions/saveUser/saveUser.ts index ad9f1599e31fe..e5bf04c24d58b 100644 --- a/apps/meteor/app/lib/server/functions/saveUser/saveUser.ts +++ b/apps/meteor/app/lib/server/functions/saveUser/saveUser.ts @@ -16,6 +16,7 @@ import { validateUserEditing } from './validateUserEditing'; import { wrapInSessionTransaction, onceTransactionCommitedSuccessfully } from '../../../../../server/database/utils'; import type { UserChangedAuditStore } from '../../../../../server/lib/auditServerEvents/userChanged'; import { callbacks } from '../../../../../server/lib/callbacks'; +import { afterUserRolesChanged } from '../../../../../server/lib/roles/afterUserRolesChanged'; import { shouldBreakInVersion } from '../../../../../server/lib/shouldBreakInVersion'; import { hasPermissionAsync } from '../../../../authorization/server/functions/hasPermission'; import { generatePassword } from '../../lib/generatePassword'; @@ -71,6 +72,9 @@ const findUserById = async (uid: IUser['_id']): Promise => { return user; }; +const arraysHaveSameMembers = (a: readonly string[] = [], b: readonly string[] = []): boolean => + a.length === b.length && new Set([...a, ...b]).size === a.length; + const _saveUser = (session?: ClientSession) => async function (userId: IUser['_id'], userData: SaveUserData, options?: SaveUserOptions) { const performedBy = await findUserById(userId); @@ -219,6 +223,10 @@ const _saveUser = (session?: ClientSession) => oldUser: oldUserData, }); + if (userData._id && userData.roles && !arraysHaveSameMembers(userData.roles, oldUserData?.roles)) { + await afterUserRolesChanged(userData._id); + } + await Apps.self?.triggerEvent(AppEvents.IPostUserUpdated, { user: userUpdated, previousUser: oldUserData, diff --git a/apps/meteor/app/lib/server/methods/saveSetting.ts b/apps/meteor/app/lib/server/methods/saveSetting.ts index 044a618dacac0..a2f823cd52893 100644 --- a/apps/meteor/app/lib/server/methods/saveSetting.ts +++ b/apps/meteor/app/lib/server/methods/saveSetting.ts @@ -8,6 +8,7 @@ import { updateAuditedByUser } from '../../../../server/settings/lib/auditedSett import { twoFactorRequired } from '../../../2fa/server/twoFactorRequired'; import { getSettingPermissionId } from '../../../authorization/lib'; import { hasPermissionAsync, hasAllPermissionAsync } from '../../../authorization/server/functions/hasPermission'; +import { beforeSaveSetting } from '../../../settings/server/lib/beforeSaveSetting'; import { disableCustomScripts } from '../functions/disableCustomScripts'; import { notifyOnSettingChanged } from '../lib/notifyListener'; @@ -67,6 +68,8 @@ Meteor.methods({ break; } + await beforeSaveSetting(_id, value); + const auditSettingOperation = updateAuditedByUser({ _id: uid, username: (await Meteor.userAsync())!.username!, diff --git a/apps/meteor/app/lib/server/methods/saveSettings.ts b/apps/meteor/app/lib/server/methods/saveSettings.ts index 11458f2f0be78..bde421589d07a 100644 --- a/apps/meteor/app/lib/server/methods/saveSettings.ts +++ b/apps/meteor/app/lib/server/methods/saveSettings.ts @@ -10,6 +10,7 @@ import { twoFactorRequired } from '../../../2fa/server/twoFactorRequired'; import { getSettingPermissionId } from '../../../authorization/lib'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { settings } from '../../../settings/server'; +import { beforeSaveSetting } from '../../../settings/server/lib/beforeSaveSetting'; import { disableCustomScripts } from '../functions/disableCustomScripts'; import { checkSettingValueBounds } from '../lib/checkSettingValueBonds'; import { notifyOnSettingChangedById } from '../lib/notifyListener'; @@ -127,6 +128,10 @@ Meteor.methods({ }); } + for (const { _id, value } of params) { + await beforeSaveSetting(_id, value); + } + const auditSettingOperation = updateAuditedByUser({ _id: uid, username: (await Meteor.userAsync())!.username!, diff --git a/apps/meteor/app/settings/server/lib/beforeSaveSetting.ts b/apps/meteor/app/settings/server/lib/beforeSaveSetting.ts new file mode 100644 index 0000000000000..d59ec8e415c34 --- /dev/null +++ b/apps/meteor/app/settings/server/lib/beforeSaveSetting.ts @@ -0,0 +1,6 @@ +import type { SettingValue } from '@rocket.chat/core-typings'; +import { makeFunction } from '@rocket.chat/patch-injection'; + +export const beforeSaveSetting = makeFunction(async (_settingId: string, _newValue: SettingValue): Promise => { + // default no-op +}); diff --git a/apps/meteor/client/hooks/useRolesList.ts b/apps/meteor/client/hooks/useRolesList.ts new file mode 100644 index 0000000000000..0caf1b1b78cc7 --- /dev/null +++ b/apps/meteor/client/hooks/useRolesList.ts @@ -0,0 +1,12 @@ +import { useEndpoint } from '@rocket.chat/ui-contexts'; +import { useQuery } from '@tanstack/react-query'; + +export const useRolesList = () => { + const rolesListEndpoint = useEndpoint('GET', '/v1/roles.list'); + + return useQuery({ + queryKey: ['roles', 'list'], + queryFn: () => rolesListEndpoint(), + staleTime: 60_000, + }); +}; diff --git a/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeField.tsx b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeField.tsx index 21a54fd8539f5..691331c0764c9 100644 --- a/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeField.tsx +++ b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeField.tsx @@ -10,7 +10,7 @@ type ABACAttributeAutocompleteProps = { labelId: string; onRemove: () => void; index: number; - attributeList: { value: string; label: string; attributeValues: string[] }[]; + attributeList: { value: string; label: string; attributeValues: string[]; valueLabels?: Record }[]; required?: boolean; }; @@ -52,8 +52,13 @@ const RoomFormAttributeField = ({ labelId, onRemove, index, attributeList, requi } const selectedAttributeData = attributeList.find((option) => option.value === keyField.value); + if (!selectedAttributeData) { + return []; + } + + const { attributeValues, valueLabels } = selectedAttributeData; - return selectedAttributeData?.attributeValues.map((value) => [value, value]) || []; + return attributeValues.map((value) => [value, valueLabels?.[value] ?? value]); }, [attributeList, keyField.value]); return ( diff --git a/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingField.tsx b/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingField.tsx index 6d743296c7316..1310a1ac2b707 100644 --- a/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingField.tsx +++ b/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingField.tsx @@ -1,7 +1,7 @@ import type { ISettingColor, SettingEditor, SettingValue } from '@rocket.chat/core-typings'; import { isSettingColor, isSetting } from '@rocket.chat/core-typings'; import { useDebouncedCallback } from '@rocket.chat/fuselage-hooks'; -import { useSettingsDispatch, useSettingStructure } from '@rocket.chat/ui-contexts'; +import { useSettingsDispatch, useSettingStructure, useToastMessageDispatch } from '@rocket.chat/ui-contexts'; import DOMPurify from 'dompurify'; import type { ReactElement } from 'react'; import { useEffect, useMemo, useState, useCallback } from 'react'; @@ -33,30 +33,37 @@ function SettingField({ className = undefined, settingId, sectionChanged }: Sett } const dispatch = useSettingsDispatch(); + const dispatchToastMessage = useToastMessageDispatch(); + + const { t, i18n } = useTranslation(); + + const [value, setValue] = useState(setting.value); + const [editor, setEditor] = useState(isSettingColor(setting) ? setting.editor : undefined); const update = useDebouncedCallback( - ({ value, editor }: { value?: SettingValue; editor?: SettingEditor }) => { + async ({ value, editor }: { value?: SettingValue; editor?: SettingEditor }) => { if (!persistedSetting) { return; } - dispatch([ - { - _id: persistedSetting._id, - ...(value !== undefined && { value }), - ...(editor !== undefined && { editor }), - }, - ]); + try { + await dispatch([ + { + _id: persistedSetting._id, + ...(value !== undefined && { value }), + ...(editor !== undefined && { editor }), + }, + ]); + } catch (error) { + dispatchToastMessage({ type: 'error', message: error }); + setValue(setting.value); + setEditor(isSettingColor(setting) ? setting.editor : undefined); + } }, 230, - [persistedSetting, dispatch], + [persistedSetting, dispatch, dispatchToastMessage, setting], ); - const { t, i18n } = useTranslation(); - - const [value, setValue] = useState(setting.value); - const [editor, setEditor] = useState(isSettingColor(setting) ? setting.editor : undefined); - useEffect(() => { setValue(setting.value); }, [setting.value]); diff --git a/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingsPage.tsx b/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingsPage.tsx index 62771f8a3a9dd..613987f857f74 100644 --- a/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingsPage.tsx +++ b/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingsPage.tsx @@ -19,6 +19,7 @@ const SettingsPage = () => { + {pdpType === 'local' && ( diff --git a/apps/meteor/client/views/admin/ABAC/hooks/useAttributeList.ts b/apps/meteor/client/views/admin/ABAC/hooks/useAttributeList.ts index d17fac26ea532..fda89aae805aa 100644 --- a/apps/meteor/client/views/admin/ABAC/hooks/useAttributeList.ts +++ b/apps/meteor/client/views/admin/ABAC/hooks/useAttributeList.ts @@ -1,20 +1,40 @@ import { useEndpoint } from '@rocket.chat/ui-contexts'; import { useQuery } from '@tanstack/react-query'; +import { useMemo } from 'react'; import { useIsABACAvailable } from './useIsABACAvailable'; import { ABACQueryKeys } from '../../../../lib/queryKeys'; +import { useRolesList } from '../../../../hooks/useRolesList'; const COUNT = 150; +const RC_USER_ROLE_ATTRIBUTE_KEY = 'RC-user-role'; + +type AttributeListItem = { + _id: string; + label: string; + value: string; + attributeValues: string[]; + valueLabels?: Record; +}; export const useAttributeList = () => { const attributesAutoCompleteEndpoint = useEndpoint('GET', '/v1/abac/attributes'); const isABACAvailable = useIsABACAvailable(); + const { data: rolesData } = useRolesList(); + + const roleLabels = useMemo(() => { + const labels: Record = {}; + for (const role of rolesData?.roles ?? []) { + labels[role._id] = role.name || role._id; + } + return labels; + }, [rolesData]); - return useQuery({ + const attributesQuery = useQuery({ enabled: isABACAvailable, queryKey: ABACQueryKeys.roomAttributes.list(), queryFn: async () => { - const firstPage = await attributesAutoCompleteEndpoint({ offset: 0, count: COUNT }); + const firstPage = await attributesAutoCompleteEndpoint({ offset: 0, count: COUNT, includeUserRoleAttribute: true }); const { attributes: firstPageAttributes, total } = firstPage; let currentPage = COUNT; @@ -26,14 +46,27 @@ export const useAttributeList = () => { } const remainingPages = await Promise.all(pages); - return { - attributes: [...firstPageAttributes, ...remainingPages.flatMap((page) => page.attributes)].map((attribute) => ({ - _id: attribute._id, - label: attribute.key, - value: attribute.key, - attributeValues: attribute.values, - })), - }; + return [...firstPageAttributes, ...remainingPages.flatMap((page) => page.attributes)]; }, }); + + const data = useMemo(() => { + if (!attributesQuery.data) { + return undefined; + } + return { + attributes: attributesQuery.data.map((attribute) => ({ + _id: attribute._id, + label: attribute.key, + value: attribute.key, + attributeValues: attribute.values, + ...(attribute.key === RC_USER_ROLE_ATTRIBUTE_KEY && { valueLabels: roleLabels }), + })), + }; + }, [attributesQuery.data, roleLabels]); + + return { + ...attributesQuery, + data, + }; }; diff --git a/apps/meteor/ee/server/api/abac/index.ts b/apps/meteor/ee/server/api/abac/index.ts index 43307ea132106..d11977aa5270e 100644 --- a/apps/meteor/ee/server/api/abac/index.ts +++ b/apps/meteor/ee/server/api/abac/index.ts @@ -182,7 +182,8 @@ const abacEndpoints = API.v1 }, }, async function action() { - const { offset, count } = await getPaginationItems(this.queryParams as Record); + const { includeUserRoleAttribute, ...paginationParams } = this.queryParams; + const { offset, count } = await getPaginationItems(paginationParams as Record); const { key, values } = this.queryParams; return API.v1.success( @@ -192,6 +193,7 @@ const abacEndpoints = API.v1 values, offset, count, + includeUserRoleAttribute, }, getActorFromUser(this.user), ), diff --git a/apps/meteor/ee/server/api/abac/schemas.ts b/apps/meteor/ee/server/api/abac/schemas.ts index 582b43291d1f3..3521159fa0cf4 100644 --- a/apps/meteor/ee/server/api/abac/schemas.ts +++ b/apps/meteor/ee/server/api/abac/schemas.ts @@ -60,13 +60,18 @@ const GetAbacAttributesQuery = { values: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN }, offset: { type: 'number' }, count: { type: 'number' }, + includeUserRoleAttribute: { type: 'boolean' }, }, additionalProperties: false, }; -export const GETAbacAttributesQuerySchema = ajvQuery.compile<{ key?: string; values?: string; offset: number; count?: number }>( - GetAbacAttributesQuery, -); +export const GETAbacAttributesQuerySchema = ajvQuery.compile<{ + key?: string; + values?: string; + offset: number; + count?: number; + includeUserRoleAttribute?: boolean; +}>(GetAbacAttributesQuery); const AbacAttributeRecord = { type: 'object', diff --git a/apps/meteor/ee/server/configuration/abac.ts b/apps/meteor/ee/server/configuration/abac.ts index 576e29797265a..edf158461d983 100644 --- a/apps/meteor/ee/server/configuration/abac.ts +++ b/apps/meteor/ee/server/configuration/abac.ts @@ -1,14 +1,41 @@ import { Abac } from '@rocket.chat/core-services'; import { cronJobs } from '@rocket.chat/cron'; import { License } from '@rocket.chat/license'; +import { Logger } from '@rocket.chat/logger'; import { Users } from '@rocket.chat/models'; import { isValidCron } from 'cron-validator'; import { Meteor } from 'meteor/meteor'; import { settings } from '../../../app/settings/server'; +import { beforeSaveSetting } from '../../../app/settings/server/lib/beforeSaveSetting'; +import { callbacks } from '../../../server/lib/callbacks'; +import { afterUserRolesChanged } from '../../../server/lib/roles/afterUserRolesChanged'; import { LDAPEE } from '../sdk'; const VIRTRU_PDP_SYNC_JOB = 'ABAC_Virtru_PDP_Sync'; +const abacLogger = new Logger('ABAC'); + +beforeSaveSetting.patch(async (next, settingId, newValue) => { + if ( + settingId === 'ABAC_Use_User_Roles_As_Attributes' && + newValue === false && + (await Abac.isAbacAttributeInUseByAnyRoom()) + ) { + throw new Meteor.Error( + 'error-abac-role-attribute-in-use', + 'Cannot disable `Use user roles as ABAC attributes` while rooms use the `RC-user-role` attribute. Remove it from those rooms first.', + ); + } + + await next(settingId, newValue); +}); + +afterUserRolesChanged.patch(async (next, userId) => { + if (await Abac.isRoleAttributeFeatureActive()) { + await Abac.syncUserRoleAttribute(userId); + } + await next(userId); +}); Meteor.startup(async () => { let stopWatcher: () => void; @@ -30,6 +57,23 @@ Meteor.startup(async () => { } }); + callbacks.add( + 'afterCreateUser', + async (user) => { + if (!(await Abac.isRoleAttributeFeatureActive()) || !user?._id || !user.roles?.length) { + return user; + } + try { + await Abac.syncUserRoleAttribute(user._id); + } catch (err) { + abacLogger.error({ msg: 'Failed to sync RC-user-role attribute on user create', userId: user._id, err }); + } + return user; + }, + callbacks.priority.MEDIUM, + 'abac.syncUserRoleAttributeOnCreate', + ); + async function configureVirtruPdpSync(): Promise { if (await cronJobs.has(VIRTRU_PDP_SYNC_JOB)) { await cronJobs.remove(VIRTRU_PDP_SYNC_JOB); @@ -59,6 +103,7 @@ Meteor.startup(async () => { down: async () => { stopWatcher?.(); stopCronWatcher?.(); + callbacks.remove('afterCreateUser', 'abac.syncUserRoleAttributeOnCreate'); if (await cronJobs.has(VIRTRU_PDP_SYNC_JOB)) { await cronJobs.remove(VIRTRU_PDP_SYNC_JOB); diff --git a/apps/meteor/ee/server/settings/abac.ts b/apps/meteor/ee/server/settings/abac.ts index 6dcbaf93c2f83..5b35731c1f8da 100644 --- a/apps/meteor/ee/server/settings/abac.ts +++ b/apps/meteor/ee/server/settings/abac.ts @@ -2,6 +2,7 @@ import { settingsRegistry } from '../../../app/settings/server'; const abacEnabledQuery = { _id: 'ABAC_Enabled', value: true }; const virtruPdpQuery = [abacEnabledQuery, { _id: 'ABAC_PDP_Type', value: 'virtru' }]; +const localPdpQuery = [abacEnabledQuery, { _id: 'ABAC_PDP_Type', value: 'local' }]; export function addSettings(): Promise { return settingsRegistry.addGroup('General', async function () { @@ -36,6 +37,14 @@ export function addSettings(): Promise { section: 'ABAC', enableQuery: abacEnabledQuery, }); + await this.add('ABAC_Use_User_Roles_As_Attributes', false, { + type: 'boolean', + public: true, + invalidValue: false, + section: 'ABAC', + i18nDescription: 'ABAC_Use_User_Roles_As_Attributes_Description', + enableQuery: localPdpQuery, + }); await this.add('Abac_Cache_Decision_Time_Seconds', 300, { type: 'int', public: true, diff --git a/apps/meteor/server/lib/roles/addUserRoles.ts b/apps/meteor/server/lib/roles/addUserRoles.ts index 4e098b6fc4477..d57295d736522 100644 --- a/apps/meteor/server/lib/roles/addUserRoles.ts +++ b/apps/meteor/server/lib/roles/addUserRoles.ts @@ -2,6 +2,7 @@ import { MeteorError } from '@rocket.chat/core-services'; import type { IRole, IUser, IRoom } from '@rocket.chat/core-typings'; import { Roles, Subscriptions, Users } from '@rocket.chat/models'; +import { afterUserRolesChanged } from './afterUserRolesChanged'; import { syncRoomRolePriorityForUserAndRoom } from './syncRoomRolePriority'; import { validateRoleList } from './validateRoleList'; import { notifyOnSubscriptionChangedByRoomIdAndUserId } from '../../../app/lib/server/lib/notifyListener'; @@ -24,6 +25,7 @@ export const addUserRolesAsync = async (userId: IUser['_id'], roles: IRole['_id' process.env.NODE_ENV === 'development' && console.warn('[WARN] RolesRaw.addUserRoles: roles should be an array'); } + let userRolesChanged = false; for await (const roleId of roles) { const role = await Roles.findOneById>(roleId, { projection: { scope: 1 } }); @@ -40,8 +42,13 @@ export const addUserRolesAsync = async (userId: IUser['_id'], roles: IRole['_id' } } else { await Users.addRolesByUserId(userId, [role._id]); + userRolesChanged = true; } } + if (userRolesChanged) { + await afterUserRolesChanged(userId); + } + return true; }; diff --git a/apps/meteor/server/lib/roles/afterUserRolesChanged.ts b/apps/meteor/server/lib/roles/afterUserRolesChanged.ts new file mode 100644 index 0000000000000..605f2bade01a6 --- /dev/null +++ b/apps/meteor/server/lib/roles/afterUserRolesChanged.ts @@ -0,0 +1,6 @@ +import type { IUser } from '@rocket.chat/core-typings'; +import { makeFunction } from '@rocket.chat/patch-injection'; + +export const afterUserRolesChanged = makeFunction(async (_userId: IUser['_id']): Promise => { + // default no-op +}); diff --git a/apps/meteor/server/lib/roles/removeUserFromRoles.ts b/apps/meteor/server/lib/roles/removeUserFromRoles.ts index db4bdab48413f..967e5844d9141 100644 --- a/apps/meteor/server/lib/roles/removeUserFromRoles.ts +++ b/apps/meteor/server/lib/roles/removeUserFromRoles.ts @@ -2,6 +2,7 @@ import { MeteorError } from '@rocket.chat/core-services'; import type { IRole, IUser, IRoom } from '@rocket.chat/core-typings'; import { Users, Subscriptions, Roles } from '@rocket.chat/models'; +import { afterUserRolesChanged } from './afterUserRolesChanged'; import { syncRoomRolePriorityForUserAndRoom } from './syncRoomRolePriority'; import { validateRoleList } from './validateRoleList'; import { notifyOnSubscriptionChangedByRoomIdAndUserId } from '../../../app/lib/server/lib/notifyListener'; @@ -24,6 +25,7 @@ export const removeUserFromRolesAsync = async (userId: IUser['_id'], roles: IRol throw new Error('Roles.removeUserRoles method received a role scope instead of a scope value.'); } + let userRolesChanged = false; for await (const roleId of roles) { const role = await Roles.findOneById>(roleId, { projection: { scope: 1 } }); if (!role) { @@ -38,8 +40,13 @@ export const removeUserFromRolesAsync = async (userId: IUser['_id'], roles: IRol } } else { await Users.removeRolesByUserId(userId, [roleId]); + userRolesChanged = true; } } + if (userRolesChanged) { + await afterUserRolesChanged(userId); + } + return true; }; diff --git a/ee/packages/abac/src/constants.ts b/ee/packages/abac/src/constants.ts new file mode 100644 index 0000000000000..818f91b2968cb --- /dev/null +++ b/ee/packages/abac/src/constants.ts @@ -0,0 +1,3 @@ +export const RC_USER_ROLE_ATTRIBUTE_KEY = 'RC-user-role'; + +export const RC_USER_ROLE_ATTRIBUTE_SYNTHETIC_ID = '__rc-user-role__'; diff --git a/ee/packages/abac/src/helper.ts b/ee/packages/abac/src/helper.ts index 5522b09b99662..bb9472fa36daf 100644 --- a/ee/packages/abac/src/helper.ts +++ b/ee/packages/abac/src/helper.ts @@ -1,7 +1,9 @@ +import { Abac } from '@rocket.chat/core-services'; import type { ILDAPEntry, IAbacAttributeDefinition, IRoom } from '@rocket.chat/core-typings'; -import { AbacAttributes, Rooms } from '@rocket.chat/models'; +import { AbacAttributes, Roles, Rooms } from '@rocket.chat/models'; import mem from 'mem'; +import { RC_USER_ROLE_ATTRIBUTE_KEY } from './constants'; import { AbacAttributeDefinitionNotFoundError, AbacCannotConvertDefaultRoomToAbacError, @@ -150,6 +152,15 @@ const getAttributeDefinitionsCached = mem(getAttributeDefinitionsFromDb, { cacheKey: JSON.stringify, }); +const getAllRoleIdsFromDb = (): Promise => + Roles.find({}, { projection: { _id: 1 } }) + .map((r) => r._id) + .toArray(); + +export const getAllRoleIdsCached = mem(getAllRoleIdsFromDb, { + maxAge: 5 * 60_000, +}); + export async function ensureAttributeDefinitionsExist(normalized: IAbacAttributeDefinition[]): Promise { if (!normalized.length) { return; @@ -158,7 +169,14 @@ export async function ensureAttributeDefinitionsExist(normalized: IAbacAttribute const uniqueKeys = [...new Set(normalized.map((a) => a.key))]; const attributeDefinitions = await getAttributeDefinitionsCached(uniqueKeys); - const definitionValuesMap = new Map>(attributeDefinitions.map((def) => [def.key, new Set(def.values)])); + const needsRoleAttribute = uniqueKeys.includes(RC_USER_ROLE_ATTRIBUTE_KEY) && (await Abac.isRoleAttributeFeatureActive()); + const roleAttributeDefinition: IAbacAttributeDefinition[] = needsRoleAttribute + ? [{ key: RC_USER_ROLE_ATTRIBUTE_KEY, values: await getAllRoleIdsCached() }] + : []; + + const allDefinitions = [...roleAttributeDefinition, ...attributeDefinitions]; + + const definitionValuesMap = new Map>(allDefinitions.map((def) => [def.key, new Set(def.values)])); if (definitionValuesMap.size !== uniqueKeys.length) { throw new AbacAttributeDefinitionNotFoundError(); } diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index cf8e6aae68a47..9b59166992d79 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -17,6 +17,7 @@ import type { Document, UpdateFilter } from 'mongodb'; import pLimit from 'p-limit'; import { Audit } from './audit'; +import { RC_USER_ROLE_ATTRIBUTE_KEY, RC_USER_ROLE_ATTRIBUTE_SYNTHETIC_ID } from './constants'; import { AbacAttributeInUseError, AbacAttributeNotFoundError, @@ -29,6 +30,7 @@ import { } from './errors'; import { getAbacRoom, + getAllRoleIdsCached, diffAttributes, extractAttribute, diffAttributeSets, @@ -61,9 +63,21 @@ export class AbacService extends ServiceClass implements IAbacService { decisionCacheTimeout = 60; // seconds + private abacEnabled = false; + + private useUserRolesAsAttributes = false; + constructor() { super(); + this.onSettingChanged('ABAC_Enabled', async ({ setting }): Promise => { + this.abacEnabled = !!setting.value; + }); + + this.onSettingChanged('ABAC_Use_User_Roles_As_Attributes', async ({ setting }): Promise => { + this.useUserRolesAsAttributes = !!setting.value; + }); + this.onSettingChanged('ABAC_PDP_Type', async ({ setting }): Promise => { const { value } = setting; if (value !== 'local' && value !== 'virtru') { @@ -167,6 +181,8 @@ export class AbacService extends ServiceClass implements IAbacService { override async started(): Promise { this.decisionCacheTimeout = await Settings.get('Abac_Cache_Decision_Time_Seconds'); + this.abacEnabled = await Settings.get('ABAC_Enabled'); + this.useUserRolesAsAttributes = await Settings.get('ABAC_Use_User_Roles_As_Attributes'); const pdpType = await Settings.get('ABAC_PDP_Type'); if (pdpType !== 'virtru') { @@ -205,6 +221,14 @@ export class AbacService extends ServiceClass implements IAbacService { values: Array.from(valuesSet), })); + const preservedRoleAttribute = (await this.isRoleAttributeFeatureActive()) + ? user.abacAttributes?.find((a) => a.key === RC_USER_ROLE_ATTRIBUTE_KEY) + : undefined; + + if (preservedRoleAttribute) { + finalAttributes.push(preservedRoleAttribute); + } + if (!finalAttributes.length) { if (Array.isArray(user.abacAttributes) && user.abacAttributes.length) { const finalUser = await Users.unsetAbacAttributesById(user._id); @@ -242,7 +266,13 @@ 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; + includeUserRoleAttribute?: boolean; + }): Promise<{ attributes: IAbacAttribute[]; offset: number; count: number; @@ -269,13 +299,28 @@ export class AbacService extends ServiceClass implements IAbacService { ); const attributes = await cursor.toArray(); - - return { + const base = { attributes, offset, count: attributes.length, total: await totalCount, }; + + if (!filters?.includeUserRoleAttribute || offset !== 0 || !(await this.isRoleAttributeFeatureActive())) { + return base; + } + + const synthetic: IAbacAttribute = { + _id: RC_USER_ROLE_ATTRIBUTE_SYNTHETIC_ID, + key: RC_USER_ROLE_ATTRIBUTE_KEY, + values: await getAllRoleIdsCached(), + } as IAbacAttribute; + + return { + ...base, + attributes: [synthetic, ...base.attributes], + count: base.count + 1, + }; } async listAbacRooms(filters?: { @@ -446,6 +491,11 @@ export class AbacService extends ServiceClass implements IAbacService { void Audit.objectAttributeChanged({ _id: room._id, name: room.name }, room.abacAttributes || [], normalized, 'updated', actor); const previous: IAbacAttributeDefinition[] = room.abacAttributes || []; + const roleAttributeAdded = + normalized.some((a) => a.key === RC_USER_ROLE_ATTRIBUTE_KEY) && !previous.some((a) => a.key === RC_USER_ROLE_ATTRIBUTE_KEY); + if (roleAttributeAdded) { + await this.syncRoomMembersWithRoleAttribute(rid); + } if (diffAttributeSets(previous, normalized).added) { await this.onRoomAttributesChanged(room, updated?.abacAttributes ?? normalized); } @@ -476,6 +526,9 @@ export class AbacService extends ServiceClass implements IAbacService { ); const next = [...previous, { key, values }]; + if (key === RC_USER_ROLE_ATTRIBUTE_KEY) { + await this.syncRoomMembersWithRoleAttribute(rid); + } await this.onRoomAttributesChanged(room, next); return; } @@ -549,6 +602,9 @@ export class AbacService extends ServiceClass implements IAbacService { void Audit.objectAttributeChanged({ _id: room._id, name: room.name }, previous, next, 'key-added', actor); + if (key === RC_USER_ROLE_ATTRIBUTE_KEY) { + await this.syncRoomMembersWithRoleAttribute(rid); + } await this.onRoomAttributesChanged(room, next); } @@ -582,6 +638,9 @@ export class AbacService extends ServiceClass implements IAbacService { } const updated = await Rooms.insertAbacAttributeIfNotExistsById(rid, key, values); + if (key === RC_USER_ROLE_ATTRIBUTE_KEY) { + await this.syncRoomMembersWithRoleAttribute(rid); + } void Audit.objectAttributeChanged( { _id: room._id, name: room.name }, room.abacAttributes || [], @@ -772,6 +831,55 @@ export class AbacService extends ServiceClass implements IAbacService { await this.pdp.getHealthStatus(); } + async isRoleAttributeFeatureActive(): Promise { + return this.abacEnabled && this.pdpType === 'local' && this.useUserRolesAsAttributes; + } + + async syncUserRoleAttribute(userId: IUser['_id']): Promise { + if (!(await this.isRoleAttributeFeatureActive())) { + return; + } + + const user = await Users.findOneById>(userId, { + projection: { _id: 1, username: 1, roles: 1, abacAttributes: 1, __rooms: 1 }, + }); + if (!user) { + return; + } + + const previous = user.abacAttributes || []; + const roleValues = user.roles || []; + + await Users.upsertAbacAttributeByKey(user._id, RC_USER_ROLE_ATTRIBUTE_KEY, roleValues); + + const next: IAbacAttributeDefinition[] = [ + ...previous.filter((a) => a.key !== RC_USER_ROLE_ATTRIBUTE_KEY), + { key: RC_USER_ROLE_ATTRIBUTE_KEY, values: roleValues }, + ]; + + if (diffAttributeSets(previous, next).removed) { + await this.onSubjectAttributesChanged({ ...user, abacAttributes: next } as IUser, next); + } + } + + async syncRoomMembersWithRoleAttribute(rid: IRoom['_id']): Promise { + if (!rid) { + return; + } + await Users.addRolesAsAbacAttributeForRoomMembers(RC_USER_ROLE_ATTRIBUTE_KEY, rid); + } + + async isRoleInUseByAbacRooms(roleId: string): Promise { + if (!roleId || !(await this.isRoleAttributeFeatureActive())) { + return false; + } + return Rooms.isAbacAttributeInUse(RC_USER_ROLE_ATTRIBUTE_KEY, [roleId]); + } + + async isAbacAttributeInUseByAnyRoom(): Promise { + return Rooms.isAbacAttributeKeyInUse(RC_USER_ROLE_ATTRIBUTE_KEY); + } + async evaluateRoomMembership(): Promise { if (!this.pdp || !(await this.pdp.isAvailable())) { return; diff --git a/ee/packages/abac/src/subject-attributes-validations.spec.ts b/ee/packages/abac/src/subject-attributes-validations.spec.ts index 88744bff7174e..81d4ed3651264 100644 --- a/ee/packages/abac/src/subject-attributes-validations.spec.ts +++ b/ee/packages/abac/src/subject-attributes-validations.spec.ts @@ -13,6 +13,9 @@ jest.mock('@rocket.chat/core-services', () => ({ removeUserFromRoom: jest.fn(), }, MeteorError: class extends Error {}, + Settings: { + get: jest.fn().mockResolvedValue(false), + }, })); const makeUser = (overrides: Partial = {}): IUser => diff --git a/ee/packages/abac/src/user-auto-removal.spec.ts b/ee/packages/abac/src/user-auto-removal.spec.ts index 29d234b49fc47..b4ca7ef9e8a7d 100644 --- a/ee/packages/abac/src/user-auto-removal.spec.ts +++ b/ee/packages/abac/src/user-auto-removal.spec.ts @@ -17,6 +17,12 @@ jest.mock('@rocket.chat/core-services', () => ({ await Subscriptions.removeByRoomIdAndUserId(roomId, user._id); }, }, + Settings: { + get: jest.fn().mockResolvedValue(false), + }, + Abac: { + isRoleAttributeFeatureActive: jest.fn().mockResolvedValue(false), + }, })); describe('AbacService integration (onRoomAttributesChanged)', () => { diff --git a/packages/core-services/src/types/IAbacService.ts b/packages/core-services/src/types/IAbacService.ts index 17e9a19fe93bf..8e184f4e66031 100644 --- a/packages/core-services/src/types/IAbacService.ts +++ b/packages/core-services/src/types/IAbacService.ts @@ -18,6 +18,7 @@ export interface IAbacService { values?: string; offset?: number; count?: number; + includeUserRoleAttribute?: boolean; }, actor?: AbacActor, ): Promise<{ attributes: IAbacAttribute[]; offset: number; count: number; total: number }>; @@ -48,4 +49,9 @@ export interface IAbacService { addSubjectAttributes(user: IUser, ldapUser: ILDAPEntry, map: Record, actor: AbacActor | undefined): Promise; evaluateRoomMembership(): Promise; getPDPHealth(): Promise; + isRoleAttributeFeatureActive(): Promise; + syncUserRoleAttribute(userId: IUser['_id']): Promise; + syncRoomMembersWithRoleAttribute(rid: IRoom['_id']): Promise; + isRoleInUseByAbacRooms(roleId: string): Promise; + isAbacAttributeInUseByAnyRoom(): Promise; } diff --git a/packages/i18n/src/locales/en.i18n.json b/packages/i18n/src/locales/en.i18n.json index 2abb096ac43b2..7aee68fb7a47f 100644 --- a/packages/i18n/src/locales/en.i18n.json +++ b/packages/i18n/src/locales/en.i18n.json @@ -51,6 +51,8 @@ "ABAC_Warning_Modal_Content": "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 <1>ABAC > Rooms.", "ABAC_ShowAttributesInRooms": "Show ABAC attributes in rooms", "ABAC_ShowAttributesInRooms_Description": "Display the ABAC attributes assigned to the room in the contextual bar", + "ABAC_Use_User_Roles_As_Attributes": "Use user roles as ABAC attributes", + "ABAC_Use_User_Roles_As_Attributes_Description": "When enabled, workspace roles are exposed as a user attribute `RC-user-role` and can be used in ABAC room policies. Only available with the native PDP.", "ABAC_Room": "Room", "ABAC_Room_Attribute": "Room Attribute", "ABAC_Element": "ABAC Element", diff --git a/packages/model-typings/src/models/IRoomsModel.ts b/packages/model-typings/src/models/IRoomsModel.ts index 39ce7d7ae3379..77ccc2e4d4391 100644 --- a/packages/model-typings/src/models/IRoomsModel.ts +++ b/packages/model-typings/src/models/IRoomsModel.ts @@ -48,6 +48,7 @@ export interface IRoomsModel extends IBaseModel { ): FindCursor; isAbacAttributeInUse(key: string, values: string[]): Promise; + isAbacAttributeKeyInUse(key: string): Promise; findOneByRoomIdAndUserId(rid: IRoom['_id'], uid: IUser['_id'], options?: FindOptions): Promise; diff --git a/packages/model-typings/src/models/IUsersModel.ts b/packages/model-typings/src/models/IUsersModel.ts index 2e8f84e647876..39d72804debb7 100644 --- a/packages/model-typings/src/models/IUsersModel.ts +++ b/packages/model-typings/src/models/IUsersModel.ts @@ -168,6 +168,8 @@ export interface IUsersModel extends IBaseModel { setAbacAttributesById(userId: IUser['_id'], attributes: NonNullable): Promise; unsetAbacAttributesById(userId: IUser['_id']): Promise; + upsertAbacAttributeByKey(userId: IUser['_id'], key: string, values: string[]): Promise; + addRolesAsAbacAttributeForRoomMembers(key: string, rid: IRoom['_id']): Promise; findActiveByRoomIds(roomIds: IRoom['_id'][], options?: FindOptions): FindCursor; updateStatusText(_id: IUser['_id'], statusText: string, options?: UpdateOptions): Promise; diff --git a/packages/models/src/models/Rooms.ts b/packages/models/src/models/Rooms.ts index c930207f79b13..9e37c4cd713a0 100644 --- a/packages/models/src/models/Rooms.ts +++ b/packages/models/src/models/Rooms.ts @@ -136,6 +136,11 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { return !!room; } + async isAbacAttributeKeyInUse(key: string): Promise { + const room = await this.findOne({ 'abacAttributes.key': key }, { projection: { _id: 1 } }); + return !!room; + } + findOneByRoomIdAndUserId(rid: IRoom['_id'], uid: IUser['_id'], options: FindOptions = {}): Promise { const query: Filter = { '_id': rid, diff --git a/packages/models/src/models/Users.ts b/packages/models/src/models/Users.ts index 76cd72d921a9a..e90fed6cc4667 100644 --- a/packages/models/src/models/Users.ts +++ b/packages/models/src/models/Users.ts @@ -143,6 +143,46 @@ export class UsersRaw extends BaseRaw> implements IU return this.findOneAndUpdate({ _id }, { $unset: { abacAttributes: 1 } }, { returnDocument: 'after' }); } + upsertAbacAttributeByKey(_id: IUser['_id'], key: string, values: string[]) { + return this.updateOne({ _id }, [ + { + $set: { + abacAttributes: { + $concatArrays: [ + { + $filter: { + input: { $ifNull: ['$abacAttributes', []] }, + cond: { $ne: ['$$this.key', key] }, + }, + }, + [{ key, values }], + ], + }, + }, + }, + ]); + } + + addRolesAsAbacAttributeForRoomMembers(key: string, rid: IRoom['_id']) { + return this.col.updateMany({ __rooms: rid }, [ + { + $set: { + abacAttributes: { + $concatArrays: [ + { + $filter: { + input: { $ifNull: ['$abacAttributes', []] }, + cond: { $ne: ['$$this.key', key] }, + }, + }, + [{ key, values: { $ifNull: ['$roles', []] } }], + ], + }, + }, + }, + ]); + } + findActiveByRoomIds(roomIds: IRoom['_id'][], options?: FindOptions) { return this.find({ active: true, __rooms: { $in: roomIds } }, options); } From e54f0d622ee81b0785c7fe67ffb845d7ce4f6173 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 22 Apr 2026 16:54:15 -0600 Subject: [PATCH 02/12] tool --- packages/core-typings/src/IRoom.ts | 3 +- packages/tools/src/hasSameElements.spec.ts | 32 ++++++++++++++++++++++ packages/tools/src/hasSameElements.ts | 21 ++++++++++++++ packages/tools/src/index.ts | 1 + 4 files changed, 55 insertions(+), 2 deletions(-) create mode 100644 packages/tools/src/hasSameElements.spec.ts create mode 100644 packages/tools/src/hasSameElements.ts diff --git a/packages/core-typings/src/IRoom.ts b/packages/core-typings/src/IRoom.ts index 278bd66c23ab7..4d35c8bdf8481 100644 --- a/packages/core-typings/src/IRoom.ts +++ b/packages/core-typings/src/IRoom.ts @@ -381,12 +381,11 @@ export type RoomAdminFieldsType = | 'teamMain' | 'announcement' | 'description' - | 'broadcast' | 'uids' | 'avatarETag' | 'abacAttributes'; -export type IRoomAdmin = Pick; +export type IRoomAdmin = Pick & { broadcast?: boolean }; export interface IRoomWithRetentionPolicy extends IRoom { retention: { diff --git a/packages/tools/src/hasSameElements.spec.ts b/packages/tools/src/hasSameElements.spec.ts new file mode 100644 index 0000000000000..f274f69942a1f --- /dev/null +++ b/packages/tools/src/hasSameElements.spec.ts @@ -0,0 +1,32 @@ +import { hasSameElements } from './hasSameElements'; + +describe('hasSameElements', () => { + test.each<[string, unknown[] | undefined, unknown[] | undefined, boolean]>([ + ['both empty', [], [], true], + ['both undefined', undefined, undefined, true], + ['one empty, one undefined', [], undefined, true], + ['same single element', ['a'], ['a'], true], + ['same order', ['a', 'b', 'c'], ['a', 'b', 'c'], true], + ['different order', ['a', 'b', 'c'], ['c', 'a', 'b'], true], + ['numbers same', [1, 2, 3], [3, 2, 1], true], + ['different length', ['a', 'b'], ['a', 'b', 'c'], false], + ['one empty vs non-empty', [], ['a'], false], + ['disjoint sets', ['a'], ['b'], false], + ['partial overlap', ['a', 'b'], ['a', 'c'], false], + ['duplicate in one', ['a', 'a'], ['a', 'b'], false], + ['same type mismatch', [1, 2], ['1', '2'], false], + ])('%s', (_label, a, b, expected) => { + expect(hasSameElements(a as any, b as any)).toBe(expected); + }); + + it('is symmetric', () => { + expect(hasSameElements(['a', 'b'], ['b', 'a'])).toBe(hasSameElements(['b', 'a'], ['a', 'b'])); + }); + + it('uses strict equality for references', () => { + const objA = { id: 1 }; + const objB = { id: 1 }; + expect(hasSameElements([objA], [objA])).toBe(true); + expect(hasSameElements([objA], [objB])).toBe(false); + }); +}); diff --git a/packages/tools/src/hasSameElements.ts b/packages/tools/src/hasSameElements.ts new file mode 100644 index 0000000000000..99c7b8b4ed0b7 --- /dev/null +++ b/packages/tools/src/hasSameElements.ts @@ -0,0 +1,21 @@ +/** + * Checks whether two arrays contain the same unique elements, order-insensitive. + * Arrays are compared as sets — duplicates within an array are ignored. + * Uses strict equality, so values must be primitives or reference-equal. + */ +export const hasSameElements = (a: readonly T[] = [], b: readonly T[] = []): boolean => { + if (a === b) { + return true; + } + const setA = new Set(a); + const setB = new Set(b); + if (setA.size !== setB.size) { + return false; + } + for (const value of setA) { + if (!setB.has(value)) { + return false; + } + } + return true; +}; diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index c8df164de8508..0caca3b1aa5ba 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -18,3 +18,4 @@ export * from './truncateString'; export * from './isTruthy'; export * from './getHeader'; export * from './isAbsoluteURL'; +export * from './hasSameElements'; From 3a973e75918d0cd0c8ff1fa4061c371fb6a6bba5 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 22 Apr 2026 17:07:21 -0600 Subject: [PATCH 03/12] rollback room type --- apps/meteor/app/lib/server/functions/saveUser/saveUser.ts | 6 ++---- packages/core-typings/src/IRoom.ts | 3 ++- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/apps/meteor/app/lib/server/functions/saveUser/saveUser.ts b/apps/meteor/app/lib/server/functions/saveUser/saveUser.ts index e5bf04c24d58b..a9c8c190d1420 100644 --- a/apps/meteor/app/lib/server/functions/saveUser/saveUser.ts +++ b/apps/meteor/app/lib/server/functions/saveUser/saveUser.ts @@ -3,6 +3,7 @@ import { MeteorError } from '@rocket.chat/core-services'; import { isUserFederated } from '@rocket.chat/core-typings'; import type { IUser, IRole, IUserSettings, RequiredField } from '@rocket.chat/core-typings'; import { Users } from '@rocket.chat/models'; +import { hasSameElements } from '@rocket.chat/tools'; import { Meteor } from 'meteor/meteor'; import type { ClientSession } from 'mongodb'; @@ -72,9 +73,6 @@ const findUserById = async (uid: IUser['_id']): Promise => { return user; }; -const arraysHaveSameMembers = (a: readonly string[] = [], b: readonly string[] = []): boolean => - a.length === b.length && new Set([...a, ...b]).size === a.length; - const _saveUser = (session?: ClientSession) => async function (userId: IUser['_id'], userData: SaveUserData, options?: SaveUserOptions) { const performedBy = await findUserById(userId); @@ -223,7 +221,7 @@ const _saveUser = (session?: ClientSession) => oldUser: oldUserData, }); - if (userData._id && userData.roles && !arraysHaveSameMembers(userData.roles, oldUserData?.roles)) { + if (userData._id && userData.roles && !hasSameElements(userData.roles, oldUserData?.roles)) { await afterUserRolesChanged(userData._id); } diff --git a/packages/core-typings/src/IRoom.ts b/packages/core-typings/src/IRoom.ts index 4d35c8bdf8481..278bd66c23ab7 100644 --- a/packages/core-typings/src/IRoom.ts +++ b/packages/core-typings/src/IRoom.ts @@ -381,11 +381,12 @@ export type RoomAdminFieldsType = | 'teamMain' | 'announcement' | 'description' + | 'broadcast' | 'uids' | 'avatarETag' | 'abacAttributes'; -export type IRoomAdmin = Pick & { broadcast?: boolean }; +export type IRoomAdmin = Pick; export interface IRoomWithRetentionPolicy extends IRoom { retention: { From 74727d20ebf4ea7bb8e8a4022885ac56796773dd Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 22 Apr 2026 17:14:06 -0600 Subject: [PATCH 04/12] set --- packages/tools/src/hasSameElements.ts | 20 ++------------------ packages/tools/tsconfig.json | 2 +- 2 files changed, 3 insertions(+), 19 deletions(-) diff --git a/packages/tools/src/hasSameElements.ts b/packages/tools/src/hasSameElements.ts index 99c7b8b4ed0b7..05d370a3cc73c 100644 --- a/packages/tools/src/hasSameElements.ts +++ b/packages/tools/src/hasSameElements.ts @@ -1,21 +1,5 @@ /** * Checks whether two arrays contain the same unique elements, order-insensitive. - * Arrays are compared as sets — duplicates within an array are ignored. - * Uses strict equality, so values must be primitives or reference-equal. */ -export const hasSameElements = (a: readonly T[] = [], b: readonly T[] = []): boolean => { - if (a === b) { - return true; - } - const setA = new Set(a); - const setB = new Set(b); - if (setA.size !== setB.size) { - return false; - } - for (const value of setA) { - if (!setB.has(value)) { - return false; - } - } - return true; -}; +export const hasSameElements = (a: readonly T[] = [], b: readonly T[] = []): boolean => + new Set(a).symmetricDifference(new Set(b)).size === 0; diff --git a/packages/tools/tsconfig.json b/packages/tools/tsconfig.json index 5954cf18b430f..22533e6758ee8 100644 --- a/packages/tools/tsconfig.json +++ b/packages/tools/tsconfig.json @@ -4,7 +4,7 @@ "declaration": true, "rootDir": "./src", "outDir": "./dist", - "lib": ["es2022"] + "lib": ["es2024", "ESNext.Collection"] }, "include": ["./src/**/*"] } From 6926e7be74f500869a5c8438e0b6ca0b305ee1ad Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 22 Apr 2026 17:26:26 -0600 Subject: [PATCH 05/12] model --- .../client/views/admin/ABAC/ABACSettingTab/SettingField.tsx | 1 - ee/packages/abac/src/index.ts | 2 +- packages/model-typings/src/models/IRoomsModel.ts | 2 +- packages/models/src/models/Rooms.ts | 5 ++--- 4 files changed, 4 insertions(+), 6 deletions(-) diff --git a/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingField.tsx b/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingField.tsx index 1310a1ac2b707..049e834fc82ff 100644 --- a/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingField.tsx +++ b/apps/meteor/client/views/admin/ABAC/ABACSettingTab/SettingField.tsx @@ -57,7 +57,6 @@ function SettingField({ className = undefined, settingId, sectionChanged }: Sett } catch (error) { dispatchToastMessage({ type: 'error', message: error }); setValue(setting.value); - setEditor(isSettingColor(setting) ? setting.editor : undefined); } }, 230, diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index 9b59166992d79..e43dba8a50ef7 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -877,7 +877,7 @@ export class AbacService extends ServiceClass implements IAbacService { } async isAbacAttributeInUseByAnyRoom(): Promise { - return Rooms.isAbacAttributeKeyInUse(RC_USER_ROLE_ATTRIBUTE_KEY); + return !!(await Rooms.findOneByAbacAttributeKey(RC_USER_ROLE_ATTRIBUTE_KEY)); } async evaluateRoomMembership(): Promise { diff --git a/packages/model-typings/src/models/IRoomsModel.ts b/packages/model-typings/src/models/IRoomsModel.ts index 77ccc2e4d4391..9ea81c1b5f858 100644 --- a/packages/model-typings/src/models/IRoomsModel.ts +++ b/packages/model-typings/src/models/IRoomsModel.ts @@ -48,7 +48,7 @@ export interface IRoomsModel extends IBaseModel { ): FindCursor; isAbacAttributeInUse(key: string, values: string[]): Promise; - isAbacAttributeKeyInUse(key: string): Promise; + findOneByAbacAttributeKey(key: string): Promise; findOneByRoomIdAndUserId(rid: IRoom['_id'], uid: IUser['_id'], options?: FindOptions): Promise; diff --git a/packages/models/src/models/Rooms.ts b/packages/models/src/models/Rooms.ts index 9e37c4cd713a0..494cb4da141fc 100644 --- a/packages/models/src/models/Rooms.ts +++ b/packages/models/src/models/Rooms.ts @@ -136,9 +136,8 @@ export class RoomsRaw extends BaseRaw implements IRoomsModel { return !!room; } - async isAbacAttributeKeyInUse(key: string): Promise { - const room = await this.findOne({ 'abacAttributes.key': key }, { projection: { _id: 1 } }); - return !!room; + findOneByAbacAttributeKey(key: string) { + return this.findOne({ 'abacAttributes.key': key }, { projection: { _id: 1 } }); } findOneByRoomIdAndUserId(rid: IRoom['_id'], uid: IUser['_id'], options: FindOptions = {}): Promise { From 187f0f679b5627b5c954d4821061fece94eec475 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 22 Apr 2026 17:50:51 -0600 Subject: [PATCH 06/12] use ui --- apps/meteor/client/hooks/useRolesList.ts | 12 ----- .../ABACRoomsTab/RoomFormAttributeField.tsx | 17 +++++-- .../admin/ABAC/hooks/useAttributeList.ts | 50 ++++++++++--------- apps/meteor/ee/server/api/abac/index.ts | 4 +- apps/meteor/ee/server/api/abac/schemas.ts | 11 ++-- ee/packages/abac/src/constants.ts | 2 - ee/packages/abac/src/index.ts | 30 ++--------- .../core-services/src/types/IAbacService.ts | 1 - 8 files changed, 48 insertions(+), 79 deletions(-) delete mode 100644 apps/meteor/client/hooks/useRolesList.ts diff --git a/apps/meteor/client/hooks/useRolesList.ts b/apps/meteor/client/hooks/useRolesList.ts deleted file mode 100644 index 0caf1b1b78cc7..0000000000000 --- a/apps/meteor/client/hooks/useRolesList.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { useEndpoint } from '@rocket.chat/ui-contexts'; -import { useQuery } from '@tanstack/react-query'; - -export const useRolesList = () => { - const rolesListEndpoint = useEndpoint('GET', '/v1/roles.list'); - - return useQuery({ - queryKey: ['roles', 'list'], - queryFn: () => rolesListEndpoint(), - staleTime: 60_000, - }); -}; diff --git a/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeField.tsx b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeField.tsx index 691331c0764c9..07772e4a77240 100644 --- a/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeField.tsx +++ b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeField.tsx @@ -1,21 +1,25 @@ import type { SelectOption } from '@rocket.chat/fuselage'; import { Box, Button, FieldError, FieldRow, MultiSelect, SelectFiltered } from '@rocket.chat/fuselage'; +import { useRolesDescription } from '@rocket.chat/ui-contexts'; import { useCallback, useMemo } from 'react'; import { useController, useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import type { RoomFormData } from './RoomForm'; +const RC_USER_ROLE_ATTRIBUTE_KEY = 'RC-user-role'; + type ABACAttributeAutocompleteProps = { labelId: string; onRemove: () => void; index: number; - attributeList: { value: string; label: string; attributeValues: string[]; valueLabels?: Record }[]; + attributeList: { value: string; label: string; attributeValues: string[] }[]; required?: boolean; }; const RoomFormAttributeField = ({ labelId, onRemove, index, attributeList, required = false }: ABACAttributeAutocompleteProps) => { const { t } = useTranslation(); + const getRolesDescription = useRolesDescription(); const { control, getValues, resetField } = useFormContext(); @@ -56,10 +60,15 @@ const RoomFormAttributeField = ({ labelId, onRemove, index, attributeList, requi return []; } - const { attributeValues, valueLabels } = selectedAttributeData; + const { attributeValues } = selectedAttributeData; + + if (keyField.value === RC_USER_ROLE_ATTRIBUTE_KEY) { + const labels = getRolesDescription(attributeValues); + return attributeValues.map((value, i) => [value, labels[i] ?? value]); + } - return attributeValues.map((value) => [value, valueLabels?.[value] ?? value]); - }, [attributeList, keyField.value]); + return attributeValues.map((value) => [value, value]); + }, [attributeList, keyField.value, getRolesDescription]); return ( diff --git a/apps/meteor/client/views/admin/ABAC/hooks/useAttributeList.ts b/apps/meteor/client/views/admin/ABAC/hooks/useAttributeList.ts index fda89aae805aa..5af60ca82c9ec 100644 --- a/apps/meteor/client/views/admin/ABAC/hooks/useAttributeList.ts +++ b/apps/meteor/client/views/admin/ABAC/hooks/useAttributeList.ts @@ -1,40 +1,35 @@ -import { useEndpoint } from '@rocket.chat/ui-contexts'; +import { AuthorizationContext, useEndpoint, useSetting } from '@rocket.chat/ui-contexts'; import { useQuery } from '@tanstack/react-query'; -import { useMemo } from 'react'; +import { useContext, useMemo } from 'react'; import { useIsABACAvailable } from './useIsABACAvailable'; import { ABACQueryKeys } from '../../../../lib/queryKeys'; -import { useRolesList } from '../../../../hooks/useRolesList'; const COUNT = 150; const RC_USER_ROLE_ATTRIBUTE_KEY = 'RC-user-role'; +const RC_USER_ROLE_SYNTHETIC_ID = '__rc-user-role__'; type AttributeListItem = { _id: string; label: string; value: string; attributeValues: string[]; - valueLabels?: Record; }; export const useAttributeList = () => { const attributesAutoCompleteEndpoint = useEndpoint('GET', '/v1/abac/attributes'); const isABACAvailable = useIsABACAvailable(); - const { data: rolesData } = useRolesList(); - const roleLabels = useMemo(() => { - const labels: Record = {}; - for (const role of rolesData?.roles ?? []) { - labels[role._id] = role.name || role._id; - } - return labels; - }, [rolesData]); + const useUserRolesAsAttributes = useSetting('ABAC_Use_User_Roles_As_Attributes', false); + const pdpType = useSetting('ABAC_PDP_Type', 'local'); + + const { getRoles } = useContext(AuthorizationContext); const attributesQuery = useQuery({ enabled: isABACAvailable, queryKey: ABACQueryKeys.roomAttributes.list(), queryFn: async () => { - const firstPage = await attributesAutoCompleteEndpoint({ offset: 0, count: COUNT, includeUserRoleAttribute: true }); + const firstPage = await attributesAutoCompleteEndpoint({ offset: 0, count: COUNT }); const { attributes: firstPageAttributes, total } = firstPage; let currentPage = COUNT; @@ -54,16 +49,25 @@ export const useAttributeList = () => { if (!attributesQuery.data) { return undefined; } - return { - attributes: attributesQuery.data.map((attribute) => ({ - _id: attribute._id, - label: attribute.key, - value: attribute.key, - attributeValues: attribute.values, - ...(attribute.key === RC_USER_ROLE_ATTRIBUTE_KEY && { valueLabels: roleLabels }), - })), - }; - }, [attributesQuery.data, roleLabels]); + + const attributes: AttributeListItem[] = attributesQuery.data.map((attribute) => ({ + _id: attribute._id, + label: attribute.key, + value: attribute.key, + attributeValues: attribute.values, + })); + + if (useUserRolesAsAttributes && pdpType === 'local') { + attributes.unshift({ + _id: RC_USER_ROLE_SYNTHETIC_ID, + label: RC_USER_ROLE_ATTRIBUTE_KEY, + value: RC_USER_ROLE_ATTRIBUTE_KEY, + attributeValues: [...getRoles().keys()], + }); + } + + return { attributes }; + }, [attributesQuery.data, useUserRolesAsAttributes, pdpType, getRoles]); return { ...attributesQuery, diff --git a/apps/meteor/ee/server/api/abac/index.ts b/apps/meteor/ee/server/api/abac/index.ts index d11977aa5270e..43307ea132106 100644 --- a/apps/meteor/ee/server/api/abac/index.ts +++ b/apps/meteor/ee/server/api/abac/index.ts @@ -182,8 +182,7 @@ const abacEndpoints = API.v1 }, }, async function action() { - const { includeUserRoleAttribute, ...paginationParams } = this.queryParams; - const { offset, count } = await getPaginationItems(paginationParams as Record); + const { offset, count } = await getPaginationItems(this.queryParams as Record); const { key, values } = this.queryParams; return API.v1.success( @@ -193,7 +192,6 @@ const abacEndpoints = API.v1 values, offset, count, - includeUserRoleAttribute, }, getActorFromUser(this.user), ), diff --git a/apps/meteor/ee/server/api/abac/schemas.ts b/apps/meteor/ee/server/api/abac/schemas.ts index 3521159fa0cf4..582b43291d1f3 100644 --- a/apps/meteor/ee/server/api/abac/schemas.ts +++ b/apps/meteor/ee/server/api/abac/schemas.ts @@ -60,18 +60,13 @@ const GetAbacAttributesQuery = { values: { type: 'string', minLength: 1, pattern: ATTRIBUTE_KEY_PATTERN }, offset: { type: 'number' }, count: { type: 'number' }, - includeUserRoleAttribute: { type: 'boolean' }, }, additionalProperties: false, }; -export const GETAbacAttributesQuerySchema = ajvQuery.compile<{ - key?: string; - values?: string; - offset: number; - count?: number; - includeUserRoleAttribute?: boolean; -}>(GetAbacAttributesQuery); +export const GETAbacAttributesQuerySchema = ajvQuery.compile<{ key?: string; values?: string; offset: number; count?: number }>( + GetAbacAttributesQuery, +); const AbacAttributeRecord = { type: 'object', diff --git a/ee/packages/abac/src/constants.ts b/ee/packages/abac/src/constants.ts index 818f91b2968cb..09b76b2664b14 100644 --- a/ee/packages/abac/src/constants.ts +++ b/ee/packages/abac/src/constants.ts @@ -1,3 +1 @@ export const RC_USER_ROLE_ATTRIBUTE_KEY = 'RC-user-role'; - -export const RC_USER_ROLE_ATTRIBUTE_SYNTHETIC_ID = '__rc-user-role__'; diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index e43dba8a50ef7..a0b1850442a32 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -17,7 +17,7 @@ import type { Document, UpdateFilter } from 'mongodb'; import pLimit from 'p-limit'; import { Audit } from './audit'; -import { RC_USER_ROLE_ATTRIBUTE_KEY, RC_USER_ROLE_ATTRIBUTE_SYNTHETIC_ID } from './constants'; +import { RC_USER_ROLE_ATTRIBUTE_KEY } from './constants'; import { AbacAttributeInUseError, AbacAttributeNotFoundError, @@ -30,7 +30,6 @@ import { } from './errors'; import { getAbacRoom, - getAllRoleIdsCached, diffAttributes, extractAttribute, diffAttributeSets, @@ -266,13 +265,7 @@ export class AbacService extends ServiceClass implements IAbacService { } } - async listAbacAttributes(filters?: { - key?: string; - values?: string; - offset?: number; - count?: number; - includeUserRoleAttribute?: boolean; - }): Promise<{ + async listAbacAttributes(filters?: { key?: string; values?: string; offset?: number; count?: number }): Promise<{ attributes: IAbacAttribute[]; offset: number; count: number; @@ -299,28 +292,13 @@ export class AbacService extends ServiceClass implements IAbacService { ); const attributes = await cursor.toArray(); - const base = { + + return { attributes, offset, count: attributes.length, total: await totalCount, }; - - if (!filters?.includeUserRoleAttribute || offset !== 0 || !(await this.isRoleAttributeFeatureActive())) { - return base; - } - - const synthetic: IAbacAttribute = { - _id: RC_USER_ROLE_ATTRIBUTE_SYNTHETIC_ID, - key: RC_USER_ROLE_ATTRIBUTE_KEY, - values: await getAllRoleIdsCached(), - } as IAbacAttribute; - - return { - ...base, - attributes: [synthetic, ...base.attributes], - count: base.count + 1, - }; } async listAbacRooms(filters?: { diff --git a/packages/core-services/src/types/IAbacService.ts b/packages/core-services/src/types/IAbacService.ts index 8e184f4e66031..1566ade2cac01 100644 --- a/packages/core-services/src/types/IAbacService.ts +++ b/packages/core-services/src/types/IAbacService.ts @@ -18,7 +18,6 @@ export interface IAbacService { values?: string; offset?: number; count?: number; - includeUserRoleAttribute?: boolean; }, actor?: AbacActor, ): Promise<{ attributes: IAbacAttribute[]; offset: number; count: number; total: number }>; From 6073c279375796c9f3ca377cb753b329264dc7c7 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 22 Apr 2026 17:59:57 -0600 Subject: [PATCH 07/12] constants --- .../views/admin/ABAC/ABACRoomsTab/RoomFormAttributeField.tsx | 3 +-- apps/meteor/client/views/admin/ABAC/constants.ts | 3 +++ .../meteor/client/views/admin/ABAC/hooks/useAttributeList.ts | 5 ++--- 3 files changed, 6 insertions(+), 5 deletions(-) create mode 100644 apps/meteor/client/views/admin/ABAC/constants.ts diff --git a/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeField.tsx b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeField.tsx index 07772e4a77240..7636f10f50088 100644 --- a/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeField.tsx +++ b/apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeField.tsx @@ -6,8 +6,7 @@ import { useController, useFormContext } from 'react-hook-form'; import { useTranslation } from 'react-i18next'; import type { RoomFormData } from './RoomForm'; - -const RC_USER_ROLE_ATTRIBUTE_KEY = 'RC-user-role'; +import { RC_USER_ROLE_ATTRIBUTE_KEY } from '../constants'; type ABACAttributeAutocompleteProps = { labelId: string; diff --git a/apps/meteor/client/views/admin/ABAC/constants.ts b/apps/meteor/client/views/admin/ABAC/constants.ts new file mode 100644 index 0000000000000..818f91b2968cb --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/constants.ts @@ -0,0 +1,3 @@ +export const RC_USER_ROLE_ATTRIBUTE_KEY = 'RC-user-role'; + +export const RC_USER_ROLE_ATTRIBUTE_SYNTHETIC_ID = '__rc-user-role__'; diff --git a/apps/meteor/client/views/admin/ABAC/hooks/useAttributeList.ts b/apps/meteor/client/views/admin/ABAC/hooks/useAttributeList.ts index 5af60ca82c9ec..4527a31ba6a14 100644 --- a/apps/meteor/client/views/admin/ABAC/hooks/useAttributeList.ts +++ b/apps/meteor/client/views/admin/ABAC/hooks/useAttributeList.ts @@ -4,10 +4,9 @@ import { useContext, useMemo } from 'react'; import { useIsABACAvailable } from './useIsABACAvailable'; import { ABACQueryKeys } from '../../../../lib/queryKeys'; +import { RC_USER_ROLE_ATTRIBUTE_KEY, RC_USER_ROLE_ATTRIBUTE_SYNTHETIC_ID } from '../constants'; const COUNT = 150; -const RC_USER_ROLE_ATTRIBUTE_KEY = 'RC-user-role'; -const RC_USER_ROLE_SYNTHETIC_ID = '__rc-user-role__'; type AttributeListItem = { _id: string; @@ -59,7 +58,7 @@ export const useAttributeList = () => { if (useUserRolesAsAttributes && pdpType === 'local') { attributes.unshift({ - _id: RC_USER_ROLE_SYNTHETIC_ID, + _id: RC_USER_ROLE_ATTRIBUTE_SYNTHETIC_ID, label: RC_USER_ROLE_ATTRIBUTE_KEY, value: RC_USER_ROLE_ATTRIBUTE_KEY, attributeValues: [...getRoles().keys()], From 49cfd972a0805fd86408fde7d3a7e4f170fb6e4a Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Wed, 22 Apr 2026 18:35:43 -0600 Subject: [PATCH 08/12] lint --- apps/meteor/ee/server/configuration/abac.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/apps/meteor/ee/server/configuration/abac.ts b/apps/meteor/ee/server/configuration/abac.ts index edf158461d983..b4129934f9df6 100644 --- a/apps/meteor/ee/server/configuration/abac.ts +++ b/apps/meteor/ee/server/configuration/abac.ts @@ -16,11 +16,7 @@ const VIRTRU_PDP_SYNC_JOB = 'ABAC_Virtru_PDP_Sync'; const abacLogger = new Logger('ABAC'); beforeSaveSetting.patch(async (next, settingId, newValue) => { - if ( - settingId === 'ABAC_Use_User_Roles_As_Attributes' && - newValue === false && - (await Abac.isAbacAttributeInUseByAnyRoom()) - ) { + if (settingId === 'ABAC_Use_User_Roles_As_Attributes' && newValue === false && (await Abac.isAbacAttributeInUseByAnyRoom())) { throw new Meteor.Error( 'error-abac-role-attribute-in-use', 'Cannot disable `Use user roles as ABAC attributes` while rooms use the `RC-user-role` attribute. Remove it from those rooms first.', From 5cca518b98c6ddc05b8542042fdf621aae47af04 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Thu, 23 Apr 2026 09:50:47 -0600 Subject: [PATCH 09/12] test --- apps/meteor/tests/end-to-end/api/abac.ts | 500 +++++++++++++++++++++++ 1 file changed, 500 insertions(+) diff --git a/apps/meteor/tests/end-to-end/api/abac.ts b/apps/meteor/tests/end-to-end/api/abac.ts index 014cc470948a5..751ec8fb94f0c 100644 --- a/apps/meteor/tests/end-to-end/api/abac.ts +++ b/apps/meteor/tests/end-to-end/api/abac.ts @@ -3004,4 +3004,504 @@ const addAbacAttributesToUserDirectly = async (userId: string, abacAttributes: I expect(memberIds).to.include(credentials['X-User-Id']); }); }); + + describe('RC-user-role attribute feature', () => { + const v1 = '/api/v1'; + const ROLE_ATTR_KEY = 'RC-user-role'; + + let memberA: IUser; + let memberB: IUser; + let outsider: IUser; + let abacRoom: IRoom; + + let roleAId = ''; + let roleBId = ''; + + before(async () => { + await updateSetting('ABAC_Enabled', true); + await updateSetting('ABAC_PDP_Type', 'local'); + await updateSetting('ABAC_Use_User_Roles_As_Attributes', true); + + const createRoleRes1 = await request + .post(`${v1}/roles.create`) + .set(credentials) + .send({ name: `rc_user_role_A_${Date.now()}` }) + .expect(200); + roleAId = createRoleRes1.body.role._id; + + const createRoleRes2 = await request + .post(`${v1}/roles.create`) + .set(credentials) + .send({ name: `rc_user_role_B_${Date.now()}` }) + .expect(200); + roleBId = createRoleRes2.body.role._id; + + memberA = await createUser(); + memberB = await createUser(); + outsider = await createUser(); + + abacRoom = (await createRoom({ type: 'p', name: `abac-rc-role-${Date.now()}` })).body.group; + await request.post(`${v1}/groups.invite`).set(credentials).send({ roomId: abacRoom._id, userId: memberA._id }).expect(200); + await request.post(`${v1}/groups.invite`).set(credentials).send({ roomId: abacRoom._id, userId: memberB._id }).expect(200); + }); + + after(async () => { + await request.delete(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`).set(credentials); + + await deleteRoom({ type: 'p', roomId: abacRoom._id }); + + await request.post(`${v1}/roles.delete`).set(credentials).send({ roleId: roleAId }); + await request.post(`${v1}/roles.delete`).set(credentials).send({ roleId: roleBId }); + + await deleteUser(memberA); + await deleteUser(memberB); + await deleteUser(outsider); + + await updateSetting('ABAC_Use_User_Roles_As_Attributes', false); + }); + + describe('Role add/remove sync', () => { + beforeEach(async () => { + await updateSetting('ABAC_Use_User_Roles_As_Attributes', true); + await request.post(`${v1}/roles.removeUserFromRole`).set(credentials).send({ roleId: roleAId, username: memberA.username }); + await request.post(`${v1}/roles.removeUserFromRole`).set(credentials).send({ roleId: roleBId, username: memberA.username }); + }); + + it('should sync RC-user-role attribute after adding a role to a user', async () => { + await request.post(`${v1}/roles.addUserToRole`).set(credentials).send({ roleId: roleAId, username: memberA.username }).expect(200); + + const userRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); + const roleAttr = (userRes.body.user.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY); + expect(roleAttr).to.exist; + expect(roleAttr!.values).to.include.members([roleAId, 'user']); + }); + + it('should shrink RC-user-role values after removing a role while keeping the key', async () => { + await request.post(`${v1}/roles.addUserToRole`).set(credentials).send({ roleId: roleAId, username: memberA.username }).expect(200); + await request.post(`${v1}/roles.addUserToRole`).set(credentials).send({ roleId: roleBId, username: memberA.username }).expect(200); + + await request + .post(`${v1}/roles.removeUserFromRole`) + .set(credentials) + .send({ roleId: roleAId, username: memberA.username }) + .expect(200); + + const userRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); + const roleAttr = (userRes.body.user.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY); + expect(roleAttr).to.exist; + expect(roleAttr!.values).to.include(roleBId); + expect(roleAttr!.values).to.not.include(roleAId); + }); + + it('should be a no-op when adding a role the user already has', async () => { + await request.post(`${v1}/roles.addUserToRole`).set(credentials).send({ roleId: roleAId, username: memberA.username }).expect(200); + const beforeRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); + const before = beforeRes.body.user.abacAttributes || []; + + await request.post(`${v1}/roles.addUserToRole`).set(credentials).send({ roleId: roleAId, username: memberA.username }).expect(200); + + const afterRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); + expect(afterRes.body.user.abacAttributes || []).to.deep.equal(before); + }); + + it('should be a no-op when removing a role the user does not have', async () => { + await request.post(`${v1}/roles.addUserToRole`).set(credentials).send({ roleId: roleAId, username: memberA.username }).expect(200); + const beforeRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); + const before = beforeRes.body.user.abacAttributes || []; + + await request + .post(`${v1}/roles.removeUserFromRole`) + .set(credentials) + .send({ roleId: roleBId, username: memberA.username }) + .expect(200); + + const afterRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); + expect(afterRes.body.user.abacAttributes || []).to.deep.equal(before); + }); + + it('should not write RC-user-role when the feature is OFF', async () => { + await updateSetting('ABAC_Use_User_Roles_As_Attributes', false); + + await addAbacAttributesToUserDirectly(memberA._id, []); + + await request.post(`${v1}/roles.addUserToRole`).set(credentials).send({ roleId: roleAId, username: memberA.username }).expect(200); + + const userRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); + const attrs = userRes.body.user.abacAttributes || []; + expect(attrs.find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY)).to.be.undefined; + + await updateSetting('ABAC_Use_User_Roles_As_Attributes', true); + }); + }); + + describe('users.update / users.create sync', () => { + beforeEach(async () => { + await updateSetting('ABAC_Use_User_Roles_As_Attributes', true); + await request + .post(`${v1}/users.update`) + .set(credentials) + .send({ userId: memberA._id, data: { roles: ['user'] } }) + .expect(200); + }); + + it('should sync the attribute when users.update changes the roles array', async () => { + await request + .post(`${v1}/users.update`) + .set(credentials) + .send({ userId: memberA._id, data: { roles: ['user', roleAId] } }) + .expect(200); + + const userRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); + const roleAttr = (userRes.body.user.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY); + expect(roleAttr).to.exist; + expect(roleAttr!.values).to.have.members(['user', roleAId]); + }); + + it('should not re-sync when users.update receives the same roles (reordered)', async () => { + await request + .post(`${v1}/users.update`) + .set(credentials) + .send({ userId: memberA._id, data: { roles: ['user', roleAId] } }) + .expect(200); + const beforeRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); + const before = beforeRes.body.user.abacAttributes || []; + + await request + .post(`${v1}/users.update`) + .set(credentials) + .send({ userId: memberA._id, data: { roles: [roleAId, 'user'] } }) + .expect(200); + + const afterRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); + expect(afterRes.body.user.abacAttributes || []).to.deep.equal(before); + }); + + it('should not touch the attribute when users.update has no roles field', async () => { + await request + .post(`${v1}/users.update`) + .set(credentials) + .send({ userId: memberA._id, data: { roles: ['user', roleAId] } }) + .expect(200); + const beforeRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); + const before = beforeRes.body.user.abacAttributes || []; + + await request + .post(`${v1}/users.update`) + .set(credentials) + .send({ userId: memberA._id, data: { name: 'renamed-in-test' } }) + .expect(200); + + const afterRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); + expect(afterRes.body.user.abacAttributes || []).to.deep.equal(before); + }); + + it('should sync the attribute when users.create is called with explicit roles', async () => { + const username = `a34user_${Date.now()}`; + const createRes = await request + .post(`${v1}/users.create`) + .set(credentials) + .send({ + email: `${username}@test.com`, + name: username, + username, + password: 'Abc123!!', + roles: ['user', roleAId], + }) + .expect(200); + const createdId = createRes.body.user._id as string; + + try { + const userRes = await request.get(`${v1}/users.info`).set(credentials).query({ username }).expect(200); + const roleAttr = (userRes.body.user.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY); + expect(roleAttr).to.exist; + expect(roleAttr!.values).to.have.members(['user', roleAId]); + } finally { + await deleteUser({ _id: createdId }); + } + }); + + it('should reflect the default role when users.create is called without roles', async () => { + const username = `a35user_${Date.now()}`; + const createRes = await request + .post(`${v1}/users.create`) + .set(credentials) + .send({ + email: `${username}@test.com`, + name: username, + username, + password: 'Abc123!!', + }) + .expect(200); + const createdId = createRes.body.user._id as string; + + try { + const userRes = await request.get(`${v1}/users.info`).set(credentials).query({ username }).expect(200); + const roleAttr = (userRes.body.user.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY); + expect(roleAttr).to.exist; + expect(roleAttr!.values).to.include('user'); + } finally { + await deleteUser({ _id: createdId }); + } + }); + }); + + describe('Room attribute ops involving RC-user-role', () => { + beforeEach(async () => { + await updateSetting('ABAC_Use_User_Roles_As_Attributes', true); + await request + .post(`${v1}/users.update`) + .set(credentials) + .send({ userId: memberA._id, data: { roles: ['user', roleAId] } }) + .expect(200); + await request + .post(`${v1}/users.update`) + .set(credentials) + .send({ userId: memberB._id, data: { roles: ['user', roleAId] } }) + .expect(200); + await request + .post(`${v1}/users.update`) + .set(credentials) + .send({ userId: outsider._id, data: { roles: ['user'] } }) + .expect(200); + + await request.delete(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`).set(credentials); + }); + + afterEach(async () => { + await request.delete(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`).set(credentials); + }); + + it("should sync members' attributes to mirror their own roles when RC-user-role is added to the room", async () => { + await request + .post(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`) + .set(credentials) + .send({ values: [roleAId] }) + .expect(200); + + const aRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); + const bRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberB.username }).expect(200); + const outRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: outsider.username }).expect(200); + + const aRoleAttr = (aRes.body.user.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY); + const bRoleAttr = (bRes.body.user.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY); + const outRoleAttr = (outRes.body.user.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY); + + expect(aRoleAttr).to.exist; + expect(aRoleAttr!.values).to.have.members(['user', roleAId]); + expect(bRoleAttr).to.exist; + expect(bRoleAttr!.values).to.have.members(['user', roleAId]); + expect(outRoleAttr).to.be.undefined; + }); + + it("should not narrow members' attributes when the room's RC-user-role values change", async () => { + await request + .post(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`) + .set(credentials) + .send({ values: [roleAId] }) + .expect(200); + + await request + .post(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`) + .set(credentials) + .send({ values: [roleBId] }) + .expect(200); + + const aRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); + const aRoleAttr = (aRes.body.user.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY); + expect(aRoleAttr!.values).to.have.members(['user', roleAId]); + }); + + it('should sync members when RC-user-role is set via bulk attribute replace', async () => { + await request + .post(`${v1}/abac/rooms/${abacRoom._id}/attributes`) + .set(credentials) + .send({ attributes: { [ROLE_ATTR_KEY]: [roleAId] } }) + .expect(200); + + const aRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); + const aRoleAttr = (aRes.body.user.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY); + expect(aRoleAttr).to.exist; + expect(aRoleAttr!.values).to.have.members(['user', roleAId]); + }); + + it('should leave the user-side attribute intact when RC-user-role is deleted from the room', async () => { + await request + .post(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`) + .set(credentials) + .send({ values: [roleAId] }) + .expect(200); + + await request.delete(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`).set(credentials).expect(200); + + const roomRes = await request.get(`${v1}/rooms.info`).set(credentials).query({ roomId: abacRoom._id }).expect(200); + expect((roomRes.body.room.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY)).to.be.undefined; + + const aRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); + const aRoleAttr = (aRes.body.user.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY); + expect(aRoleAttr).to.exist; + expect(aRoleAttr!.values).to.have.members(['user', roleAId]); + }); + + it('should never touch a non-member when any RC-user-role room op runs', async () => { + await addAbacAttributesToUserDirectly(outsider._id, []); + + await request + .post(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`) + .set(credentials) + .send({ values: [roleAId] }) + .expect(200); + const after1Res = await request.get(`${v1}/users.info`).set(credentials).query({ username: outsider.username }).expect(200); + expect((after1Res.body.user.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY)).to.be.undefined; + + await request + .post(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`) + .set(credentials) + .send({ values: [roleBId] }) + .expect(200); + const after2Res = await request.get(`${v1}/users.info`).set(credentials).query({ username: outsider.username }).expect(200); + expect((after2Res.body.user.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY)).to.be.undefined; + + await request.delete(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`).set(credentials).expect(200); + const after3Res = await request.get(`${v1}/users.info`).set(credentials).query({ username: outsider.username }).expect(200); + expect((after3Res.body.user.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY)).to.be.undefined; + }); + + it('should reject adding RC-user-role to a room when the feature is OFF', async () => { + await updateSetting('ABAC_Use_User_Roles_As_Attributes', false); + + const res = await request + .post(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`) + .set(credentials) + .send({ values: [roleAId] }) + .expect(400); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error').that.includes('error-attribute-definition-not-found'); + + await updateSetting('ABAC_Use_User_Roles_As_Attributes', true); + }); + }); + + describe('roles.delete guard', () => { + beforeEach(async () => { + await updateSetting('ABAC_Use_User_Roles_As_Attributes', true); + await request.delete(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`).set(credentials); + }); + + afterEach(async () => { + await request.delete(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`).set(credentials); + }); + + it('should allow deleting a role not referenced by any ABAC room', async () => { + const createRes = await request + .post(`${v1}/roles.create`) + .set(credentials) + .send({ name: `a51_${Date.now()}` }) + .expect(200); + const roleId = createRes.body.role._id; + + await request.post(`${v1}/roles.delete`).set(credentials).send({ roleId }).expect(200); + }); + + it('should block deleting a role referenced by an ABAC room via RC-user-role', async () => { + const createRes = await request + .post(`${v1}/roles.create`) + .set(credentials) + .send({ name: `a52_${Date.now()}` }) + .expect(200); + const roleId = createRes.body.role._id; + + await request + .post(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`) + .set(credentials) + .send({ values: [roleId] }) + .expect(200); + + const res = await request.post(`${v1}/roles.delete`).set(credentials).send({ roleId }).expect(400); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error').that.includes('error-abac-role-in-use-by-room'); + + await request.delete(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`).set(credentials); + await request.post(`${v1}/roles.delete`).set(credentials).send({ roleId }).expect(200); + }); + + it('should allow the same role deletion when the feature is OFF (guard inactive)', async () => { + const createRes = await request + .post(`${v1}/roles.create`) + .set(credentials) + .send({ name: `a53_${Date.now()}` }) + .expect(200); + const roleId = createRes.body.role._id; + + await request + .post(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`) + .set(credentials) + .send({ values: [roleId] }) + .expect(200); + + await request.delete(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`).set(credentials); + await updateSetting('ABAC_Use_User_Roles_As_Attributes', false); + await request.post(`${v1}/roles.delete`).set(credentials).send({ roleId }).expect(200); + + await updateSetting('ABAC_Use_User_Roles_As_Attributes', true); + }); + + }); + + describe('Setting-disable guard', () => { + beforeEach(async () => { + await updateSetting('ABAC_Use_User_Roles_As_Attributes', true); + await request.delete(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`).set(credentials); + }); + + afterEach(async () => { + await request.delete(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`).set(credentials); + await updateSetting('ABAC_Use_User_Roles_As_Attributes', true); + }); + + it('should allow disabling the setting when no room uses RC-user-role', async () => { + const res = await request + .post(`${v1}/settings/ABAC_Use_User_Roles_As_Attributes`) + .set(credentials) + .send({ value: false }) + .expect(200); + expect(res.body).to.have.property('success', true); + }); + + it('should reject disabling while a room uses RC-user-role and keep the setting true', async () => { + await request + .post(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`) + .set(credentials) + .send({ values: [roleAId] }) + .expect(200); + + const res = await request + .post(`${v1}/settings/ABAC_Use_User_Roles_As_Attributes`) + .set(credentials) + .send({ value: false }) + .expect(400); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error').that.includes('error-abac-role-attribute-in-use'); + + const check = await request.get(`${v1}/settings/ABAC_Use_User_Roles_As_Attributes`).set(credentials).expect(200); + expect(check.body).to.have.property('value', true); + }); + + it('should allow disabling after clearing RC-user-role from all rooms', async () => { + await request + .post(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`) + .set(credentials) + .send({ values: [roleAId] }) + .expect(200); + + await request.delete(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`).set(credentials); + + const res = await request + .post(`${v1}/settings/ABAC_Use_User_Roles_As_Attributes`) + .set(credentials) + .send({ value: false }) + .expect(200); + expect(res.body).to.have.property('success', true); + }); + }); + }); }); From 3930cdf4cfe99be3806126255e454899b8c21736 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Thu, 23 Apr 2026 10:22:23 -0600 Subject: [PATCH 10/12] lint --- apps/meteor/tests/end-to-end/api/abac.ts | 1 - 1 file changed, 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 751ec8fb94f0c..809ad7aebeca1 100644 --- a/apps/meteor/tests/end-to-end/api/abac.ts +++ b/apps/meteor/tests/end-to-end/api/abac.ts @@ -3444,7 +3444,6 @@ const addAbacAttributesToUserDirectly = async (userId: string, abacAttributes: I await updateSetting('ABAC_Use_User_Roles_As_Attributes', true); }); - }); describe('Setting-disable guard', () => { From 8c106b47e6692c46086382a668a9e22ab10a58e9 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Thu, 23 Apr 2026 11:14:29 -0600 Subject: [PATCH 11/12] test --- apps/meteor/tests/end-to-end/api/abac.ts | 1475 +++++++++++----------- 1 file changed, 725 insertions(+), 750 deletions(-) diff --git a/apps/meteor/tests/end-to-end/api/abac.ts b/apps/meteor/tests/end-to-end/api/abac.ts index 809ad7aebeca1..1d48cbbe65ca6 100644 --- a/apps/meteor/tests/end-to-end/api/abac.ts +++ b/apps/meteor/tests/end-to-end/api/abac.ts @@ -2568,352 +2568,538 @@ const addAbacAttributesToUserDirectly = async (userId: string, abacAttributes: I ]); }); }); -}); - -(IS_EE ? describe : describe.skip)('[ABAC] External PDP (mock-server)', function () { - this.retries(0); - - const attrKey = `ext_pdp_attr_${Date.now()}`; - - before((done) => { - getCredentials(done); - }); - - before(async function () { - this.timeout(15000); - - const healthy = await mockServerHealthy(); - expect(healthy, 'mock-server is not reachable — ensure it is running').to.be.true; - - await updatePermission('abac-management', ['admin']); - await updateSetting('ABAC_Enabled', true); - await updateSetting('ABAC_PDP_Type', 'virtru'); - await Promise.all([ - updateSetting('ABAC_Virtru_Base_URL', 'http://mock-server:8080'), - updateSetting('ABAC_Virtru_OIDC_Endpoint', 'http://mock-server:8080/auth/realms/mock'), - updateSetting('ABAC_Virtru_Client_ID', 'mock-client'), - updateSetting('ABAC_Virtru_Client_Secret', 'mock-secret'), - updateSetting('ABAC_Virtru_Default_Entity_Key', 'emailAddress'), - updateSetting('ABAC_Virtru_Attribute_Namespace', 'example.com'), - updateSetting('Abac_Cache_Decision_Time_Seconds', 0), - ]); - - await request - .post('/api/v1/abac/attributes') - .set(credentials) - .send({ key: attrKey, values: ['alpha', 'beta', 'gamma'] }) - .expect(200); - }); - - after(async function () { - this.timeout(10000); - - await mockServerReset(); - await updateSetting('ABAC_PDP_Type', 'local'); - await updateSetting('ABAC_Enabled', false); - }); - describe('PERMIT all: users remain when PDP permits everyone', () => { - let room: IRoom; - let user: IUser; - let userCreds: Credentials; + describe('RC-user-role attribute feature', () => { + const ROLE_ATTR_KEY = 'RC-user-role'; - before(async function () { - this.timeout(15000); + let memberA: IUser; + let memberB: IUser; + let outsider: IUser; + let abacRoom: IRoom; - user = await createUser(); - userCreds = await login(user.username, password); + let roleAId = ''; + let roleBId = ''; - room = (await createRoom({ type: 'p', name: `extpdp-permit-${Date.now()}` })).body.group; + before(async () => { + await updateSetting('ABAC_Enabled', true); + await updateSetting('ABAC_PDP_Type', 'local'); + await updateSetting('ABAC_Use_User_Roles_As_Attributes', true); - await request - .post('/api/v1/groups.invite') + const createRoleRes1 = await request + .post(`${v1}/roles.create`) .set(credentials) - .send({ roomId: room._id, usernames: [user.username] }) + .send({ name: `rc_user_role_A_${Date.now()}` }) .expect(200); + roleAId = createRoleRes1.body.role._id; - await mockServerReset(); - await seedDefaultMocks(); - await seedGetDecisionBulk([ - { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }, - { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }, - ]); - - await request - .post(`/api/v1/abac/rooms/${room._id}/attributes/${attrKey}`) + const createRoleRes2 = await request + .post(`${v1}/roles.create`) .set(credentials) - .send({ values: ['alpha'] }) + .send({ name: `rc_user_role_B_${Date.now()}` }) .expect(200); + roleBId = createRoleRes2.body.role._id; + + memberA = await createUser(); + memberB = await createUser(); + outsider = await createUser(); + + abacRoom = (await createRoom({ type: 'p', name: `abac-rc-role-${Date.now()}` })).body.group; + await request.post(`${v1}/groups.invite`).set(credentials).send({ roomId: abacRoom._id, userId: memberA._id }).expect(200); + await request.post(`${v1}/groups.invite`).set(credentials).send({ roomId: abacRoom._id, userId: memberB._id }).expect(200); }); after(async () => { - await Promise.all([deleteRoom({ type: 'p', roomId: room._id }), deleteUser(user)]); - }); + await request.delete(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`).set(credentials); - it('room creator remains in the room', async () => { - const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200); + await deleteRoom({ type: 'p', roomId: abacRoom._id }); - const memberIds = res.body.members.map((m: IUser) => m._id); - expect(memberIds).to.include(credentials['X-User-Id']); - }); + await request.post(`${v1}/roles.delete`).set(credentials).send({ roleId: roleAId }); + await request.post(`${v1}/roles.delete`).set(credentials).send({ roleId: roleBId }); - it('user remains in the room', async () => { - const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200); + await deleteUser(memberA); + await deleteUser(memberB); + await deleteUser(outsider); - const usernames = res.body.members.map((m: IUser) => m.username); - expect(usernames).to.include(user.username); + await updateSetting('ABAC_Use_User_Roles_As_Attributes', false); }); - it('user can access room history when PDP returns PERMIT', async () => { - await mockServerReset(); - await seedDefaultMocks(); - await seedGetDecisions('DECISION_PERMIT'); - - await request - .get('/api/v1/groups.history') - .set(userCreds) - .query({ roomId: room._id }) - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - expect(res.body).to.have.property('messages').that.is.an('array'); - }); - }); - }); + describe('Role add/remove sync', () => { + beforeEach(async () => { + await updateSetting('ABAC_Use_User_Roles_As_Attributes', true); + await request.post(`${v1}/roles.removeUserFromRole`).set(credentials).send({ roleId: roleAId, username: memberA.username }); + await request.post(`${v1}/roles.removeUserFromRole`).set(credentials).send({ roleId: roleBId, username: memberA.username }); + }); - describe('Access check: PDP DENY removes user on room access', () => { - let room: IRoom; - let user: IUser; - let userCreds: Credentials; + it('should sync RC-user-role attribute after adding a role to a user', async () => { + await request.post(`${v1}/roles.addUserToRole`).set(credentials).send({ roleId: roleAId, username: memberA.username }).expect(200); - before(async function () { - this.timeout(15000); + const userRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); + const roleAttr = (userRes.body.user.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY); + expect(roleAttr).to.exist; + expect(roleAttr!.values).to.include.members([roleAId, 'user']); + }); - user = await createUser(); - userCreds = await login(user.username, password); + it('should shrink RC-user-role values after removing a role while keeping the key', async () => { + await request.post(`${v1}/roles.addUserToRole`).set(credentials).send({ roleId: roleAId, username: memberA.username }).expect(200); + await request.post(`${v1}/roles.addUserToRole`).set(credentials).send({ roleId: roleBId, username: memberA.username }).expect(200); - room = (await createRoom({ type: 'p', name: `extpdp-access-${Date.now()}` })).body.group; + await request + .post(`${v1}/roles.removeUserFromRole`) + .set(credentials) + .send({ roleId: roleAId, username: memberA.username }) + .expect(200); - await request - .post('/api/v1/groups.invite') - .set(credentials) - .send({ roomId: room._id, usernames: [user.username] }) - .expect(200); + const userRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); + const roleAttr = (userRes.body.user.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY); + expect(roleAttr).to.exist; + expect(roleAttr!.values).to.include(roleBId); + expect(roleAttr!.values).to.not.include(roleAId); + }); - await mockServerReset(); - await seedDefaultMocks(); - await seedGetDecisionBulk([ - { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }, - { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }, - ]); + it('should not write RC-user-role when the feature is OFF', async () => { + await updateSetting('ABAC_Use_User_Roles_As_Attributes', false); - await request - .post(`/api/v1/abac/rooms/${room._id}/attributes/${attrKey}`) - .set(credentials) - .send({ values: ['alpha'] }) - .expect(200); - }); + await addAbacAttributesToUserDirectly(memberA._id, []); - after(async () => { - await Promise.all([deleteRoom({ type: 'p', roomId: room._id }), deleteUser(user)]); - }); + await request.post(`${v1}/roles.addUserToRole`).set(credentials).send({ roleId: roleAId, username: memberA.username }).expect(200); - it('user loses access when PDP flips to DENY', async () => { - await mockServerReset(); - await seedDefaultMocks(); - await seedGetDecisions('DECISION_DENY'); + const userRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); + const attrs = userRes.body.user.abacAttributes || []; + expect(attrs.find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY)).to.be.undefined; - await request - .get('/api/v1/groups.history') - .set(userCreds) - .query({ roomId: room._id }) - .expect(403) - .expect((res) => { - expect(res.body).to.have.property('success', false); - }); + await updateSetting('ABAC_Use_User_Roles_As_Attributes', true); + }); }); - it('user is removed from room after access DENY', async () => { - const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200); + describe('users.update / users.create sync', () => { + beforeEach(async () => { + await updateSetting('ABAC_Use_User_Roles_As_Attributes', true); + await request + .post(`${v1}/users.update`) + .set(credentials) + .send({ userId: memberA._id, data: { roles: ['user'] } }) + .expect(200); + }); - const usernames = res.body.members.map((m: IUser) => m.username); - expect(usernames).to.not.include(user.username); - }); - }); + it('should sync the attribute when users.update changes the roles array', async () => { + await request + .post(`${v1}/users.update`) + .set(credentials) + .send({ userId: memberA._id, data: { roles: ['user', roleAId] } }) + .expect(200); - describe('Invite to ABAC room: PDP decides who can join', () => { - let room: IRoom; - let permitUser: IUser; - let denyUser: IUser; + const userRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); + const roleAttr = (userRes.body.user.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY); + expect(roleAttr).to.exist; + expect(roleAttr!.values).to.have.members(['user', roleAId]); + }); - before(async function () { - this.timeout(10000); + it('should not re-sync when users.update receives the same roles (reordered)', async () => { + await request + .post(`${v1}/users.update`) + .set(credentials) + .send({ userId: memberA._id, data: { roles: ['user', roleAId] } }) + .expect(200); + const beforeRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); + const before = beforeRes.body.user.abacAttributes || []; - permitUser = await createUser(); - denyUser = await createUser(); + await request + .post(`${v1}/users.update`) + .set(credentials) + .send({ userId: memberA._id, data: { roles: [roleAId, 'user'] } }) + .expect(200); - room = (await createRoom({ type: 'p', name: `extpdp-invite-${Date.now()}` })).body.group; - await mockServerReset(); - await seedDefaultMocks(); - await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }]); - await request - .post(`/api/v1/abac/rooms/${room._id}/attributes/${attrKey}`) - .set(credentials) - .send({ values: ['alpha'] }) - .expect(200); - }); + const afterRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); + expect(afterRes.body.user.abacAttributes || []).to.deep.equal(before); + }); - after(async () => { - await Promise.all([deleteRoom({ type: 'p', roomId: room._id }), deleteUser(permitUser), deleteUser(denyUser)]); - }); + it('should not touch the attribute when users.update has no roles field', async () => { + await request + .post(`${v1}/users.update`) + .set(credentials) + .send({ userId: memberA._id, data: { roles: ['user', roleAId] } }) + .expect(200); + const beforeRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); + const before = beforeRes.body.user.abacAttributes || []; - it('should allow invite when PDP returns PERMIT', async () => { - await mockServerReset(); - await seedDefaultMocks(); - await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }]); + await request + .post(`${v1}/users.update`) + .set(credentials) + .send({ userId: memberA._id, data: { name: 'renamed-in-test' } }) + .expect(200); - await request - .post('/api/v1/groups.invite') - .set(credentials) - .send({ roomId: room._id, usernames: [permitUser.username] }) - .expect(200) - .expect((res) => { - expect(res.body).to.have.property('success', true); - }); - }); + const afterRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); + expect(afterRes.body.user.abacAttributes || []).to.deep.equal(before); + }); - it('invited user is a member of the room after PERMIT', async () => { - const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200); + it('should sync the attribute when users.create is called with explicit roles', async () => { + const username = `a34user_${Date.now()}`; + const createRes = await request + .post(`${v1}/users.create`) + .set(credentials) + .send({ + email: `${username}@test.com`, + name: username, + username, + password: 'Abc123!!', + roles: ['user', roleAId], + }) + .expect(200); + const createdId = createRes.body.user._id as string; - const usernames = res.body.members.map((m: IUser) => m.username); - expect(usernames).to.include(permitUser.username); - }); + try { + const userRes = await request.get(`${v1}/users.info`).set(credentials).query({ username }).expect(200); + const roleAttr = (userRes.body.user.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY); + expect(roleAttr).to.exist; + expect(roleAttr!.values).to.have.members(['user', roleAId]); + } finally { + await deleteUser({ _id: createdId }); + } + }); - it('should reject invite when PDP returns DENY', async () => { - await mockServerReset(); - await seedDefaultMocks(); - await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_DENY', ephemeralResourceId: room._id }] }]); + it('should reflect the default role when users.create is called without roles', async () => { + const username = `a35user_${Date.now()}`; + const createRes = await request + .post(`${v1}/users.create`) + .set(credentials) + .send({ + email: `${username}@test.com`, + name: username, + username, + password: 'Abc123!!', + }) + .expect(200); + const createdId = createRes.body.user._id as string; - await request - .post('/api/v1/groups.invite') - .set(credentials) - .send({ roomId: room._id, usernames: [denyUser.username] }) - .expect(400) - .expect((res) => { - expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('errorType', 'error-only-compliant-users-can-be-added-to-abac-rooms'); - }); + try { + const userRes = await request.get(`${v1}/users.info`).set(credentials).query({ username }).expect(200); + const roleAttr = (userRes.body.user.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY); + expect(roleAttr).to.exist; + expect(roleAttr!.values).to.include('user'); + } finally { + await deleteUser({ _id: createdId }); + } + }); }); - it('denied user is not a member of the room', async () => { - const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200); + describe('Room attribute ops involving RC-user-role', () => { + beforeEach(async () => { + await updateSetting('ABAC_Use_User_Roles_As_Attributes', true); + await request + .post(`${v1}/users.update`) + .set(credentials) + .send({ userId: memberA._id, data: { roles: ['user', roleAId] } }) + .expect(200); + await request + .post(`${v1}/users.update`) + .set(credentials) + .send({ userId: memberB._id, data: { roles: ['user', roleAId] } }) + .expect(200); + await request + .post(`${v1}/users.update`) + .set(credentials) + .send({ userId: outsider._id, data: { roles: ['user'] } }) + .expect(200); - const usernames = res.body.members.map((m: IUser) => m.username); - expect(usernames).to.not.include(denyUser.username); - }); + await request.delete(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`).set(credentials); + }); - it('room creator remains after invite operations', async () => { - const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200); + afterEach(async () => { + await request.delete(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`).set(credentials); + }); - const memberIds = res.body.members.map((m: IUser) => m._id); - expect(memberIds).to.include(credentials['X-User-Id']); - }); - }); + it("should sync members' attributes to mirror their own roles when RC-user-role is added to the room", async () => { + await addAbacAttributesToUserDirectly(outsider._id, []); - describe('PDP unavailability: fail-closed behavior', () => { - let room: IRoom; - let user: IUser; - let userCredentials: Credentials; + await request + .post(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`) + .set(credentials) + .send({ values: [roleAId] }) + .expect(200); - before(async function () { - this.timeout(10000); + const aRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); + const bRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberB.username }).expect(200); + const outRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: outsider.username }).expect(200); - user = await createUser(); - userCredentials = await login(user.username, password); + const aRoleAttr = (aRes.body.user.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY); + const bRoleAttr = (bRes.body.user.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY); + const outRoleAttr = (outRes.body.user.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY); - room = (await createRoom({ type: 'p', name: `extpdp-failclose-${Date.now()}` })).body.group; - await request - .post('/api/v1/groups.invite') - .set(credentials) - .send({ roomId: room._id, usernames: [user.username] }) - .expect(200); + expect(aRoleAttr).to.exist; + expect(aRoleAttr!.values).to.have.members(['user', roleAId]); + expect(bRoleAttr).to.exist; + expect(bRoleAttr!.values).to.have.members(['user', roleAId]); + expect(outRoleAttr).to.be.undefined; + }); - await mockServerReset(); - await seedDefaultMocks(); - await seedGetDecisionBulk([ - { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }, - { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }, - ]); - await request - .post(`/api/v1/abac/rooms/${room._id}/attributes/${attrKey}`) - .set(credentials) - .send({ values: ['alpha'] }) - .expect(200); - }); + it("should not narrow members' attributes when the room's RC-user-role values change", async () => { + await request + .post(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`) + .set(credentials) + .send({ values: [roleAId] }) + .expect(200); - after(async () => { - await mockServerReset(); - await seedDefaultMocks(); - await Promise.all([deleteRoom({ type: 'p', roomId: room._id }), deleteUser(user)]); + await request + .put(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`) + .set(credentials) + .send({ values: [roleBId] }) + .expect(200); + + const aRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); + const aRoleAttr = (aRes.body.user.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY); + expect(aRoleAttr!.values).to.have.members(['user', roleAId]); + }); + + it('should sync members when RC-user-role is set via bulk attribute replace', async () => { + await request + .post(`${v1}/abac/rooms/${abacRoom._id}/attributes`) + .set(credentials) + .send({ attributes: { [ROLE_ATTR_KEY]: [roleAId] } }) + .expect(200); + + const aRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); + const aRoleAttr = (aRes.body.user.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY); + expect(aRoleAttr).to.exist; + expect(aRoleAttr!.values).to.have.members(['user', roleAId]); + }); + + it('should leave the user-side attribute intact when RC-user-role is deleted from the room', async () => { + await request + .post(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`) + .set(credentials) + .send({ values: [roleAId] }) + .expect(200); + + await request.delete(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`).set(credentials).expect(200); + + const roomRes = await request.get(`${v1}/rooms.info`).set(credentials).query({ roomId: abacRoom._id }).expect(200); + expect((roomRes.body.room.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY)).to.be.undefined; + + const aRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); + const aRoleAttr = (aRes.body.user.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY); + expect(aRoleAttr).to.exist; + expect(aRoleAttr!.values).to.have.members(['user', roleAId]); + }); + + it('should never touch a non-member when any RC-user-role room op runs', async () => { + await addAbacAttributesToUserDirectly(outsider._id, []); + + await request + .post(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`) + .set(credentials) + .send({ values: [roleAId] }) + .expect(200); + const after1Res = await request.get(`${v1}/users.info`).set(credentials).query({ username: outsider.username }).expect(200); + expect((after1Res.body.user.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY)).to.be.undefined; + + await request + .put(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`) + .set(credentials) + .send({ values: [roleBId] }) + .expect(200); + const after2Res = await request.get(`${v1}/users.info`).set(credentials).query({ username: outsider.username }).expect(200); + expect((after2Res.body.user.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY)).to.be.undefined; + + await request.delete(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`).set(credentials).expect(200); + const after3Res = await request.get(`${v1}/users.info`).set(credentials).query({ username: outsider.username }).expect(200); + expect((after3Res.body.user.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY)).to.be.undefined; + }); + + it('should reject adding RC-user-role to a room when the feature is OFF', async () => { + await updateSetting('ABAC_Use_User_Roles_As_Attributes', false); + + const res = await request + .post(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`) + .set(credentials) + .send({ values: [roleAId] }) + .expect(400); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error').that.includes('error-attribute-definition-not-found'); + + await updateSetting('ABAC_Use_User_Roles_As_Attributes', true); + }); }); - it('should deny access when PDP health check returns NOT_SERVING', async () => { - await mockServerReset(); - await mockServerSet('GET', '/healthz', { status: 'NOT_SERVING' }); + describe('roles.delete guard', () => { + beforeEach(async () => { + await updateSetting('ABAC_Use_User_Roles_As_Attributes', true); + await request.delete(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`).set(credentials); + }); - await request - .get('/api/v1/groups.history') - .set(userCredentials) - .query({ roomId: room._id }) - .expect(403) - .expect((res) => { - expect(res.body).to.have.property('success', false); - }); + afterEach(async () => { + await request.delete(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`).set(credentials); + }); + + it('should allow deleting a role not referenced by any ABAC room', async () => { + const createRes = await request + .post(`${v1}/roles.create`) + .set(credentials) + .send({ name: `a51_${Date.now()}` }) + .expect(200); + const roleId = createRes.body.role._id; + + await request.post(`${v1}/roles.delete`).set(credentials).send({ roleId }).expect(200); + }); + + it('should block deleting a role referenced by an ABAC room via RC-user-role', async () => { + const createRes = await request + .post(`${v1}/roles.create`) + .set(credentials) + .send({ name: `a52_${Date.now()}` }) + .expect(200); + const roleId = createRes.body.role._id; + + await request + .post(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`) + .set(credentials) + .send({ values: [roleId] }) + .expect(200); + + const res = await request.post(`${v1}/roles.delete`).set(credentials).send({ roleId }).expect(400); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error').that.includes('error-abac-role-in-use-by-room'); + + await request.delete(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`).set(credentials); + await request.post(`${v1}/roles.delete`).set(credentials).send({ roleId }).expect(200); + }); + + it('should allow the same role deletion when the feature is OFF (guard inactive)', async () => { + const createRes = await request + .post(`${v1}/roles.create`) + .set(credentials) + .send({ name: `a53_${Date.now()}` }) + .expect(200); + const roleId = createRes.body.role._id; + + await request + .post(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`) + .set(credentials) + .send({ values: [roleId] }) + .expect(200); + + await request.delete(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`).set(credentials); + await updateSetting('ABAC_Use_User_Roles_As_Attributes', false); + await request.post(`${v1}/roles.delete`).set(credentials).send({ roleId }).expect(200); + + await updateSetting('ABAC_Use_User_Roles_As_Attributes', true); + }); }); - it('should deny invite when PDP is unavailable', async () => { - await mockServerReset(); - await mockServerSet('GET', '/healthz', { status: 'NOT_SERVING' }); + describe('Setting-disable guard', () => { + beforeEach(async () => { + await updateSetting('ABAC_Use_User_Roles_As_Attributes', true); + await request.delete(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`).set(credentials); + }); - const newUser = await createUser(); + afterEach(async () => { + await request.delete(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`).set(credentials); + await updateSetting('ABAC_Use_User_Roles_As_Attributes', true); + }); - await request - .post('/api/v1/groups.invite') - .set(credentials) - .send({ roomId: room._id, usernames: [newUser.username!] }) - .expect(400) - .expect((res) => { - expect(res.body).to.have.property('success', false); - }); + it('should allow disabling the setting when no room uses RC-user-role', async () => { + const res = await request + .post(`${v1}/settings/ABAC_Use_User_Roles_As_Attributes`) + .set(credentials) + .send({ value: false }) + .expect(200); + expect(res.body).to.have.property('success', true); + }); - await deleteUser(newUser); + it('should reject disabling while a room uses RC-user-role and keep the setting true', async () => { + await request + .post(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`) + .set(credentials) + .send({ values: [roleAId] }) + .expect(200); + + const res = await request + .post(`${v1}/settings/ABAC_Use_User_Roles_As_Attributes`) + .set(credentials) + .send({ value: false }) + .expect(400); + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error').that.includes('error-abac-role-attribute-in-use'); + + const check = await request.get(`${v1}/settings/ABAC_Use_User_Roles_As_Attributes`).set(credentials).expect(200); + expect(check.body).to.have.property('value', true); + }); + + it('should allow disabling after clearing RC-user-role from all rooms', async () => { + await request + .post(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`) + .set(credentials) + .send({ values: [roleAId] }) + .expect(200); + + await request.delete(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`).set(credentials); + + const res = await request + .post(`${v1}/settings/ABAC_Use_User_Roles_As_Attributes`) + .set(credentials) + .send({ value: false }) + .expect(200); + expect(res.body).to.have.property('success', true); + }); }); + }); +}); + +(IS_EE ? describe : describe.skip)('[ABAC] External PDP (mock-server)', function () { + this.retries(0); + + const attrKey = `ext_pdp_attr_${Date.now()}`; + + before((done) => { + getCredentials(done); + }); + + before(async function () { + this.timeout(15000); + + const healthy = await mockServerHealthy(); + expect(healthy, 'mock-server is not reachable — ensure it is running').to.be.true; + + await updatePermission('abac-management', ['admin']); + await updateSetting('ABAC_Enabled', true); + await updateSetting('ABAC_PDP_Type', 'virtru'); + await Promise.all([ + updateSetting('ABAC_Virtru_Base_URL', 'http://mock-server:8080'), + updateSetting('ABAC_Virtru_OIDC_Endpoint', 'http://mock-server:8080/auth/realms/mock'), + updateSetting('ABAC_Virtru_Client_ID', 'mock-client'), + updateSetting('ABAC_Virtru_Client_Secret', 'mock-secret'), + updateSetting('ABAC_Virtru_Default_Entity_Key', 'emailAddress'), + updateSetting('ABAC_Virtru_Attribute_Namespace', 'example.com'), + updateSetting('Abac_Cache_Decision_Time_Seconds', 0), + ]); + + await request + .post('/api/v1/abac/attributes') + .set(credentials) + .send({ key: attrKey, values: ['alpha', 'beta', 'gamma'] }) + .expect(200); + }); - it('should deny room attribute changes when PDP is unavailable', async () => { - await mockServerReset(); - await mockServerSet('GET', '/healthz', { status: 'NOT_SERVING' }); + after(async function () { + this.timeout(10000); - await request - .post(`/api/v1/abac/rooms/${room._id}/attributes/${attrKey}`) - .set(credentials) - .send({ values: ['beta'] }) - .expect(400) - .expect((res) => { - expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('error', 'error-pdp-unavailable'); - }); - }); + await mockServerReset(); + await updateSetting('ABAC_PDP_Type', 'local'); + await updateSetting('ABAC_Enabled', false); }); - describe('Selective DENY: only non-permitted users are removed', () => { + describe('PERMIT all: users remain when PDP permits everyone', () => { let room: IRoom; let user: IUser; + let userCreds: Credentials; before(async function () { this.timeout(15000); user = await createUser(); - room = (await createRoom({ type: 'p', name: `extpdp-selective-${Date.now()}` })).body.group; + userCreds = await login(user.username, password); + + room = (await createRoom({ type: 'p', name: `extpdp-permit-${Date.now()}` })).body.group; + await request .post('/api/v1/groups.invite') .set(credentials) @@ -2922,7 +3108,10 @@ const addAbacAttributesToUserDirectly = async (userId: string, abacAttributes: I await mockServerReset(); await seedDefaultMocks(); - await seedBulkDecisionByEntity([adminEmail], 'DECISION_DENY'); + await seedGetDecisionBulk([ + { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }, + { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }, + ]); await request .post(`/api/v1/abac/rooms/${room._id}/attributes/${attrKey}`) @@ -2935,28 +3124,50 @@ const addAbacAttributesToUserDirectly = async (userId: string, abacAttributes: I await Promise.all([deleteRoom({ type: 'p', roomId: room._id }), deleteUser(user)]); }); - it('admin (permitted) remains in the room', async () => { + it('room creator remains in the room', async () => { const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200); + const memberIds = res.body.members.map((m: IUser) => m._id); expect(memberIds).to.include(credentials['X-User-Id']); }); - it('user (denied) was removed from the room', async () => { + it('user remains in the room', async () => { const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200); + const usernames = res.body.members.map((m: IUser) => m.username); - expect(usernames).to.not.include(user.username); + expect(usernames).to.include(user.username); + }); + + it('user can access room history when PDP returns PERMIT', async () => { + await mockServerReset(); + await seedDefaultMocks(); + await seedGetDecisions('DECISION_PERMIT'); + + await request + .get('/api/v1/groups.history') + .set(userCreds) + .query({ roomId: room._id }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + expect(res.body).to.have.property('messages').that.is.an('array'); + }); }); }); - describe('Tightening attributes: selective DENY removes only denied users', () => { + describe('Access check: PDP DENY removes user on room access', () => { let room: IRoom; let user: IUser; + let userCreds: Credentials; before(async function () { this.timeout(15000); user = await createUser(); - room = (await createRoom({ type: 'p', name: `extpdp-tighten-${Date.now()}` })).body.group; + userCreds = await login(user.username, password); + + room = (await createRoom({ type: 'p', name: `extpdp-access-${Date.now()}` })).body.group; + await request .post('/api/v1/groups.invite') .set(credentials) @@ -2969,6 +3180,7 @@ const addAbacAttributesToUserDirectly = async (userId: string, abacAttributes: I { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }, { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }, ]); + await request .post(`/api/v1/abac/rooms/${room._id}/attributes/${attrKey}`) .set(credentials) @@ -2976,531 +3188,294 @@ const addAbacAttributesToUserDirectly = async (userId: string, abacAttributes: I .expect(200); }); - after(async () => { - await Promise.all([deleteRoom({ type: 'p', roomId: room._id }), deleteUser(user)]); - }); - - it('user is removed when attributes are tightened and PDP denies them', async function () { - this.timeout(10000); - - await mockServerReset(); - await seedDefaultMocks(); - await seedBulkDecisionByEntity([adminEmail], 'DECISION_DENY'); - - await request - .put(`/api/v1/abac/rooms/${room._id}/attributes/${attrKey}`) - .set(credentials) - .send({ values: ['alpha', 'beta'] }) - .expect(200); - - const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200); - const usernames = res.body.members.map((m: IUser) => m.username); - expect(usernames).to.not.include(user.username); - }); - - it('admin remains in the room after tightening', async () => { - const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200); - const memberIds = res.body.members.map((m: IUser) => m._id); - expect(memberIds).to.include(credentials['X-User-Id']); - }); - }); - - describe('RC-user-role attribute feature', () => { - const v1 = '/api/v1'; - const ROLE_ATTR_KEY = 'RC-user-role'; - - let memberA: IUser; - let memberB: IUser; - let outsider: IUser; - let abacRoom: IRoom; - - let roleAId = ''; - let roleBId = ''; - - before(async () => { - await updateSetting('ABAC_Enabled', true); - await updateSetting('ABAC_PDP_Type', 'local'); - await updateSetting('ABAC_Use_User_Roles_As_Attributes', true); - - const createRoleRes1 = await request - .post(`${v1}/roles.create`) - .set(credentials) - .send({ name: `rc_user_role_A_${Date.now()}` }) - .expect(200); - roleAId = createRoleRes1.body.role._id; - - const createRoleRes2 = await request - .post(`${v1}/roles.create`) - .set(credentials) - .send({ name: `rc_user_role_B_${Date.now()}` }) - .expect(200); - roleBId = createRoleRes2.body.role._id; - - memberA = await createUser(); - memberB = await createUser(); - outsider = await createUser(); - - abacRoom = (await createRoom({ type: 'p', name: `abac-rc-role-${Date.now()}` })).body.group; - await request.post(`${v1}/groups.invite`).set(credentials).send({ roomId: abacRoom._id, userId: memberA._id }).expect(200); - await request.post(`${v1}/groups.invite`).set(credentials).send({ roomId: abacRoom._id, userId: memberB._id }).expect(200); - }); - - after(async () => { - await request.delete(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`).set(credentials); - - await deleteRoom({ type: 'p', roomId: abacRoom._id }); - - await request.post(`${v1}/roles.delete`).set(credentials).send({ roleId: roleAId }); - await request.post(`${v1}/roles.delete`).set(credentials).send({ roleId: roleBId }); - - await deleteUser(memberA); - await deleteUser(memberB); - await deleteUser(outsider); - - await updateSetting('ABAC_Use_User_Roles_As_Attributes', false); - }); - - describe('Role add/remove sync', () => { - beforeEach(async () => { - await updateSetting('ABAC_Use_User_Roles_As_Attributes', true); - await request.post(`${v1}/roles.removeUserFromRole`).set(credentials).send({ roleId: roleAId, username: memberA.username }); - await request.post(`${v1}/roles.removeUserFromRole`).set(credentials).send({ roleId: roleBId, username: memberA.username }); - }); - - it('should sync RC-user-role attribute after adding a role to a user', async () => { - await request.post(`${v1}/roles.addUserToRole`).set(credentials).send({ roleId: roleAId, username: memberA.username }).expect(200); - - const userRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); - const roleAttr = (userRes.body.user.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY); - expect(roleAttr).to.exist; - expect(roleAttr!.values).to.include.members([roleAId, 'user']); - }); - - it('should shrink RC-user-role values after removing a role while keeping the key', async () => { - await request.post(`${v1}/roles.addUserToRole`).set(credentials).send({ roleId: roleAId, username: memberA.username }).expect(200); - await request.post(`${v1}/roles.addUserToRole`).set(credentials).send({ roleId: roleBId, username: memberA.username }).expect(200); - - await request - .post(`${v1}/roles.removeUserFromRole`) - .set(credentials) - .send({ roleId: roleAId, username: memberA.username }) - .expect(200); - - const userRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); - const roleAttr = (userRes.body.user.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY); - expect(roleAttr).to.exist; - expect(roleAttr!.values).to.include(roleBId); - expect(roleAttr!.values).to.not.include(roleAId); - }); - - it('should be a no-op when adding a role the user already has', async () => { - await request.post(`${v1}/roles.addUserToRole`).set(credentials).send({ roleId: roleAId, username: memberA.username }).expect(200); - const beforeRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); - const before = beforeRes.body.user.abacAttributes || []; - - await request.post(`${v1}/roles.addUserToRole`).set(credentials).send({ roleId: roleAId, username: memberA.username }).expect(200); - - const afterRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); - expect(afterRes.body.user.abacAttributes || []).to.deep.equal(before); - }); - - it('should be a no-op when removing a role the user does not have', async () => { - await request.post(`${v1}/roles.addUserToRole`).set(credentials).send({ roleId: roleAId, username: memberA.username }).expect(200); - const beforeRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); - const before = beforeRes.body.user.abacAttributes || []; - - await request - .post(`${v1}/roles.removeUserFromRole`) - .set(credentials) - .send({ roleId: roleBId, username: memberA.username }) - .expect(200); - - const afterRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); - expect(afterRes.body.user.abacAttributes || []).to.deep.equal(before); - }); - - it('should not write RC-user-role when the feature is OFF', async () => { - await updateSetting('ABAC_Use_User_Roles_As_Attributes', false); - - await addAbacAttributesToUserDirectly(memberA._id, []); - - await request.post(`${v1}/roles.addUserToRole`).set(credentials).send({ roleId: roleAId, username: memberA.username }).expect(200); - - const userRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); - const attrs = userRes.body.user.abacAttributes || []; - expect(attrs.find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY)).to.be.undefined; - - await updateSetting('ABAC_Use_User_Roles_As_Attributes', true); - }); - }); - - describe('users.update / users.create sync', () => { - beforeEach(async () => { - await updateSetting('ABAC_Use_User_Roles_As_Attributes', true); - await request - .post(`${v1}/users.update`) - .set(credentials) - .send({ userId: memberA._id, data: { roles: ['user'] } }) - .expect(200); - }); - - it('should sync the attribute when users.update changes the roles array', async () => { - await request - .post(`${v1}/users.update`) - .set(credentials) - .send({ userId: memberA._id, data: { roles: ['user', roleAId] } }) - .expect(200); - - const userRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); - const roleAttr = (userRes.body.user.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY); - expect(roleAttr).to.exist; - expect(roleAttr!.values).to.have.members(['user', roleAId]); - }); - - it('should not re-sync when users.update receives the same roles (reordered)', async () => { - await request - .post(`${v1}/users.update`) - .set(credentials) - .send({ userId: memberA._id, data: { roles: ['user', roleAId] } }) - .expect(200); - const beforeRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); - const before = beforeRes.body.user.abacAttributes || []; - - await request - .post(`${v1}/users.update`) - .set(credentials) - .send({ userId: memberA._id, data: { roles: [roleAId, 'user'] } }) - .expect(200); + after(async () => { + await Promise.all([deleteRoom({ type: 'p', roomId: room._id }), deleteUser(user)]); + }); - const afterRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); - expect(afterRes.body.user.abacAttributes || []).to.deep.equal(before); - }); + it('user loses access when PDP flips to DENY', async () => { + await mockServerReset(); + await seedDefaultMocks(); + await seedGetDecisions('DECISION_DENY'); - it('should not touch the attribute when users.update has no roles field', async () => { - await request - .post(`${v1}/users.update`) - .set(credentials) - .send({ userId: memberA._id, data: { roles: ['user', roleAId] } }) - .expect(200); - const beforeRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); - const before = beforeRes.body.user.abacAttributes || []; + await request + .get('/api/v1/groups.history') + .set(userCreds) + .query({ roomId: room._id }) + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); - await request - .post(`${v1}/users.update`) - .set(credentials) - .send({ userId: memberA._id, data: { name: 'renamed-in-test' } }) - .expect(200); + it('user is removed from room after access DENY', async () => { + const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200); - const afterRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); - expect(afterRes.body.user.abacAttributes || []).to.deep.equal(before); - }); + const usernames = res.body.members.map((m: IUser) => m.username); + expect(usernames).to.not.include(user.username); + }); + }); - it('should sync the attribute when users.create is called with explicit roles', async () => { - const username = `a34user_${Date.now()}`; - const createRes = await request - .post(`${v1}/users.create`) - .set(credentials) - .send({ - email: `${username}@test.com`, - name: username, - username, - password: 'Abc123!!', - roles: ['user', roleAId], - }) - .expect(200); - const createdId = createRes.body.user._id as string; + describe('Invite to ABAC room: PDP decides who can join', () => { + let room: IRoom; + let permitUser: IUser; + let denyUser: IUser; - try { - const userRes = await request.get(`${v1}/users.info`).set(credentials).query({ username }).expect(200); - const roleAttr = (userRes.body.user.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY); - expect(roleAttr).to.exist; - expect(roleAttr!.values).to.have.members(['user', roleAId]); - } finally { - await deleteUser({ _id: createdId }); - } - }); + before(async function () { + this.timeout(10000); - it('should reflect the default role when users.create is called without roles', async () => { - const username = `a35user_${Date.now()}`; - const createRes = await request - .post(`${v1}/users.create`) - .set(credentials) - .send({ - email: `${username}@test.com`, - name: username, - username, - password: 'Abc123!!', - }) - .expect(200); - const createdId = createRes.body.user._id as string; + permitUser = await createUser(); + denyUser = await createUser(); - try { - const userRes = await request.get(`${v1}/users.info`).set(credentials).query({ username }).expect(200); - const roleAttr = (userRes.body.user.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY); - expect(roleAttr).to.exist; - expect(roleAttr!.values).to.include('user'); - } finally { - await deleteUser({ _id: createdId }); - } - }); + room = (await createRoom({ type: 'p', name: `extpdp-invite-${Date.now()}` })).body.group; + await mockServerReset(); + await seedDefaultMocks(); + await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }]); + await request + .post(`/api/v1/abac/rooms/${room._id}/attributes/${attrKey}`) + .set(credentials) + .send({ values: ['alpha'] }) + .expect(200); }); - describe('Room attribute ops involving RC-user-role', () => { - beforeEach(async () => { - await updateSetting('ABAC_Use_User_Roles_As_Attributes', true); - await request - .post(`${v1}/users.update`) - .set(credentials) - .send({ userId: memberA._id, data: { roles: ['user', roleAId] } }) - .expect(200); - await request - .post(`${v1}/users.update`) - .set(credentials) - .send({ userId: memberB._id, data: { roles: ['user', roleAId] } }) - .expect(200); - await request - .post(`${v1}/users.update`) - .set(credentials) - .send({ userId: outsider._id, data: { roles: ['user'] } }) - .expect(200); + after(async () => { + await Promise.all([deleteRoom({ type: 'p', roomId: room._id }), deleteUser(permitUser), deleteUser(denyUser)]); + }); - await request.delete(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`).set(credentials); - }); + it('should allow invite when PDP returns PERMIT', async () => { + await mockServerReset(); + await seedDefaultMocks(); + await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }]); - afterEach(async () => { - await request.delete(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`).set(credentials); - }); + await request + .post('/api/v1/groups.invite') + .set(credentials) + .send({ roomId: room._id, usernames: [permitUser.username] }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + }); - it("should sync members' attributes to mirror their own roles when RC-user-role is added to the room", async () => { - await request - .post(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`) - .set(credentials) - .send({ values: [roleAId] }) - .expect(200); + it('invited user is a member of the room after PERMIT', async () => { + const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200); - const aRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); - const bRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberB.username }).expect(200); - const outRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: outsider.username }).expect(200); + const usernames = res.body.members.map((m: IUser) => m.username); + expect(usernames).to.include(permitUser.username); + }); - const aRoleAttr = (aRes.body.user.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY); - const bRoleAttr = (bRes.body.user.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY); - const outRoleAttr = (outRes.body.user.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY); + it('should reject invite when PDP returns DENY', async () => { + await mockServerReset(); + await seedDefaultMocks(); + await seedGetDecisionBulk([{ resourceDecisions: [{ decision: 'DECISION_DENY', ephemeralResourceId: room._id }] }]); - expect(aRoleAttr).to.exist; - expect(aRoleAttr!.values).to.have.members(['user', roleAId]); - expect(bRoleAttr).to.exist; - expect(bRoleAttr!.values).to.have.members(['user', roleAId]); - expect(outRoleAttr).to.be.undefined; - }); + await request + .post('/api/v1/groups.invite') + .set(credentials) + .send({ roomId: room._id, usernames: [denyUser.username] }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('errorType', 'error-only-compliant-users-can-be-added-to-abac-rooms'); + }); + }); - it("should not narrow members' attributes when the room's RC-user-role values change", async () => { - await request - .post(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`) - .set(credentials) - .send({ values: [roleAId] }) - .expect(200); + it('denied user is not a member of the room', async () => { + const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200); - await request - .post(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`) - .set(credentials) - .send({ values: [roleBId] }) - .expect(200); + const usernames = res.body.members.map((m: IUser) => m.username); + expect(usernames).to.not.include(denyUser.username); + }); - const aRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); - const aRoleAttr = (aRes.body.user.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY); - expect(aRoleAttr!.values).to.have.members(['user', roleAId]); - }); + it('room creator remains after invite operations', async () => { + const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200); - it('should sync members when RC-user-role is set via bulk attribute replace', async () => { - await request - .post(`${v1}/abac/rooms/${abacRoom._id}/attributes`) - .set(credentials) - .send({ attributes: { [ROLE_ATTR_KEY]: [roleAId] } }) - .expect(200); + const memberIds = res.body.members.map((m: IUser) => m._id); + expect(memberIds).to.include(credentials['X-User-Id']); + }); + }); - const aRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); - const aRoleAttr = (aRes.body.user.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY); - expect(aRoleAttr).to.exist; - expect(aRoleAttr!.values).to.have.members(['user', roleAId]); - }); + describe('PDP unavailability: fail-closed behavior', () => { + let room: IRoom; + let user: IUser; + let userCredentials: Credentials; - it('should leave the user-side attribute intact when RC-user-role is deleted from the room', async () => { - await request - .post(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`) - .set(credentials) - .send({ values: [roleAId] }) - .expect(200); + before(async function () { + this.timeout(10000); - await request.delete(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`).set(credentials).expect(200); + user = await createUser(); + userCredentials = await login(user.username, password); - const roomRes = await request.get(`${v1}/rooms.info`).set(credentials).query({ roomId: abacRoom._id }).expect(200); - expect((roomRes.body.room.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY)).to.be.undefined; + room = (await createRoom({ type: 'p', name: `extpdp-failclose-${Date.now()}` })).body.group; + await request + .post('/api/v1/groups.invite') + .set(credentials) + .send({ roomId: room._id, usernames: [user.username] }) + .expect(200); - const aRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); - const aRoleAttr = (aRes.body.user.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY); - expect(aRoleAttr).to.exist; - expect(aRoleAttr!.values).to.have.members(['user', roleAId]); - }); + await mockServerReset(); + await seedDefaultMocks(); + await seedGetDecisionBulk([ + { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }, + { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }, + ]); + await request + .post(`/api/v1/abac/rooms/${room._id}/attributes/${attrKey}`) + .set(credentials) + .send({ values: ['alpha'] }) + .expect(200); + }); - it('should never touch a non-member when any RC-user-role room op runs', async () => { - await addAbacAttributesToUserDirectly(outsider._id, []); + after(async () => { + await mockServerReset(); + await seedDefaultMocks(); + await Promise.all([deleteRoom({ type: 'p', roomId: room._id }), deleteUser(user)]); + }); - await request - .post(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`) - .set(credentials) - .send({ values: [roleAId] }) - .expect(200); - const after1Res = await request.get(`${v1}/users.info`).set(credentials).query({ username: outsider.username }).expect(200); - expect((after1Res.body.user.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY)).to.be.undefined; + it('should deny access when PDP health check returns NOT_SERVING', async () => { + await mockServerReset(); + await mockServerSet('GET', '/healthz', { status: 'NOT_SERVING' }); - await request - .post(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`) - .set(credentials) - .send({ values: [roleBId] }) - .expect(200); - const after2Res = await request.get(`${v1}/users.info`).set(credentials).query({ username: outsider.username }).expect(200); - expect((after2Res.body.user.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY)).to.be.undefined; + await request + .get('/api/v1/groups.history') + .set(userCredentials) + .query({ roomId: room._id }) + .expect(403) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); + }); - await request.delete(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`).set(credentials).expect(200); - const after3Res = await request.get(`${v1}/users.info`).set(credentials).query({ username: outsider.username }).expect(200); - expect((after3Res.body.user.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY)).to.be.undefined; - }); + it('should deny invite when PDP is unavailable', async () => { + await mockServerReset(); + await mockServerSet('GET', '/healthz', { status: 'NOT_SERVING' }); - it('should reject adding RC-user-role to a room when the feature is OFF', async () => { - await updateSetting('ABAC_Use_User_Roles_As_Attributes', false); + const newUser = await createUser(); - const res = await request - .post(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`) - .set(credentials) - .send({ values: [roleAId] }) - .expect(400); - expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('error').that.includes('error-attribute-definition-not-found'); + await request + .post('/api/v1/groups.invite') + .set(credentials) + .send({ roomId: room._id, usernames: [newUser.username!] }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + }); - await updateSetting('ABAC_Use_User_Roles_As_Attributes', true); - }); + await deleteUser(newUser); }); - describe('roles.delete guard', () => { - beforeEach(async () => { - await updateSetting('ABAC_Use_User_Roles_As_Attributes', true); - await request.delete(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`).set(credentials); - }); - - afterEach(async () => { - await request.delete(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`).set(credentials); - }); - - it('should allow deleting a role not referenced by any ABAC room', async () => { - const createRes = await request - .post(`${v1}/roles.create`) - .set(credentials) - .send({ name: `a51_${Date.now()}` }) - .expect(200); - const roleId = createRes.body.role._id; + it('should deny room attribute changes when PDP is unavailable', async () => { + await mockServerReset(); + await mockServerSet('GET', '/healthz', { status: 'NOT_SERVING' }); - await request.post(`${v1}/roles.delete`).set(credentials).send({ roleId }).expect(200); - }); + await request + .post(`/api/v1/abac/rooms/${room._id}/attributes/${attrKey}`) + .set(credentials) + .send({ values: ['beta'] }) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property('error', 'error-pdp-unavailable'); + }); + }); + }); - it('should block deleting a role referenced by an ABAC room via RC-user-role', async () => { - const createRes = await request - .post(`${v1}/roles.create`) - .set(credentials) - .send({ name: `a52_${Date.now()}` }) - .expect(200); - const roleId = createRes.body.role._id; + describe('Selective DENY: only non-permitted users are removed', () => { + let room: IRoom; + let user: IUser; - await request - .post(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`) - .set(credentials) - .send({ values: [roleId] }) - .expect(200); + before(async function () { + this.timeout(15000); - const res = await request.post(`${v1}/roles.delete`).set(credentials).send({ roleId }).expect(400); - expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('error').that.includes('error-abac-role-in-use-by-room'); + user = await createUser(); + room = (await createRoom({ type: 'p', name: `extpdp-selective-${Date.now()}` })).body.group; + await request + .post('/api/v1/groups.invite') + .set(credentials) + .send({ roomId: room._id, usernames: [user.username] }) + .expect(200); - await request.delete(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`).set(credentials); - await request.post(`${v1}/roles.delete`).set(credentials).send({ roleId }).expect(200); - }); + await mockServerReset(); + await seedDefaultMocks(); + await seedBulkDecisionByEntity([adminEmail], 'DECISION_DENY'); - it('should allow the same role deletion when the feature is OFF (guard inactive)', async () => { - const createRes = await request - .post(`${v1}/roles.create`) - .set(credentials) - .send({ name: `a53_${Date.now()}` }) - .expect(200); - const roleId = createRes.body.role._id; + await request + .post(`/api/v1/abac/rooms/${room._id}/attributes/${attrKey}`) + .set(credentials) + .send({ values: ['alpha'] }) + .expect(200); + }); - await request - .post(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`) - .set(credentials) - .send({ values: [roleId] }) - .expect(200); + after(async () => { + await Promise.all([deleteRoom({ type: 'p', roomId: room._id }), deleteUser(user)]); + }); - await request.delete(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`).set(credentials); - await updateSetting('ABAC_Use_User_Roles_As_Attributes', false); - await request.post(`${v1}/roles.delete`).set(credentials).send({ roleId }).expect(200); + it('admin (permitted) remains in the room', async () => { + const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200); + const memberIds = res.body.members.map((m: IUser) => m._id); + expect(memberIds).to.include(credentials['X-User-Id']); + }); - await updateSetting('ABAC_Use_User_Roles_As_Attributes', true); - }); + it('user (denied) was removed from the room', async () => { + const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200); + const usernames = res.body.members.map((m: IUser) => m.username); + expect(usernames).to.not.include(user.username); }); + }); - describe('Setting-disable guard', () => { - beforeEach(async () => { - await updateSetting('ABAC_Use_User_Roles_As_Attributes', true); - await request.delete(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`).set(credentials); - }); + describe('Tightening attributes: selective DENY removes only denied users', () => { + let room: IRoom; + let user: IUser; - afterEach(async () => { - await request.delete(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`).set(credentials); - await updateSetting('ABAC_Use_User_Roles_As_Attributes', true); - }); + before(async function () { + this.timeout(15000); - it('should allow disabling the setting when no room uses RC-user-role', async () => { - const res = await request - .post(`${v1}/settings/ABAC_Use_User_Roles_As_Attributes`) - .set(credentials) - .send({ value: false }) - .expect(200); - expect(res.body).to.have.property('success', true); - }); + user = await createUser(); + room = (await createRoom({ type: 'p', name: `extpdp-tighten-${Date.now()}` })).body.group; + await request + .post('/api/v1/groups.invite') + .set(credentials) + .send({ roomId: room._id, usernames: [user.username] }) + .expect(200); - it('should reject disabling while a room uses RC-user-role and keep the setting true', async () => { - await request - .post(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`) - .set(credentials) - .send({ values: [roleAId] }) - .expect(200); + await mockServerReset(); + await seedDefaultMocks(); + await seedGetDecisionBulk([ + { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }, + { resourceDecisions: [{ decision: 'DECISION_PERMIT', ephemeralResourceId: room._id }] }, + ]); + await request + .post(`/api/v1/abac/rooms/${room._id}/attributes/${attrKey}`) + .set(credentials) + .send({ values: ['alpha'] }) + .expect(200); + }); - const res = await request - .post(`${v1}/settings/ABAC_Use_User_Roles_As_Attributes`) - .set(credentials) - .send({ value: false }) - .expect(400); - expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('error').that.includes('error-abac-role-attribute-in-use'); + after(async () => { + await Promise.all([deleteRoom({ type: 'p', roomId: room._id }), deleteUser(user)]); + }); - const check = await request.get(`${v1}/settings/ABAC_Use_User_Roles_As_Attributes`).set(credentials).expect(200); - expect(check.body).to.have.property('value', true); - }); + it('user is removed when attributes are tightened and PDP denies them', async function () { + this.timeout(10000); - it('should allow disabling after clearing RC-user-role from all rooms', async () => { - await request - .post(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`) - .set(credentials) - .send({ values: [roleAId] }) - .expect(200); + await mockServerReset(); + await seedDefaultMocks(); + await seedBulkDecisionByEntity([adminEmail], 'DECISION_DENY'); - await request.delete(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`).set(credentials); + await request + .put(`/api/v1/abac/rooms/${room._id}/attributes/${attrKey}`) + .set(credentials) + .send({ values: ['alpha', 'beta'] }) + .expect(200); - const res = await request - .post(`${v1}/settings/ABAC_Use_User_Roles_As_Attributes`) - .set(credentials) - .send({ value: false }) - .expect(200); - expect(res.body).to.have.property('success', true); - }); + const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200); + const usernames = res.body.members.map((m: IUser) => m.username); + expect(usernames).to.not.include(user.username); + }); + + it('admin remains in the room after tightening', async () => { + const res = await request.get('/api/v1/groups.members').set(credentials).query({ roomId: room._id }).expect(200); + const memberIds = res.body.members.map((m: IUser) => m._id); + expect(memberIds).to.include(credentials['X-User-Id']); }); }); }); From 8512432c4c6ac053cb7553814f22fab144fa6aab Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Thu, 23 Apr 2026 11:58:42 -0600 Subject: [PATCH 12/12] test --- apps/meteor/tests/end-to-end/api/abac.ts | 91 ++++++++++++++---------- 1 file changed, 53 insertions(+), 38 deletions(-) diff --git a/apps/meteor/tests/end-to-end/api/abac.ts b/apps/meteor/tests/end-to-end/api/abac.ts index 1d48cbbe65ca6..b238baf66080d 100644 --- a/apps/meteor/tests/end-to-end/api/abac.ts +++ b/apps/meteor/tests/end-to-end/api/abac.ts @@ -2579,6 +2579,11 @@ const addAbacAttributesToUserDirectly = async (userId: string, abacAttributes: I let roleAId = ''; let roleBId = ''; + // Roles used by the roles.delete guard tests. They must be created up-front: the feature's + // `getAllRoleIdsCached` helper memoizes for 5 minutes, so any role created *during* a test + // won't be in the cached set and will be rejected with `error-attribute-definition-not-found`. + let roleGuardedAId = ''; + let roleGuardedBId = ''; before(async () => { await updateSetting('ABAC_Enabled', true); @@ -2599,6 +2604,20 @@ const addAbacAttributesToUserDirectly = async (userId: string, abacAttributes: I .expect(200); roleBId = createRoleRes2.body.role._id; + const createGuardedA = await request + .post(`${v1}/roles.create`) + .set(credentials) + .send({ name: `rc_user_role_guarded_A_${Date.now()}` }) + .expect(200); + roleGuardedAId = createGuardedA.body.role._id; + + const createGuardedB = await request + .post(`${v1}/roles.create`) + .set(credentials) + .send({ name: `rc_user_role_guarded_B_${Date.now()}` }) + .expect(200); + roleGuardedBId = createGuardedB.body.role._id; + memberA = await createUser(); memberB = await createUser(); outsider = await createUser(); @@ -2615,6 +2634,8 @@ const addAbacAttributesToUserDirectly = async (userId: string, abacAttributes: I await request.post(`${v1}/roles.delete`).set(credentials).send({ roleId: roleAId }); await request.post(`${v1}/roles.delete`).set(credentials).send({ roleId: roleBId }); + await request.post(`${v1}/roles.delete`).set(credentials).send({ roleId: roleGuardedAId }); + await request.post(`${v1}/roles.delete`).set(credentials).send({ roleId: roleGuardedBId }); await deleteUser(memberA); await deleteUser(memberB); @@ -2872,8 +2893,8 @@ const addAbacAttributesToUserDirectly = async (userId: string, abacAttributes: I await request.delete(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`).set(credentials).expect(200); - const roomRes = await request.get(`${v1}/rooms.info`).set(credentials).query({ roomId: abacRoom._id }).expect(200); - expect((roomRes.body.room.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY)).to.be.undefined; + const roomRes = await request.get(`${v1}/rooms.adminRooms.getRoom`).set(credentials).query({ rid: abacRoom._id }).expect(200); + expect((roomRes.body.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY)).to.be.undefined; const aRes = await request.get(`${v1}/users.info`).set(credentials).query({ username: memberA.username }).expect(200); const aRoleAttr = (aRes.body.user.abacAttributes || []).find((a: IAbacAttributeDefinition) => a.key === ROLE_ATTR_KEY); @@ -2942,50 +2963,51 @@ const addAbacAttributesToUserDirectly = async (userId: string, abacAttributes: I }); it('should block deleting a role referenced by an ABAC room via RC-user-role', async () => { - const createRes = await request - .post(`${v1}/roles.create`) - .set(credentials) - .send({ name: `a52_${Date.now()}` }) - .expect(200); - const roleId = createRes.body.role._id; - await request .post(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`) .set(credentials) - .send({ values: [roleId] }) + .send({ values: [roleGuardedAId] }) .expect(200); - const res = await request.post(`${v1}/roles.delete`).set(credentials).send({ roleId }).expect(400); + const res = await request.post(`${v1}/roles.delete`).set(credentials).send({ roleId: roleGuardedAId }).expect(400); expect(res.body).to.have.property('success', false); expect(res.body).to.have.property('error').that.includes('error-abac-role-in-use-by-room'); await request.delete(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`).set(credentials); - await request.post(`${v1}/roles.delete`).set(credentials).send({ roleId }).expect(200); }); it('should allow the same role deletion when the feature is OFF (guard inactive)', async () => { - const createRes = await request - .post(`${v1}/roles.create`) - .set(credentials) - .send({ name: `a53_${Date.now()}` }) - .expect(200); - const roleId = createRes.body.role._id; - await request .post(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`) .set(credentials) - .send({ values: [roleId] }) + .send({ values: [roleGuardedBId] }) .expect(200); await request.delete(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`).set(credentials); await updateSetting('ABAC_Use_User_Roles_As_Attributes', false); - await request.post(`${v1}/roles.delete`).set(credentials).send({ roleId }).expect(200); + await request.post(`${v1}/roles.delete`).set(credentials).send({ roleId: roleGuardedBId }).expect(200); await updateSetting('ABAC_Use_User_Roles_As_Attributes', true); }); }); describe('Setting-disable guard', () => { + // The guard lives in `beforeSaveSetting`, which only runs through the Meteor `saveSettings` + // method (used by the admin UI). The REST endpoint `/settings/:_id` writes directly and + // bypasses the guard — hence these tests go through `method.call/saveSettings`. + const saveFeatureSetting = (value: boolean) => + request + .post(methodCall('saveSettings')) + .set(credentials) + .send({ + message: JSON.stringify({ + msg: 'method', + id: `save-rc-user-role-${Date.now()}`, + method: 'saveSettings', + params: [[{ _id: 'ABAC_Use_User_Roles_As_Attributes', value }]], + }), + }); + beforeEach(async () => { await updateSetting('ABAC_Use_User_Roles_As_Attributes', true); await request.delete(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`).set(credentials); @@ -2997,12 +3019,10 @@ const addAbacAttributesToUserDirectly = async (userId: string, abacAttributes: I }); it('should allow disabling the setting when no room uses RC-user-role', async () => { - const res = await request - .post(`${v1}/settings/ABAC_Use_User_Roles_As_Attributes`) - .set(credentials) - .send({ value: false }) - .expect(200); + const res = await saveFeatureSetting(false).expect(200); expect(res.body).to.have.property('success', true); + const parsed = JSON.parse(res.body.message); + expect(parsed).to.not.have.property('error'); }); it('should reject disabling while a room uses RC-user-role and keep the setting true', async () => { @@ -3012,13 +3032,10 @@ const addAbacAttributesToUserDirectly = async (userId: string, abacAttributes: I .send({ values: [roleAId] }) .expect(200); - const res = await request - .post(`${v1}/settings/ABAC_Use_User_Roles_As_Attributes`) - .set(credentials) - .send({ value: false }) - .expect(400); - expect(res.body).to.have.property('success', false); - expect(res.body).to.have.property('error').that.includes('error-abac-role-attribute-in-use'); + const res = await saveFeatureSetting(false).expect(400); + const parsed = JSON.parse(res.body.message); + expect(parsed).to.have.property('error'); + expect(parsed.error).to.have.property('error', 'error-abac-role-attribute-in-use'); const check = await request.get(`${v1}/settings/ABAC_Use_User_Roles_As_Attributes`).set(credentials).expect(200); expect(check.body).to.have.property('value', true); @@ -3033,12 +3050,10 @@ const addAbacAttributesToUserDirectly = async (userId: string, abacAttributes: I await request.delete(`${v1}/abac/rooms/${abacRoom._id}/attributes/${ROLE_ATTR_KEY}`).set(credentials); - const res = await request - .post(`${v1}/settings/ABAC_Use_User_Roles_As_Attributes`) - .set(credentials) - .send({ value: false }) - .expect(200); + const res = await saveFeatureSetting(false).expect(200); expect(res.body).to.have.property('success', true); + const parsed = JSON.parse(res.body.message); + expect(parsed).to.not.have.property('error'); }); }); });