Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion apps/meteor/app/api/server/v1/roles.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand Down Expand Up @@ -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');
Expand Down
6 changes: 6 additions & 0 deletions apps/meteor/app/lib/server/functions/saveUser/saveUser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -16,6 +17,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';
Expand Down Expand Up @@ -219,6 +221,10 @@ const _saveUser = (session?: ClientSession) =>
oldUser: oldUserData,
});

if (userData._id && userData.roles && !hasSameElements(userData.roles, oldUserData?.roles)) {
await afterUserRolesChanged(userData._id);
}

await Apps.self?.triggerEvent(AppEvents.IPostUserUpdated, {
user: userUpdated,
previousUser: oldUserData,
Expand Down
3 changes: 3 additions & 0 deletions apps/meteor/app/lib/server/methods/saveSetting.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -67,6 +68,8 @@ Meteor.methods<ServerMethods>({
break;
}

await beforeSaveSetting(_id, value);

const auditSettingOperation = updateAuditedByUser({
_id: uid,
username: (await Meteor.userAsync())!.username!,
Expand Down
5 changes: 5 additions & 0 deletions apps/meteor/app/lib/server/methods/saveSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -127,6 +128,10 @@ Meteor.methods<ServerMethods>({
});
}

for (const { _id, value } of params) {
await beforeSaveSetting(_id, value);
}

const auditSettingOperation = updateAuditedByUser({
_id: uid,
username: (await Meteor.userAsync())!.username!,
Expand Down
6 changes: 6 additions & 0 deletions apps/meteor/app/settings/server/lib/beforeSaveSetting.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
// default no-op
});
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
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';
import { RC_USER_ROLE_ATTRIBUTE_KEY } from '../constants';

type ABACAttributeAutocompleteProps = {
labelId: string;
Expand All @@ -16,6 +18,7 @@ type ABACAttributeAutocompleteProps = {

const RoomFormAttributeField = ({ labelId, onRemove, index, attributeList, required = false }: ABACAttributeAutocompleteProps) => {
const { t } = useTranslation();
const getRolesDescription = useRolesDescription();

const { control, getValues, resetField } = useFormContext<RoomFormData>();

Expand Down Expand Up @@ -52,9 +55,19 @@ const RoomFormAttributeField = ({ labelId, onRemove, index, attributeList, requi
}

const selectedAttributeData = attributeList.find((option) => option.value === keyField.value);
if (!selectedAttributeData) {
return [];
}

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 selectedAttributeData?.attributeValues.map((value) => [value, value]) || [];
}, [attributeList, keyField.value]);
return attributeValues.map((value) => [value, value]);
}, [attributeList, keyField.value, getRolesDescription]);

return (
<Box display='flex' flexDirection='column' w='full'>
Expand Down
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -33,30 +33,36 @@ 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);
}
},
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]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const SettingsPage = () => {
<AbacEnabledToggle hasABAC={hasABAC} />
<SettingField settingId='ABAC_PDP_Type' />
<SettingField settingId='ABAC_ShowAttributesInRooms' />
<SettingField settingId='ABAC_Use_User_Roles_As_Attributes' />
<SettingField settingId='Abac_Cache_Decision_Time_Seconds' />

{pdpType === 'local' && (
Expand Down
3 changes: 3 additions & 0 deletions apps/meteor/client/views/admin/ABAC/constants.ts
Original file line number Diff line number Diff line change
@@ -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__';
56 changes: 46 additions & 10 deletions apps/meteor/client/views/admin/ABAC/hooks/useAttributeList.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,30 @@
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { AuthorizationContext, useEndpoint, useSetting } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
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;

type AttributeListItem = {
_id: string;
label: string;
value: string;
attributeValues: string[];
};

export const useAttributeList = () => {
const attributesAutoCompleteEndpoint = useEndpoint('GET', '/v1/abac/attributes');
const isABACAvailable = useIsABACAvailable();

return useQuery({
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 () => {
Expand All @@ -26,14 +40,36 @@ 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;
}

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_ATTRIBUTE_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,
data,
};
};
41 changes: 41 additions & 0 deletions apps/meteor/ee/server/configuration/abac.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,37 @@
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;
Expand All @@ -30,6 +53,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<void> {
if (await cronJobs.has(VIRTRU_PDP_SYNC_JOB)) {
await cronJobs.remove(VIRTRU_PDP_SYNC_JOB);
Expand Down Expand Up @@ -59,6 +99,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);
Expand Down
9 changes: 9 additions & 0 deletions apps/meteor/ee/server/settings/abac.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void> {
return settingsRegistry.addGroup('General', async function () {
Expand Down Expand Up @@ -36,6 +37,14 @@ export function addSettings(): Promise<void> {
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,
Expand Down
Loading
Loading