Skip to content

Commit 3c47215

Browse files
authored
feat: Virtru as attribute store (#40634)
1 parent 295e951 commit 3c47215

52 files changed

Lines changed: 3081 additions & 346 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.changeset/khaki-gifts-follow.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
"@rocket.chat/meteor": minor
3+
"@rocket.chat/abac": minor
4+
---
5+
6+
Allows using Virtru as the attribute store for ABAC decisions.
7+
8+
### Important
9+
10+
- When using virtru as the store, the internal attribute store is disabled.
11+
- On switch, existing ABAC attributes from rooms will be removed. Rooms will continue to be private & no users will be removed until you add attributes again.
12+
- Users are only allowed to see & edit rooms they have access to. Access decision is evaluated on Virtru
13+
- A user/app with the `bypass-abac-store-validation` permission can assign any attributes to rooms, even if the user doesn't have them assigned on Virtru.

apps/meteor/app/api/server/lib/rooms.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import type { IRoom, ISubscription, RoomAdminFieldsType, RoomType } from '@rocket.chat/core-typings';
1+
import type { IRoom, IRoomAbacRedaction, ISubscription, RoomAdminFieldsType, RoomType } from '@rocket.chat/core-typings';
22
import { Rooms, Subscriptions } from '@rocket.chat/models';
33
import type { FindOptions, Sort } from 'mongodb';
44

5+
import { scopeAdminRoomsForAbac } from './scopeAdminRoomsForAbac';
56
import { adminFields } from '../../../../lib/rooms/adminFields';
67
import { hasAtLeastOnePermissionAsync, hasPermissionAsync } from '../../../authorization/server/functions/hasPermission';
78
import { stripABACManagedFieldsForAdmin } from '../../../authorization/server/lib/isABACManagedRoom';
@@ -17,7 +18,7 @@ export async function findAdminRooms({
1718
types: Array<RoomType | 'discussions' | 'teams'>;
1819
pagination: { offset: number; count: number; sort: Sort };
1920
}): Promise<{
20-
rooms: IRoom[];
21+
rooms: Array<Pick<IRoom, RoomAdminFieldsType> & IRoomAbacRedaction>;
2122
count: number;
2223
offset: number;
2324
total: number;
@@ -49,14 +50,20 @@ export async function findAdminRooms({
4950
]);
5051

5152
return {
52-
rooms,
53+
rooms: await scopeAdminRoomsForAbac(rooms, uid),
5354
count: rooms.length,
5455
offset,
5556
total,
5657
};
5758
}
5859

59-
export async function findAdminRoom({ uid, rid }: { uid: string; rid: string }): Promise<Pick<IRoom, RoomAdminFieldsType> | null> {
60+
export async function findAdminRoom({
61+
uid,
62+
rid,
63+
}: {
64+
uid: string;
65+
rid: string;
66+
}): Promise<(Pick<IRoom, RoomAdminFieldsType> & IRoomAbacRedaction) | null> {
6067
if (!(await hasPermissionAsync(uid, 'view-room-administration'))) {
6168
throw new Error('error-not-authorized');
6269
}
@@ -65,7 +72,9 @@ export async function findAdminRoom({ uid, rid }: { uid: string; rid: string }):
6572
if (!room) {
6673
return null;
6774
}
68-
return stripABACManagedFieldsForAdmin(room);
75+
76+
const [scoped] = await scopeAdminRoomsForAbac([stripABACManagedFieldsForAdmin(room)], uid);
77+
return scoped ?? null;
6978
}
7079

7180
export async function findChannelAndPrivateAutocomplete({ uid, selector }: { uid: string; selector: { name: string } }): Promise<{
Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
import type { IRoom, IRoomAbacRedaction, RoomAdminFieldsType } from '@rocket.chat/core-typings';
2+
import { makeFunction } from '@rocket.chat/patch-injection';
3+
4+
export const scopeAdminRoomsForAbac = makeFunction(
5+
async (rooms: Pick<IRoom, RoomAdminFieldsType>[], _uid: string): Promise<Array<Pick<IRoom, RoomAdminFieldsType> & IRoomAbacRedaction>> =>
6+
rooms,
7+
);

apps/meteor/app/api/server/v1/rooms.ts

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { FederationMatrix, MeteorError, Team } from '@rocket.chat/core-services';
22
import {
33
type IRoom,
4+
type IRoomAbacRedaction,
45
type IUpload,
56
type RequiredField,
67
type RoomAdminFieldsType,
@@ -89,6 +90,7 @@ import {
8990
findChannelAndPrivateAutocompleteWithPagination,
9091
findRoomsAvailableForTeams,
9192
} from '../lib/rooms';
93+
import { scopeAdminRoomsForAbac } from '../lib/scopeAdminRoomsForAbac';
9294

9395
export async function findRoomByIdOrName({
9496
params,
@@ -712,10 +714,15 @@ API.v1.get(
712714
authRequired: true,
713715
query: isRoomsAdminRoomsProps,
714716
response: {
715-
200: ajv.compile<{ rooms: IRoom[]; count: number; offset: number; total: number }>({
717+
200: ajv.compile<{
718+
rooms: Array<Pick<IRoom, RoomAdminFieldsType> & IRoomAbacRedaction>;
719+
count: number;
720+
offset: number;
721+
total: number;
722+
}>({
716723
type: 'object',
717724
properties: {
718-
rooms: { type: 'array', items: { type: 'object' } }, // relaxed: IRoom with admin fields
725+
rooms: { type: 'array', items: { type: 'object' } }, // relaxed: IRoom with admin fields + optional ABAC redaction
719726
count: { type: 'number' },
720727
offset: { type: 'number' },
721728
total: { type: 'number' },
@@ -785,10 +792,17 @@ API.v1.get(
785792
authRequired: true,
786793
query: isRoomsAdminRoomsGetRoomProps,
787794
response: {
788-
200: ajv.compile<Pick<IRoom, RoomAdminFieldsType>>({
795+
200: ajv.compile<Pick<IRoom, RoomAdminFieldsType> & IRoomAbacRedaction>({
789796
allOf: [
790797
{ $ref: '#/components/schemas/IRoomAdmin' },
791-
{ type: 'object', properties: { success: { type: 'boolean', enum: [true] } }, required: ['success'] },
798+
{
799+
type: 'object',
800+
properties: {
801+
success: { type: 'boolean', enum: [true] },
802+
abacAttributesRedacted: { type: 'boolean' },
803+
},
804+
required: ['success'],
805+
},
792806
],
793807
}),
794808
400: validateBadRequestErrorResponse,
@@ -1447,7 +1461,7 @@ export const roomEndpoints = API.v1
14471461
401: validateUnauthorizedErrorResponse,
14481462
403: validateUnauthorizedErrorResponse,
14491463
200: ajv.compile<{
1450-
rooms: IRoom[];
1464+
rooms: Array<Pick<IRoom, RoomAdminFieldsType> & IRoomAbacRedaction>;
14511465
count: number;
14521466
offset: number;
14531467
total: number;
@@ -1485,7 +1499,7 @@ export const roomEndpoints = API.v1
14851499
const [rooms, total] = await Promise.all([cursor.map(stripABACManagedFieldsForAdmin).toArray(), totalCount]);
14861500

14871501
return API.v1.success({
1488-
rooms,
1502+
rooms: await scopeAdminRoomsForAbac(rooms, this.userId),
14891503
count: rooms.length,
14901504
offset,
14911505
total,

apps/meteor/client/views/admin/ABAC/ABACLogsTab/LogsPage.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,14 @@ const LogsPage = () => {
114114
.join(', ') ?? t('Empty'),
115115
room: event.data?.find((item) => item.key === 'room')?.value?.name ?? '',
116116
};
117+
case 'abac.attribute.store.switched':
118+
return {
119+
...eventInfo,
120+
element: t('ABAC_Attribute_Store'),
121+
action: t('ABAC_Attribute_Store_Switched'),
122+
name: `${event.data?.find((item) => item.key === 'from')?.value} -> ${event.data?.find((item) => item.key === 'to')?.value}`,
123+
room: t('ABAC_Rooms_Affected', { count: Number(event.data?.find((item) => item.key === 'roomsAffected')?.value ?? 0) }),
124+
};
117125
default:
118126
return null;
119127
}

apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomForm.tsx

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Box, Field, FieldLabel, FieldRow, FieldError, ButtonGroup, Button, ContextualbarFooter } from '@rocket.chat/fuselage';
1+
import { Box, Callout, Field, FieldLabel, FieldRow, FieldError, ButtonGroup, Button, ContextualbarFooter } from '@rocket.chat/fuselage';
22
import { useEffectEvent } from '@rocket.chat/fuselage-hooks';
33
import { GenericModal, ContextualbarScrollableContent } from '@rocket.chat/ui-client';
44
import { useSetModal } from '@rocket.chat/ui-contexts';
@@ -16,14 +16,15 @@ type RoomFormProps = {
1616
onSave: (data: RoomFormData) => void;
1717
roomInfo?: { rid: string; name: string };
1818
setSelectedRoomLabel: Dispatch<SetStateAction<string>>;
19+
redacted?: boolean;
1920
};
2021

2122
export type RoomFormData = {
2223
room: string;
2324
attributes: { key: string; values: string[] }[];
2425
};
2526

26-
const RoomForm = ({ onClose, onSave, roomInfo, setSelectedRoomLabel }: RoomFormProps) => {
27+
const RoomForm = ({ onClose, onSave, roomInfo, setSelectedRoomLabel, redacted = false }: RoomFormProps) => {
2728
const {
2829
control,
2930
handleSubmit,
@@ -110,10 +111,17 @@ const RoomForm = ({ onClose, onSave, roomInfo, setSelectedRoomLabel }: RoomFormP
110111
</FieldError>
111112
)}
112113
</Field>
113-
<RoomFormAttributeFields fields={fields} remove={remove} />
114+
{redacted && (
115+
<Box mbe={16}>
116+
<Callout type='warning' title={t('ABAC_Attributes_Redacted')}>
117+
{t('ABAC_Attributes_Redacted_Description')}
118+
</Callout>
119+
</Box>
120+
)}
121+
<RoomFormAttributeFields fields={fields} remove={remove} disabled={redacted} />
114122
<Button
115123
w='full'
116-
disabled={fields.length >= 10}
124+
disabled={redacted || fields.length >= 10}
117125
onClick={() => {
118126
append({ key: '', values: [] });
119127
}}
@@ -124,7 +132,7 @@ const RoomForm = ({ onClose, onSave, roomInfo, setSelectedRoomLabel }: RoomFormP
124132
<ContextualbarFooter>
125133
<ButtonGroup stretch>
126134
<Button onClick={onClose}>{t('Cancel')}</Button>
127-
<Button type='submit' form={formId} disabled={!isValid || !isDirty} primary>
135+
<Button type='submit' form={formId} disabled={redacted || !isValid || !isDirty} primary>
128136
{t('Save')}
129137
</Button>
130138
</ButtonGroup>

apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeField.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,17 @@ type ABACAttributeAutocompleteProps = {
1212
index: number;
1313
attributeList: { value: string; label: string; attributeValues: string[] }[];
1414
required?: boolean;
15+
disabled?: boolean;
1516
};
1617

17-
const RoomFormAttributeField = ({ labelId, onRemove, index, attributeList, required = false }: ABACAttributeAutocompleteProps) => {
18+
const RoomFormAttributeField = ({
19+
labelId,
20+
onRemove,
21+
index,
22+
attributeList,
23+
required = false,
24+
disabled = false,
25+
}: ABACAttributeAutocompleteProps) => {
1826
const { t } = useTranslation();
1927

2028
const { control, getValues, resetField } = useFormContext<RoomFormData>();
@@ -71,6 +79,7 @@ const RoomFormAttributeField = ({ labelId, onRemove, index, attributeList, requi
7179
mbe={4}
7280
error={keyFieldState.error?.message}
7381
withTruncatedText
82+
disabled={disabled}
7483
onChange={(value) => {
7584
keyField.onChange(value);
7685
resetField(`attributes.${index}.values`, { defaultValue: [] });
@@ -94,6 +103,7 @@ const RoomFormAttributeField = ({ labelId, onRemove, index, attributeList, requi
94103
options={valueOptions}
95104
placeholder={t('ABAC_Select_Attribute_Values')}
96105
error={valuesFieldState.error?.message}
106+
disabled={disabled}
97107
/>
98108
</FieldRow>
99109
{valuesFieldState.error && (
@@ -102,7 +112,7 @@ const RoomFormAttributeField = ({ labelId, onRemove, index, attributeList, requi
102112
</FieldError>
103113
)}
104114
{index !== 0 && (
105-
<Button onClick={onRemove} title={t('Remove')} mbs={8}>
115+
<Button onClick={onRemove} title={t('Remove')} mbs={8} disabled={disabled}>
106116
{t('Remove')}
107117
</Button>
108118
)}

apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAttributeFields.spec.tsx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,9 @@ const appRoot = mockAppRoot()
4343
ABAC_Search_Attribute: 'Search attribute',
4444
ABAC_Select_Attribute_Values: 'Select attribute values',
4545
Remove: 'Remove',
46+
ABAC_Picker_External_Store_Helper: 'Available attributes are limited to those you possess.',
4647
})
48+
.withSetting('ABAC_Attribute_Store', 'local')
4749
.build();
4850

4951
const FormProviderWrapper = ({ children, defaultValues }: { children: ReactNode; defaultValues?: Partial<RoomFormData> }) => {
@@ -133,4 +135,37 @@ describe('RoomFormAttributeFields', () => {
133135
expect(screen.getByText('Public')).toBeInTheDocument();
134136
expect(screen.getByText('Internal')).toBeInTheDocument();
135137
});
138+
139+
it('should render fields with disabled inputs when disabled prop is true', () => {
140+
const fields = [{ id: 'field-1' }, { id: 'field-2' }];
141+
142+
render(
143+
<FormProviderWrapper>
144+
<RoomFormAttributeFields fields={fields} remove={mockRemove} disabled />
145+
</FormProviderWrapper>,
146+
{ wrapper: appRoot },
147+
);
148+
149+
const attributeLabels = screen.getAllByText('Attribute');
150+
expect(attributeLabels).toHaveLength(2);
151+
152+
screen.getAllByPlaceholderText('Search attribute').forEach((input) => expect(input).toBeDisabled());
153+
screen.getAllByRole('combobox').forEach((input) => expect(input).toBeDisabled());
154+
expect(screen.getByRole('button', { name: 'Remove' })).toBeDisabled();
155+
});
156+
157+
it('should render fields without disabled state when disabled prop is false', () => {
158+
const fields = [{ id: 'field-1' }];
159+
160+
render(
161+
<FormProviderWrapper>
162+
<RoomFormAttributeFields fields={fields} remove={mockRemove} disabled={false} />
163+
</FormProviderWrapper>,
164+
{ wrapper: appRoot },
165+
);
166+
167+
expect(screen.getAllByText('Attribute')).toHaveLength(1);
168+
screen.getAllByPlaceholderText('Search attribute').forEach((input) => expect(input).not.toBeDisabled());
169+
screen.getAllByRole('combobox').forEach((input) => expect(input).not.toBeDisabled());
170+
});
136171
});
Lines changed: 31 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,52 @@
1-
import { Field, FieldLabel, InputBoxSkeleton } from '@rocket.chat/fuselage';
1+
import { Box, Field, FieldLabel, InputBoxSkeleton } from '@rocket.chat/fuselage';
22
import { useTranslation } from 'react-i18next';
33

44
import RoomFormAttributeField from './RoomFormAttributeField';
55
import { useAttributeList } from '../hooks/useAttributeList';
6+
import { useIsExternalAttributeStore } from '../hooks/useIsExternalAttributeStore';
67

78
type RoomFormAttributeFieldsProps = {
89
fields: { id: string }[];
910
remove: (index: number) => void;
11+
disabled?: boolean;
1012
};
1113

12-
const RoomFormAttributeFields = ({ fields, remove }: RoomFormAttributeFieldsProps) => {
14+
const RoomFormAttributeFields = ({ fields, remove, disabled = false }: RoomFormAttributeFieldsProps) => {
1315
const { t } = useTranslation();
16+
const isExternalAttributeStore = useIsExternalAttributeStore();
1417

1518
const { data: attributeList, isLoading } = useAttributeList();
1619

1720
if (isLoading || !attributeList) {
1821
return <InputBoxSkeleton />;
1922
}
2023

21-
return fields.map((field, index) => (
22-
<Field key={field.id}>
23-
<FieldLabel id={field.id} required={index === 0}>
24-
{t('Attribute')}
25-
</FieldLabel>
26-
<RoomFormAttributeField
27-
labelId={field.id}
28-
attributeList={attributeList.attributes}
29-
required={index === 0}
30-
onRemove={() => {
31-
remove(index);
32-
}}
33-
index={index}
34-
/>
35-
</Field>
36-
));
24+
return (
25+
<>
26+
{isExternalAttributeStore && (
27+
<Box mbe={8} color='annotation' fontSize='p2'>
28+
{t('ABAC_Picker_External_Store_Helper')}
29+
</Box>
30+
)}
31+
{fields.map((field, index) => (
32+
<Field key={field.id}>
33+
<FieldLabel id={field.id} required={index === 0}>
34+
{t('Attribute')}
35+
</FieldLabel>
36+
<RoomFormAttributeField
37+
labelId={field.id}
38+
attributeList={attributeList.attributes}
39+
required={index === 0}
40+
onRemove={() => {
41+
remove(index);
42+
}}
43+
index={index}
44+
disabled={disabled}
45+
/>
46+
</Field>
47+
))}
48+
</>
49+
);
3750
};
3851

3952
export default RoomFormAttributeFields;

apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomFormAutocomplete.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ const RoomFormAutocomplete = ({ value, onSelectedRoom, ...props }: RoomFormAutoc
2828
placeholderData: keepPreviousData,
2929
select: (data) =>
3030
data.rooms
31-
.filter((room) => !room.abacAttributes || room.abacAttributes.length === 0)
31+
.filter((room) => !room.abacAttributesRedacted && (!room.abacAttributes || room.abacAttributes.length === 0))
3232
.map((room) => ({
3333
value: room._id,
3434
label: { name: room.fname || room.name },

0 commit comments

Comments
 (0)