From 4591df3565ed3335b8b57892c6c8e90a952a0eb7 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 20 Oct 2025 14:04:02 -0600 Subject: [PATCH 01/21] feat: Remove users from room when new attributes are added to the room (#37172) --- ee/packages/abac/src/index.ts | 50 +++++++++-------------------------- 1 file changed, 13 insertions(+), 37 deletions(-) diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index 4c1084f68b315..70c3f2af98c5f 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -170,13 +170,9 @@ export class AbacService extends ServiceClass implements IAbacService { } async setRoomAbacAttributes(rid: string, attributes: Record): Promise { - const room = await Rooms.findOneByIdAndType>( - rid, - 'p', - { - projection: { abacAttributes: 1, t: 1, teamMain: 1, teamDefault: 1, default: 1 }, - }, - ); + const room = await Rooms.findOneByIdAndType>(rid, 'p', { + projection: { abacAttributes: 1, t: 1, teamMain: 1 }, + }); if (!room) { throw new Error('error-room-not-found'); } @@ -272,13 +268,9 @@ export class AbacService extends ServiceClass implements IAbacService { } async updateRoomAbacAttributeValues(rid: string, key: string, values: string[]): Promise { - const room = await Rooms.findOneByIdAndType>( - rid, - 'p', - { - projection: { abacAttributes: 1, t: 1, teamMain: 1, teamDefault: 1, default: 1 }, - }, - ); + const room = await Rooms.findOneByIdAndType>(rid, 'p', { + projection: { abacAttributes: 1, t: 1, teamMain: 1 }, + }); if (!room) { throw new Error('error-room-not-found'); } @@ -325,9 +317,7 @@ export class AbacService extends ServiceClass implements IAbacService { } async removeRoomAbacAttribute(rid: string, key: string): Promise { - const room = await Rooms.findOneByIdAndType>(rid, 'p', { - projection: { abacAttributes: 1, default: 1, teamDefault: 1 }, - }); + const room = await Rooms.findOneByIdAndType>(rid, 'p', { projection: { abacAttributes: 1 } }); if (!room) { throw new Error('error-room-not-found'); } @@ -342,12 +332,6 @@ export class AbacService extends ServiceClass implements IAbacService { return; } - // if is the last attribute, just remove all - if (previous.length === 1) { - await Rooms.unsetAbacAttributesById(rid); - return; - } - await Rooms.removeAbacAttributeByRoomIdAndKey(rid, key); this.logger.debug({ msg: 'Room ABAC attribute removed', @@ -359,13 +343,9 @@ export class AbacService extends ServiceClass implements IAbacService { async addRoomAbacAttributeByKey(rid: string, key: string, values: string[]): Promise { await this.ensureAttributeDefinitionsExist([{ key, values }]); - const room = await Rooms.findOneByIdAndType>( - rid, - 'p', - { - projection: { abacAttributes: 1, t: 1, teamMain: 1, teamDefault: 1, default: 1 }, - }, - ); + const room = await Rooms.findOneByIdAndType>(rid, 'p', { + projection: { abacAttributes: 1, t: 1, teamMain: 1 }, + }); if (!room) { throw new Error('error-room-not-found'); } @@ -392,13 +372,9 @@ export class AbacService extends ServiceClass implements IAbacService { async replaceRoomAbacAttributeByKey(rid: string, key: string, values: string[]): Promise { await this.ensureAttributeDefinitionsExist([{ key, values }]); - const room = await Rooms.findOneByIdAndType>( - rid, - 'p', - { - projection: { abacAttributes: 1, t: 1, teamMain: 1, teamDefault: 1, default: 1 }, - }, - ); + const room = await Rooms.findOneByIdAndType>(rid, 'p', { + projection: { abacAttributes: 1, t: 1, teamMain: 1 }, + }); if (!room) { throw new Error('error-room-not-found'); } From 55aa068d469f77b8d23afa9bbe23ff6cef184e7b Mon Sep 17 00:00:00 2001 From: MartinSchoeler Date: Fri, 3 Oct 2025 13:21:12 -0300 Subject: [PATCH 02/21] feat: add ABAC admin settings --- .../ABACUpsellModal/ABACUpsellModal.spec.tsx | 9 +- .../client/views/admin/ABAC/AdminABACPage.tsx | 48 +++++++ .../views/admin/ABAC/AdminABACRoute.tsx | 63 +++++++++ .../ABAC/AdminABACSettingToggle.spec.tsx | 131 ++++++++++++++++++ .../ABAC/AdminABACSettingToggle.stories.tsx | 50 +++++++ .../admin/ABAC/AdminABACSettingToggle.tsx | 88 ++++++++++++ .../views/admin/ABAC/AdminABACSettings.tsx | 28 ++++ .../client/views/admin/ABAC/AdminABACTabs.tsx | 24 ++++ .../admin/ABAC/AdminABACWarningModal.tsx | 48 +++++++ .../AdminABACSettingToggle.spec.tsx.snap | 60 ++++++++ apps/meteor/client/views/admin/routes.tsx | 9 ++ .../meteor/client/views/admin/sidebarItems.ts | 5 + apps/meteor/ee/server/settings/abac.ts | 1 + apps/meteor/tests/mocks/data.ts | 1 + packages/i18n/src/locales/en.i18n.json | 10 +- 15 files changed, 569 insertions(+), 6 deletions(-) create mode 100644 apps/meteor/client/views/admin/ABAC/AdminABACPage.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/AdminABACRoute.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/AdminABACSettingToggle.spec.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/AdminABACSettingToggle.stories.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/AdminABACSettingToggle.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/AdminABACSettings.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/AdminABACTabs.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/AdminABACWarningModal.tsx create mode 100644 apps/meteor/client/views/admin/ABAC/__snapshots__/AdminABACSettingToggle.spec.tsx.snap diff --git a/apps/meteor/client/components/ABAC/ABACUpsellModal/ABACUpsellModal.spec.tsx b/apps/meteor/client/components/ABAC/ABACUpsellModal/ABACUpsellModal.spec.tsx index c2fd14fb4feb9..584b64aece7fb 100644 --- a/apps/meteor/client/components/ABAC/ABACUpsellModal/ABACUpsellModal.spec.tsx +++ b/apps/meteor/client/components/ABAC/ABACUpsellModal/ABACUpsellModal.spec.tsx @@ -4,11 +4,7 @@ import userEvent from '@testing-library/user-event'; import { axe } from 'jest-axe'; import ABACUpsellModal from './ABACUpsellModal'; - -// Mock the hooks used by ABACUpsellModal -jest.mock('../../../hooks/useHasLicenseModule', () => ({ - useHasLicenseModule: jest.fn(() => false), -})); +import { createFakeLicenseInfo } from '../../../../tests/mocks/data'; jest.mock('../../GenericUpsellModal/hooks', () => ({ useUpsellActions: jest.fn(() => ({ @@ -34,6 +30,9 @@ const appRoot = mockAppRoot() Upgrade: 'Upgrade', Cancel: 'Cancel', }) + .withEndpoint('GET', '/v1/licenses.info', async () => ({ + license: createFakeLicenseInfo(), + })) .build(); describe('ABACUpsellModal', () => { diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACPage.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACPage.tsx new file mode 100644 index 0000000000000..015b94a1ca1c5 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/AdminABACPage.tsx @@ -0,0 +1,48 @@ +import { Box, Button, Callout } from '@rocket.chat/fuselage'; +import { useRouteParameter } from '@rocket.chat/ui-contexts'; +import { Trans, useTranslation } from 'react-i18next'; + +import AdminABACSettings from './AdminABACSettings'; +import AdminABACTabs from './AdminABACTabs'; +import { Page, PageContent, PageHeader } from '../../../components/Page'; +import { useExternalLink } from '../../../hooks/useExternalLink'; + +type AdminABACPageProps = { + shouldShowWarning: boolean; +}; + +const AdminABACPage = ({ shouldShowWarning }: AdminABACPageProps) => { + const { t } = useTranslation(); + const tab = useRouteParameter('tab'); + const learnMore = useExternalLink(); + + return ( + + + + + + {shouldShowWarning && ( + + + {/* TODO: get documentation URL */} + + Renew your license to continue using all + + {' '} + ABAC capabilities without restriction. + + + + + )} + + {tab === 'settings' && } + + + ); +}; + +export default AdminABACPage; diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACRoute.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACRoute.tsx new file mode 100644 index 0000000000000..f4116d2cb3185 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/AdminABACRoute.tsx @@ -0,0 +1,63 @@ +import { usePermission, useSetModal, useCurrentModal, useRouter, useRouteParameter, useSettingStructure } from '@rocket.chat/ui-contexts'; +import type { ReactElement } from 'react'; +import { memo, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; + +import AdminABACPage from './AdminABACPage'; +import ABACUpsellModal from '../../../components/ABAC/ABACUpsellModal/ABACUpsellModal'; +import { useUpsellActions } from '../../../components/GenericUpsellModal/hooks'; +import PageSkeleton from '../../../components/PageSkeleton'; +// import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule'; +import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule'; +import SettingsProvider from '../../../providers/SettingsProvider'; +import NotAuthorizedPage from '../../notAuthorized/NotAuthorizedPage'; +import EditableSettingsProvider from '../settings/EditableSettingsProvider'; + +const AdminABACRoute = (): ReactElement => { + const { t } = useTranslation(); + // TODO: Check what permission is needed to view the ABAC page + const canViewABACPage = usePermission('manage-cloud'); + const hasABAC = useHasLicenseModule('abac') === true; + const isModalOpen = !!useCurrentModal(); + const tab = useRouteParameter('tab'); + const router = useRouter(); + + // Check if setting exists in the DB to decide if we show warning or upsell + const ABACEnabledSetting = useSettingStructure('ABAC_Enabled'); + + if (!tab) { + router.navigate({ + name: 'admin-ABAC', + params: { tab: 'settings' }, + }); + } + + const { shouldShowUpsell, handleManageSubscription } = useUpsellActions(hasABAC); + + const setModal = useSetModal(); + + useEffect(() => { + // WS has never activated ABAC + if (shouldShowUpsell && ABACEnabledSetting === undefined) { + setModal( setModal(null)} onConfirm={handleManageSubscription} />); + } + }, [shouldShowUpsell, setModal, t, handleManageSubscription, ABACEnabledSetting]); + + if (isModalOpen) { + return ; + } + + if (!canViewABACPage || (ABACEnabledSetting === undefined && !hasABAC)) { + return ; + } + + return ( + + + + + + ); +}; + +export default memo(AdminABACRoute); diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACSettingToggle.spec.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACSettingToggle.spec.tsx new file mode 100644 index 0000000000000..937cbbef474bf --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/AdminABACSettingToggle.spec.tsx @@ -0,0 +1,131 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import { render, screen, waitFor } from '@testing-library/react'; +import userEvent from '@testing-library/user-event'; +import { axe } from 'jest-axe'; + +import AdminABACSettingToggle from './AdminABACSettingToggle'; +import { createFakeLicenseInfo } from '../../../../tests/mocks/data'; +import EditableSettingsProvider from '../settings/EditableSettingsProvider'; + +const baseAppRoot = mockAppRoot() + .wrap((children) => {children}) + .withTranslations('en', 'core', { + ABAC_Enabled: 'Enable ABAC', + ABAC_Enabled_Description: 'Enable Attribute-Based Access Control', + ABAC_Warning_Modal_Title: 'Disable ABAC', + ABAC_Warning_Modal_Confirm_Text: 'Disable', + Cancel: 'Cancel', + }) + .withEndpoint('GET', '/v1/licenses.info', () => ({ + license: createFakeLicenseInfo({ activeModules: ['abac'] }), + })); + +describe('AdminABACSettingToggle', () => { + it('should render the setting toggle when setting exists', () => { + const { baseElement } = render(, { + wrapper: baseAppRoot.withSetting('ABAC_Enabled', true).build(), + }); + expect(baseElement).toMatchSnapshot(); + }); + + it('should show warning modal when disabling ABAC', async () => { + const user = userEvent.setup(); + render(, { + wrapper: baseAppRoot.withSetting('ABAC_Enabled', true).build(), + }); + + const toggle = screen.getByRole('checkbox'); + await user.click(toggle); + + await waitFor(() => { + expect(screen.getByText('Disable ABAC')).toBeInTheDocument(); + }); + + // TODO: discover how to automatically unmount all modals after each test + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + await user.click(cancelButton); + }); + + it('should not show warning modal when enabling ABAC', async () => { + const user = userEvent.setup(); + render(, { + wrapper: baseAppRoot.withSetting('ABAC_Enabled', false).build(), + }); + + const toggle = screen.getByRole('checkbox'); + await user.click(toggle); + + // The modal should not appear when enabling ABAC + expect(screen.queryByText('Disable ABAC')).not.toBeInTheDocument(); + }); + + it('should show warning modal when resetting setting', async () => { + const user = userEvent.setup(); + render(, { + wrapper: baseAppRoot.withSetting('ABAC_Enabled', true).build(), + }); + + const resetButton = screen.getByRole('button', { name: /reset/i }); + await user.click(resetButton); + + await waitFor(() => { + expect(screen.getByText('Disable ABAC')).toBeInTheDocument(); + }); + + // TODO: discover how to automatically unmount all modals after each test + const cancelButton = screen.getByRole('button', { name: /cancel/i }); + await user.click(cancelButton); + }); + + it('should have no accessibility violations', async () => { + const { container } = render(, { + wrapper: baseAppRoot.withSetting('ABAC_Enabled', true).build(), + }); + const results = await axe(container); + expect(results).toHaveNoViolations(); + }); + + it('should handle setting change correctly', async () => { + const user = userEvent.setup(); + render(, { + wrapper: baseAppRoot.withSetting('ABAC_Enabled', false).build(), + }); + + const toggle = screen.getByRole('checkbox'); + expect(toggle).not.toBeChecked(); + + await user.click(toggle); + expect(toggle).toBeChecked(); + }); + + it('should be disabled when abac license is not installed', () => { + const { baseElement } = render(, { + wrapper: baseAppRoot + .withSetting('ABAC_Enabled', true) + .withEndpoint('GET', '/v1/licenses.info', () => ({ + license: createFakeLicenseInfo({ activeModules: [] }), + })) + .build(), + }); + + const toggle = screen.getByRole('checkbox'); + expect(toggle).toBeDisabled(); + expect(baseElement).toMatchSnapshot(); + }); + + it('should show reset button when value differs from package value', () => { + render(, { + wrapper: baseAppRoot.withSetting('ABAC_Enabled', true).build(), + }); + + expect(screen.getByRole('button', { name: /reset/i })).toBeInTheDocument(); + }); + + it('should not show reset button when value matches package value', () => { + render(, { + wrapper: baseAppRoot.withSetting('ABAC_Enabled', false).build(), + }); + + expect(screen.queryByRole('button', { name: /reset/i })).not.toBeInTheDocument(); + }); +}); diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACSettingToggle.stories.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACSettingToggle.stories.tsx new file mode 100644 index 0000000000000..00848d53a8d1d --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/AdminABACSettingToggle.stories.tsx @@ -0,0 +1,50 @@ +import { mockAppRoot } from '@rocket.chat/mock-providers'; +import type { Meta, StoryObj } from '@storybook/react'; + +import AdminABACSettingToggle from './AdminABACSettingToggle'; +import { createFakeLicenseInfo } from '../../../../tests/mocks/data'; +import EditableSettingsProvider from '../settings/EditableSettingsProvider'; + +const meta: Meta = { + title: 'Admin/ABAC/AdminABACSettingToggle', + component: AdminABACSettingToggle, + parameters: { + layout: 'padded', + }, + decorators: [ + (Story) => { + const AppRoot = mockAppRoot() + .wrap((children) => {children}) + .withTranslations('en', 'core', { + ABAC_Enabled: 'Enable ABAC', + ABAC_Enabled_Description: 'Enable Attribute-Based Access Control', + ABAC_Warning_Modal_Title: 'Disable ABAC', + ABAC_Warning_Modal_Confirm_Text: 'Disable', + Cancel: 'Cancel', + }) + .withSetting('ABAC_Enabled', true, { + packageValue: true, + blocked: false, + public: true, + type: 'boolean', + i18nLabel: 'ABAC_Enabled', + i18nDescription: 'ABAC_Enabled_Description', + }) + .withEndpoint('GET', '/v1/licenses.info', () => ({ + license: createFakeLicenseInfo({ activeModules: [] }), + })) + .build(); + + return ( + + + + ); + }, + ], +}; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACSettingToggle.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACSettingToggle.tsx new file mode 100644 index 0000000000000..dc9b3f527cd7a --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/AdminABACSettingToggle.tsx @@ -0,0 +1,88 @@ +import type { SettingValue } from '@rocket.chat/core-typings'; +import { useSetModal, useSettingsDispatch } from '@rocket.chat/ui-contexts'; +import { useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { EditableSetting } from '../EditableSettingsContext'; +import { useEditableSetting } from '../EditableSettingsContext'; +import AdminABACWarningModal from './AdminABACWarningModal'; +import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule'; +import MemoizedSetting from '../settings/Setting/MemoizedSetting'; + +const AdminABACSettingToggle = () => { + const setting = useEditableSetting('ABAC_Enabled'); + const setModal = useSetModal(); + const dispatch = useSettingsDispatch(); + const { t } = useTranslation(); + const hasABAC = useHasLicenseModule('abac'); + + const [value, setValue] = useState(setting?.value === true); + + useEffect(() => { + setValue(setting?.value === true); + }, [setting]); + + const onchange = useCallback( + (value: boolean) => { + if (!setting) { + return; + } + + const handleChange = (value: boolean, setting: EditableSetting) => { + setValue(value); + dispatch([{ _id: setting._id, value }]); + }; + + if (value === false) { + return setModal( + { + handleChange(value, setting); + setModal(); + }} + onCancel={() => setModal()} + />, + ); + } + handleChange(value, setting); + }, + [dispatch, setModal, setting], + ); + + const onreset = useCallback(() => { + if (!setting) { + return; + } + const value = setting.packageValue as boolean; + setModal( + { + setValue(value); + dispatch([{ _id: setting._id, value }]); + setModal(); + }} + onCancel={() => setModal()} + />, + ); + }, [dispatch, setModal, setting]); + + if (!setting) { + return null; + } + + return ( + onchange(value === true)} + onResetButtonClick={() => onreset()} + /> + ); +}; +export default AdminABACSettingToggle; diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACSettings.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACSettings.tsx new file mode 100644 index 0000000000000..e7709218189af --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/AdminABACSettings.tsx @@ -0,0 +1,28 @@ +import { Box, Callout, Margins } from '@rocket.chat/fuselage'; +import { Trans } from 'react-i18next'; + +import AdminABACSettingToggle from './AdminABACSettingToggle'; + +const AdminABACSettings = () => { + return ( + + + + + + + {/* TODO: get documentation URL */} + + User attributes are synchronized via LDAP + + Learn more + + + + + + + ); +}; + +export default AdminABACSettings; diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACTabs.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACTabs.tsx new file mode 100644 index 0000000000000..4fc73a491f44e --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/AdminABACTabs.tsx @@ -0,0 +1,24 @@ +import { Tabs, TabsItem } from '@rocket.chat/fuselage'; +import { useRouteParameter, useRouter } from '@rocket.chat/ui-contexts'; +import { useTranslation } from 'react-i18next'; + +const AdminABACTabs = () => { + const { t } = useTranslation(); + const router = useRouter(); + const tab = useRouteParameter('tab'); + const handleTabClick = (tab: string) => { + router.navigate({ + name: 'admin-ABAC', + params: { tab }, + }); + }; + return ( + + handleTabClick('settings')}> + {t('Settings')} + + + ); +}; + +export default AdminABACTabs; diff --git a/apps/meteor/client/views/admin/ABAC/AdminABACWarningModal.tsx b/apps/meteor/client/views/admin/ABAC/AdminABACWarningModal.tsx new file mode 100644 index 0000000000000..107f069e1f8d0 --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/AdminABACWarningModal.tsx @@ -0,0 +1,48 @@ +import { Box, Palette } from '@rocket.chat/fuselage'; +import { GenericModal } from '@rocket.chat/ui-client'; +import { useRouter } from '@rocket.chat/ui-contexts'; +import { Trans, useTranslation } from 'react-i18next'; + +type AdminABACWarningModalProps = { + onConfirm: () => void; + onCancel: () => void; +}; + +const AdminABACWarningModal = ({ onConfirm, onCancel }: AdminABACWarningModalProps) => { + const { t } = useTranslation(); + const router = useRouter(); + const handleNavigate = () => { + router.navigate({ + name: 'admin-ABAC', + params: { + tab: 'rooms', + }, + }); + }; + + return ( + {t('ABAC_Warning_Modal_Confirm_Text')}} + cancelText={t('Cancel')} + onConfirm={onConfirm} + onCancel={onCancel} + onClose={onCancel} + onDismiss={onCancel} + > + + You will not be able to automatically or manually manage users in existing ABAC-managed rooms. To restore a room's default access + control, it must be removed from ABAC management in + + {' '} + ABAC {'>'} Rooms' + + . + + + ); +}; + +export default AdminABACWarningModal; diff --git a/apps/meteor/client/views/admin/ABAC/__snapshots__/AdminABACSettingToggle.spec.tsx.snap b/apps/meteor/client/views/admin/ABAC/__snapshots__/AdminABACSettingToggle.spec.tsx.snap new file mode 100644 index 0000000000000..83d70957e694a --- /dev/null +++ b/apps/meteor/client/views/admin/ABAC/__snapshots__/AdminABACSettingToggle.spec.tsx.snap @@ -0,0 +1,60 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`AdminABACSettingToggle should render the setting toggle when setting exists 1`] = ` + +
+
+
+
+ +