Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
79 commits
Select commit Hold shift + click to select a range
7fcd2b9
refactor(abac): extract pure virtru identity/FQN helpers
KevLehman May 19, 2026
37493be
refactor(abac): add shared VirtruClient (transport/auth/reachability)
KevLehman May 19, 2026
cfe96d9
refactor(abac): harden VirtruClient getConfig + assert Bearer header …
KevLehman May 19, 2026
c14ca48
refactor(abac): VirtruPDP delegates transport/identity to VirtruClient
KevLehman May 19, 2026
f2f3d35
refactor(abac): remove dead VirtruPDP.updateConfig + tidy VirtruClien…
KevLehman May 19, 2026
c86025f
chore(abac): add license dep + attribute-store-external error code
KevLehman May 19, 2026
d21700d
feat(abac): define IAttributeStore + GetEntitlements types
KevLehman May 19, 2026
4137b1a
feat(abac): LocalAttributeStore (identity/no-op, zero behavior change)
KevLehman May 19, 2026
82e1924
refactor(abac): tidy LocalAttributeStore filter condition + document …
KevLehman May 19, 2026
d57679f
feat(abac): VirtruAttributeStore list/entitlements/validate (15s enti…
KevLehman May 19, 2026
5f168bf
fix(abac): evict VirtruAttributeStore entitlements cache on failure +…
KevLehman May 19, 2026
9eff6a8
feat(abac): AbacService owns VirtruClient + effectiveStore/attribute-…
KevLehman May 19, 2026
8158fd5
chore(abac): self-contained boot-ordering comment + default lastEffec…
KevLehman May 19, 2026
e6282fc
feat(abac): ABAC_Attribute_Store setting + listeners + license-up reeval
KevLehman May 19, 2026
e160284
refactor(abac): tidy attribute-store transition path + harden listene…
KevLehman May 19, 2026
a8f8420
feat(abac): VirtruAttributeStore scopeRoomsPage + assertCanModifyRoom…
KevLehman May 19, 2026
1a91915
test(abac): split assertCanModifyRoom into per-scenario cases
KevLehman May 19, 2026
bfd84b0
feat(abac): redact denied rooms in listAbacRooms via attribute store
KevLehman May 19, 2026
6adec48
feat(abac): 4-site assertCanModifyRoom + validateAssignable store swap
KevLehman May 19, 2026
4a7b5a9
test(abac): match mocked validateAssignable arity to interface
KevLehman May 19, 2026
ee83a5c
feat(abac): block catalog CRUD in virtru attribute-store mode
KevLehman May 19, 2026
67a55be
fix(abac): make isExternalAttributeStore async to match IAbacService
KevLehman May 19, 2026
ce2fa70
feat(abac): serve attribute picker from store in virtru mode
KevLehman May 19, 2026
ba97b81
feat(abac): redact abacAttributes on rooms.adminRooms.getRoom via CE …
KevLehman May 19, 2026
51054f6
fix(abac): use settings-gated isABACManagedRoom in admin-room redacti…
KevLehman May 19, 2026
cb528d4
feat(abac): add abac.attribute_store.switched audit event type + helper
KevLehman May 19, 2026
cc7aacc
feat(abac): transition-only destructive attribute wipe (license-gated…
KevLehman May 19, 2026
5eee81e
feat(abac): surface attribute_store.switched in /v1/abac/audit + Logs…
KevLehman May 19, 2026
fa4290e
feat(abac): hide Room Attributes tab in virtru attribute-store mode
KevLehman May 19, 2026
ae68b68
feat(abac): redacted-room Callout + read-only attribute section + pic…
KevLehman May 19, 2026
bf3cbd9
test(abac): assert real DOM disability in RoomFormAttributeFields dis…
KevLehman May 19, 2026
ca6954c
test(abac): mock GetEntitlements for virtru attribute store e2e
KevLehman May 19, 2026
0dc2008
test(abac): e2e coverage for virtru attribute store
KevLehman May 19, 2026
b01990a
feat(abac): show ABAC_Attribute_Store on the ABAC admin settings page
KevLehman May 20, 2026
3330995
feat(abac): symmetric attribute-store wipe with asymmetric trigger (r…
KevLehman May 20, 2026
4eb380f
refactor(abac): scope attribute-store wipe to explicit Store-setting …
KevLehman May 20, 2026
d84182e
feat(abac): cascade ABAC_Attribute_Store=local when ABAC_PDP_Type set…
KevLehman May 20, 2026
d59e19c
refactor(abac): rename audit event key to dot-separated abac.attribut…
KevLehman May 20, 2026
4b8f076
move client to folder
KevLehman May 20, 2026
074dc1a
types
KevLehman May 20, 2026
a5bf181
minor improvements
KevLehman May 20, 2026
cfb6b66
better error
KevLehman May 20, 2026
ab23858
one audit less
KevLehman May 20, 2026
435d026
better caching
KevLehman May 20, 2026
ccc6d1c
log
KevLehman May 20, 2026
966e50c
log malformed
KevLehman May 20, 2026
349dc2f
nits
KevLehman May 20, 2026
dbdd618
license
KevLehman May 20, 2026
b8d0fe6
fix(abac): seed hasAbacLicense from License service on start
KevLehman May 21, 2026
e4654f0
fix(abac): refresh license state on reevaluateAttributeStore
KevLehman May 21, 2026
05b491f
fix(abac): resolve attribute store from a live license check
KevLehman May 21, 2026
be0c45d
chore(ci): propagate LOG_LEVEL to microservices
KevLehman May 21, 2026
3e70d6e
fix(abac): treat failed decision calls as PDP-unavailable; fix e2e setup
KevLehman May 21, 2026
660e177
add validation to remove action
KevLehman May 21, 2026
e7096fd
simplify
KevLehman May 21, 2026
f13a483
remove old license tests
KevLehman May 22, 2026
407b443
imp
KevLehman May 22, 2026
2e7ae27
store selection
KevLehman May 22, 2026
d3e0fdd
tests
KevLehman May 22, 2026
0a83970
wrong test
KevLehman May 22, 2026
2fae64e
log
KevLehman May 22, 2026
923468c
sec
KevLehman May 25, 2026
86ca41a
store transition
KevLehman May 25, 2026
4d7a5d9
Update index.ts
KevLehman May 25, 2026
ce6431b
fixes:
KevLehman May 27, 2026
7bd254e
fix tests
KevLehman May 27, 2026
7807d5e
staleTime 0 when using an external store
KevLehman May 27, 2026
7bb6e5a
tests
KevLehman May 27, 2026
28e6420
lint
KevLehman May 27, 2026
1b1f544
scope room list
KevLehman May 28, 2026
40c353a
remove superfluous tests
KevLehman May 28, 2026
419f6b5
unify
KevLehman May 28, 2026
a6bc7c2
merge hook
KevLehman May 28, 2026
82c14a4
simplify a lil bit
KevLehman May 28, 2026
0f9582d
turbo
KevLehman May 29, 2026
4ab2119
feat(abac): bypass-abac-store-validation permission (#40738)
KevLehman May 29, 2026
12a5736
use hook
KevLehman Jun 1, 2026
b370b89
Create khaki-gifts-follow.md
KevLehman Jun 1, 2026
7e3fa4d
use hook
KevLehman Jun 1, 2026
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
13 changes: 13 additions & 0 deletions .changeset/khaki-gifts-follow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
"@rocket.chat/meteor": minor
"@rocket.chat/abac": minor
---

Allows using Virtru as the attribute store for ABAC decisions.

### Important

- When using virtru as the store, the internal attribute store is disabled.
- 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.
- Users are only allowed to see & edit rooms they have access to. Access decision is evaluated on Virtru
- 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.
19 changes: 14 additions & 5 deletions apps/meteor/app/api/server/lib/rooms.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import type { IRoom, ISubscription, RoomAdminFieldsType, RoomType } from '@rocket.chat/core-typings';
import type { IRoom, IRoomAbacRedaction, ISubscription, RoomAdminFieldsType, RoomType } from '@rocket.chat/core-typings';
import { Rooms, Subscriptions } from '@rocket.chat/models';
import type { FindOptions, Sort } from 'mongodb';

import { scopeAdminRoomsForAbac } from './scopeAdminRoomsForAbac';
import { adminFields } from '../../../../lib/rooms/adminFields';
import { hasAtLeastOnePermissionAsync, hasPermissionAsync } from '../../../authorization/server/functions/hasPermission';
import { stripABACManagedFieldsForAdmin } from '../../../authorization/server/lib/isABACManagedRoom';
Expand All @@ -17,7 +18,7 @@ export async function findAdminRooms({
types: Array<RoomType | 'discussions' | 'teams'>;
pagination: { offset: number; count: number; sort: Sort };
}): Promise<{
rooms: IRoom[];
rooms: Array<Pick<IRoom, RoomAdminFieldsType> & IRoomAbacRedaction>;
count: number;
offset: number;
total: number;
Expand Down Expand Up @@ -49,14 +50,20 @@ export async function findAdminRooms({
]);

return {
rooms,
rooms: await scopeAdminRoomsForAbac(rooms, uid),
Comment thread
KevLehman marked this conversation as resolved.
count: rooms.length,
offset,
total,
};
}

export async function findAdminRoom({ uid, rid }: { uid: string; rid: string }): Promise<Pick<IRoom, RoomAdminFieldsType> | null> {
export async function findAdminRoom({
uid,
rid,
}: {
uid: string;
rid: string;
}): Promise<(Pick<IRoom, RoomAdminFieldsType> & IRoomAbacRedaction) | null> {
if (!(await hasPermissionAsync(uid, 'view-room-administration'))) {
throw new Error('error-not-authorized');
}
Expand All @@ -65,7 +72,9 @@ export async function findAdminRoom({ uid, rid }: { uid: string; rid: string }):
if (!room) {
return null;
}
return stripABACManagedFieldsForAdmin(room);

const [scoped] = await scopeAdminRoomsForAbac([stripABACManagedFieldsForAdmin(room)], uid);
return scoped ?? null;
}

export async function findChannelAndPrivateAutocomplete({ uid, selector }: { uid: string; selector: { name: string } }): Promise<{
Expand Down
7 changes: 7 additions & 0 deletions apps/meteor/app/api/server/lib/scopeAdminRoomsForAbac.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import type { IRoom, IRoomAbacRedaction, RoomAdminFieldsType } from '@rocket.chat/core-typings';
import { makeFunction } from '@rocket.chat/patch-injection';

export const scopeAdminRoomsForAbac = makeFunction(
async (rooms: Pick<IRoom, RoomAdminFieldsType>[], _uid: string): Promise<Array<Pick<IRoom, RoomAdminFieldsType> & IRoomAbacRedaction>> =>
rooms,
);
26 changes: 20 additions & 6 deletions apps/meteor/app/api/server/v1/rooms.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { FederationMatrix, MeteorError, Team } from '@rocket.chat/core-services';
import {
type IRoom,
type IRoomAbacRedaction,
type IUpload,
type RequiredField,
type RoomAdminFieldsType,
Expand Down Expand Up @@ -89,6 +90,7 @@ import {
findChannelAndPrivateAutocompleteWithPagination,
findRoomsAvailableForTeams,
} from '../lib/rooms';
import { scopeAdminRoomsForAbac } from '../lib/scopeAdminRoomsForAbac';

export async function findRoomByIdOrName({
params,
Expand Down Expand Up @@ -712,10 +714,15 @@ API.v1.get(
authRequired: true,
query: isRoomsAdminRoomsProps,
response: {
200: ajv.compile<{ rooms: IRoom[]; count: number; offset: number; total: number }>({
200: ajv.compile<{
rooms: Array<Pick<IRoom, RoomAdminFieldsType> & IRoomAbacRedaction>;
count: number;
offset: number;
total: number;
}>({
type: 'object',
properties: {
rooms: { type: 'array', items: { type: 'object' } }, // relaxed: IRoom with admin fields
rooms: { type: 'array', items: { type: 'object' } }, // relaxed: IRoom with admin fields + optional ABAC redaction
count: { type: 'number' },
offset: { type: 'number' },
total: { type: 'number' },
Expand Down Expand Up @@ -785,10 +792,17 @@ API.v1.get(
authRequired: true,
query: isRoomsAdminRoomsGetRoomProps,
response: {
200: ajv.compile<Pick<IRoom, RoomAdminFieldsType>>({
200: ajv.compile<Pick<IRoom, RoomAdminFieldsType> & IRoomAbacRedaction>({
allOf: [
{ $ref: '#/components/schemas/IRoomAdmin' },
{ type: 'object', properties: { success: { type: 'boolean', enum: [true] } }, required: ['success'] },
{
type: 'object',
properties: {
success: { type: 'boolean', enum: [true] },
abacAttributesRedacted: { type: 'boolean' },
},
required: ['success'],
},
],
}),
400: validateBadRequestErrorResponse,
Expand Down Expand Up @@ -1447,7 +1461,7 @@ export const roomEndpoints = API.v1
401: validateUnauthorizedErrorResponse,
403: validateUnauthorizedErrorResponse,
200: ajv.compile<{
rooms: IRoom[];
rooms: Array<Pick<IRoom, RoomAdminFieldsType> & IRoomAbacRedaction>;
count: number;
offset: number;
total: number;
Expand Down Expand Up @@ -1485,7 +1499,7 @@ export const roomEndpoints = API.v1
const [rooms, total] = await Promise.all([cursor.map(stripABACManagedFieldsForAdmin).toArray(), totalCount]);

return API.v1.success({
rooms,
rooms: await scopeAdminRoomsForAbac(rooms, this.userId),
count: rooms.length,
offset,
total,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,14 @@ const LogsPage = () => {
.join(', ') ?? t('Empty'),
room: event.data?.find((item) => item.key === 'room')?.value?.name ?? '',
};
case 'abac.attribute.store.switched':
return {
...eventInfo,
element: t('ABAC_Attribute_Store'),
action: t('ABAC_Attribute_Store_Switched'),
name: `${event.data?.find((item) => item.key === 'from')?.value} -> ${event.data?.find((item) => item.key === 'to')?.value}`,
room: t('ABAC_Rooms_Affected', { count: Number(event.data?.find((item) => item.key === 'roomsAffected')?.value ?? 0) }),
};
default:
return null;
}
Expand Down
18 changes: 13 additions & 5 deletions apps/meteor/client/views/admin/ABAC/ABACRoomsTab/RoomForm.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Box, Field, FieldLabel, FieldRow, FieldError, ButtonGroup, Button, ContextualbarFooter } from '@rocket.chat/fuselage';
import { Box, Callout, Field, FieldLabel, FieldRow, FieldError, ButtonGroup, Button, ContextualbarFooter } from '@rocket.chat/fuselage';
import { useEffectEvent } from '@rocket.chat/fuselage-hooks';
import { GenericModal, ContextualbarScrollableContent } from '@rocket.chat/ui-client';
import { useSetModal } from '@rocket.chat/ui-contexts';
Expand All @@ -16,14 +16,15 @@ type RoomFormProps = {
onSave: (data: RoomFormData) => void;
roomInfo?: { rid: string; name: string };
setSelectedRoomLabel: Dispatch<SetStateAction<string>>;
redacted?: boolean;
};

export type RoomFormData = {
room: string;
attributes: { key: string; values: string[] }[];
};

const RoomForm = ({ onClose, onSave, roomInfo, setSelectedRoomLabel }: RoomFormProps) => {
const RoomForm = ({ onClose, onSave, roomInfo, setSelectedRoomLabel, redacted = false }: RoomFormProps) => {
const {
control,
handleSubmit,
Expand Down Expand Up @@ -110,10 +111,17 @@ const RoomForm = ({ onClose, onSave, roomInfo, setSelectedRoomLabel }: RoomFormP
</FieldError>
)}
</Field>
<RoomFormAttributeFields fields={fields} remove={remove} />
{redacted && (
<Box mbe={16}>
<Callout type='warning' title={t('ABAC_Attributes_Redacted')}>
{t('ABAC_Attributes_Redacted_Description')}
</Callout>
</Box>
)}
<RoomFormAttributeFields fields={fields} remove={remove} disabled={redacted} />
<Button
w='full'
disabled={fields.length >= 10}
disabled={redacted || fields.length >= 10}
onClick={() => {
append({ key: '', values: [] });
}}
Expand All @@ -124,7 +132,7 @@ const RoomForm = ({ onClose, onSave, roomInfo, setSelectedRoomLabel }: RoomFormP
<ContextualbarFooter>
<ButtonGroup stretch>
<Button onClick={onClose}>{t('Cancel')}</Button>
<Button type='submit' form={formId} disabled={!isValid || !isDirty} primary>
<Button type='submit' form={formId} disabled={redacted || !isValid || !isDirty} primary>
{t('Save')}
</Button>
</ButtonGroup>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,17 @@ type ABACAttributeAutocompleteProps = {
index: number;
attributeList: { value: string; label: string; attributeValues: string[] }[];
required?: boolean;
disabled?: boolean;
};

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

const { control, getValues, resetField } = useFormContext<RoomFormData>();
Expand Down Expand Up @@ -71,6 +79,7 @@ const RoomFormAttributeField = ({ labelId, onRemove, index, attributeList, requi
mbe={4}
error={keyFieldState.error?.message}
withTruncatedText
disabled={disabled}
onChange={(value) => {
keyField.onChange(value);
resetField(`attributes.${index}.values`, { defaultValue: [] });
Expand All @@ -94,6 +103,7 @@ const RoomFormAttributeField = ({ labelId, onRemove, index, attributeList, requi
options={valueOptions}
placeholder={t('ABAC_Select_Attribute_Values')}
error={valuesFieldState.error?.message}
disabled={disabled}
/>
</FieldRow>
{valuesFieldState.error && (
Expand All @@ -102,7 +112,7 @@ const RoomFormAttributeField = ({ labelId, onRemove, index, attributeList, requi
</FieldError>
)}
{index !== 0 && (
<Button onClick={onRemove} title={t('Remove')} mbs={8}>
<Button onClick={onRemove} title={t('Remove')} mbs={8} disabled={disabled}>
{t('Remove')}
</Button>
)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,9 @@ const appRoot = mockAppRoot()
ABAC_Search_Attribute: 'Search attribute',
ABAC_Select_Attribute_Values: 'Select attribute values',
Remove: 'Remove',
ABAC_Picker_External_Store_Helper: 'Available attributes are limited to those you possess.',
})
.withSetting('ABAC_Attribute_Store', 'local')
.build();

const FormProviderWrapper = ({ children, defaultValues }: { children: ReactNode; defaultValues?: Partial<RoomFormData> }) => {
Expand Down Expand Up @@ -133,4 +135,37 @@ describe('RoomFormAttributeFields', () => {
expect(screen.getByText('Public')).toBeInTheDocument();
expect(screen.getByText('Internal')).toBeInTheDocument();
});

it('should render fields with disabled inputs when disabled prop is true', () => {
const fields = [{ id: 'field-1' }, { id: 'field-2' }];

render(
<FormProviderWrapper>
<RoomFormAttributeFields fields={fields} remove={mockRemove} disabled />
</FormProviderWrapper>,
{ wrapper: appRoot },
);

const attributeLabels = screen.getAllByText('Attribute');
expect(attributeLabels).toHaveLength(2);

screen.getAllByPlaceholderText('Search attribute').forEach((input) => expect(input).toBeDisabled());
screen.getAllByRole('combobox').forEach((input) => expect(input).toBeDisabled());
expect(screen.getByRole('button', { name: 'Remove' })).toBeDisabled();
});

it('should render fields without disabled state when disabled prop is false', () => {
const fields = [{ id: 'field-1' }];

render(
<FormProviderWrapper>
<RoomFormAttributeFields fields={fields} remove={mockRemove} disabled={false} />
</FormProviderWrapper>,
{ wrapper: appRoot },
);

expect(screen.getAllByText('Attribute')).toHaveLength(1);
screen.getAllByPlaceholderText('Search attribute').forEach((input) => expect(input).not.toBeDisabled());
screen.getAllByRole('combobox').forEach((input) => expect(input).not.toBeDisabled());
});
});
Original file line number Diff line number Diff line change
@@ -1,39 +1,52 @@
import { Field, FieldLabel, InputBoxSkeleton } from '@rocket.chat/fuselage';
import { Box, Field, FieldLabel, InputBoxSkeleton } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';

import RoomFormAttributeField from './RoomFormAttributeField';
import { useAttributeList } from '../hooks/useAttributeList';
import { useIsExternalAttributeStore } from '../hooks/useIsExternalAttributeStore';

type RoomFormAttributeFieldsProps = {
fields: { id: string }[];
remove: (index: number) => void;
disabled?: boolean;
};

const RoomFormAttributeFields = ({ fields, remove }: RoomFormAttributeFieldsProps) => {
const RoomFormAttributeFields = ({ fields, remove, disabled = false }: RoomFormAttributeFieldsProps) => {
const { t } = useTranslation();
const isExternalAttributeStore = useIsExternalAttributeStore();

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

if (isLoading || !attributeList) {
return <InputBoxSkeleton />;
}

return fields.map((field, index) => (
<Field key={field.id}>
<FieldLabel id={field.id} required={index === 0}>
{t('Attribute')}
</FieldLabel>
<RoomFormAttributeField
labelId={field.id}
attributeList={attributeList.attributes}
required={index === 0}
onRemove={() => {
remove(index);
}}
index={index}
/>
</Field>
));
return (
<>
{isExternalAttributeStore && (
<Box mbe={8} color='annotation' fontSize='p2'>
{t('ABAC_Picker_External_Store_Helper')}
</Box>
Comment thread
KevLehman marked this conversation as resolved.
)}
{fields.map((field, index) => (
<Field key={field.id}>
<FieldLabel id={field.id} required={index === 0}>
{t('Attribute')}
</FieldLabel>
<RoomFormAttributeField
labelId={field.id}
attributeList={attributeList.attributes}
required={index === 0}
onRemove={() => {
remove(index);
}}
index={index}
disabled={disabled}
/>
</Field>
))}
</>
);
};

export default RoomFormAttributeFields;
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ const RoomFormAutocomplete = ({ value, onSelectedRoom, ...props }: RoomFormAutoc
placeholderData: keepPreviousData,
select: (data) =>
data.rooms
.filter((room) => !room.abacAttributes || room.abacAttributes.length === 0)
.filter((room) => !room.abacAttributesRedacted && (!room.abacAttributes || room.abacAttributes.length === 0))
.map((room) => ({
value: room._id,
label: { name: room.fname || room.name },
Expand Down
Loading
Loading