From a4d78385bcbe2b887aaefdd30759978e3d1256c1 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Thu, 20 Nov 2025 10:21:21 -0600 Subject: [PATCH 01/15] server events on services --- packages/models/src/index.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/models/src/index.ts b/packages/models/src/index.ts index 1920a02d62e5a..27d3fa41e81be 100644 --- a/packages/models/src/index.ts +++ b/packages/models/src/index.ts @@ -120,6 +120,7 @@ import { UsersRaw, UsersSessionsRaw, AbacAttributesRaw, + ServerEventsRaw, } from './modelClasses'; import { proxify, registerModel } from './proxify'; @@ -264,6 +265,7 @@ export function registerServiceModels(db: Db, trash?: Collection new UploadsRaw(db)); registerModel('ILivechatVisitorsModel', () => new LivechatVisitorsRaw(db)); registerModel('IAbacAttributesModel', () => new AbacAttributesRaw(db)); + registerModel('IServerEventsModel', () => new ServerEventsRaw(db)); } if (!dbWatchersDisabled) { From 145d408dafed428d8230d674fd435ae0f8248022 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Thu, 20 Nov 2025 14:52:39 -0600 Subject: [PATCH 02/15] audit --- apps/meteor/ee/server/api/abac/index.ts | 59 ++++-- ee/packages/abac/src/audit.ts | 185 ++++++++++++++++++ .../abac/src/can-access-object.spec.ts | 3 + ee/packages/abac/src/helper.ts | 44 +++++ ee/packages/abac/src/index.spec.ts | 165 ++++++++++------ ee/packages/abac/src/index.ts | 65 +++--- .../abac/src/user-auto-removal.spec.ts | 34 ++-- packages/core-services/src/index.ts | 2 +- .../core-services/src/types/IAbacService.ts | 52 ++--- 9 files changed, 463 insertions(+), 146 deletions(-) create mode 100644 ee/packages/abac/src/audit.ts diff --git a/apps/meteor/ee/server/api/abac/index.ts b/apps/meteor/ee/server/api/abac/index.ts index 3fc89273e80ce..7ccb1a557ce6f 100644 --- a/apps/meteor/ee/server/api/abac/index.ts +++ b/apps/meteor/ee/server/api/abac/index.ts @@ -1,4 +1,6 @@ import { Abac } from '@rocket.chat/core-services'; +import type { AbacActor } from '@rocket.chat/core-services'; +import type { IUser } from '@rocket.chat/core-typings'; import { Users } from '@rocket.chat/models'; import { validateUnauthorizedErrorResponse } from '@rocket.chat/rest-typings/src/v1/Ajv'; @@ -24,6 +26,15 @@ import { getPaginationItems } from '../../../../app/api/server/helpers/getPagina import { settings } from '../../../../app/settings/server'; import { LDAPEE } from '../../sdk'; +const getActorFromUser = (user?: IUser | null): AbacActor | undefined => + user?._id + ? { + _id: user._id, + username: user.username, + name: user.name, + } + : undefined; + const abacEndpoints = API.v1 .post( 'abac/rooms/:rid/attributes', @@ -49,7 +60,7 @@ const abacEndpoints = API.v1 // This is a replace-all operation // IF you need fine grained, use the other endpoints for removing, editing & adding single attributes - await Abac.setRoomAbacAttributes(rid, attributes); + await Abac.setRoomAbacAttributes(rid, attributes, getActorFromUser(this.user)); return API.v1.success(); }, ) @@ -71,7 +82,7 @@ const abacEndpoints = API.v1 // We don't need to check if ABAC is enabled to clear attributes // Since we're always allowing this operation // license check is also not required - await Abac.setRoomAbacAttributes(rid, {}); + await Abac.setRoomAbacAttributes(rid, {}, getActorFromUser(this.user)); return API.v1.success(); }, ) @@ -98,7 +109,7 @@ const abacEndpoints = API.v1 throw new Error('error-abac-not-enabled'); } - await Abac.addRoomAbacAttributeByKey(rid, key, values); + await Abac.addRoomAbacAttributeByKey(rid, key, values, getActorFromUser(this.user)); return API.v1.success(); }, ) @@ -125,7 +136,7 @@ const abacEndpoints = API.v1 throw new Error('error-abac-not-enabled'); } - await Abac.replaceRoomAbacAttributeByKey(rid, key, values); + await Abac.replaceRoomAbacAttributeByKey(rid, key, values, getActorFromUser(this.user)); return API.v1.success(); }, ) @@ -145,7 +156,7 @@ const abacEndpoints = API.v1 async function action() { const { rid, key } = this.urlParams; - await Abac.removeRoomAbacAttribute(rid, key); + await Abac.removeRoomAbacAttribute(rid, key, getActorFromUser(this.user)); return API.v1.success(); }, ) @@ -169,12 +180,15 @@ const abacEndpoints = API.v1 const { key, values } = this.queryParams; return API.v1.success( - await Abac.listAbacAttributes({ - key, - values, - offset, - count, - }), + await Abac.listAbacAttributes( + { + key, + values, + offset, + count, + }, + getActorFromUser(this.user), + ), ); }, ) @@ -224,7 +238,7 @@ const abacEndpoints = API.v1 throw new Error('error-abac-not-enabled'); } - await Abac.addAbacAttribute(this.bodyParams); + await Abac.addAbacAttribute(this.bodyParams, getActorFromUser(this.user)); return API.v1.success(); }, ) @@ -249,7 +263,7 @@ const abacEndpoints = API.v1 throw new Error('error-abac-not-enabled'); } - await Abac.updateAbacAttributeById(_id, this.bodyParams); + await Abac.updateAbacAttributeById(_id, this.bodyParams, getActorFromUser(this.user)); return API.v1.success(); }, ) @@ -268,7 +282,7 @@ const abacEndpoints = API.v1 }, async function action() { const { _id } = this.urlParams; - const result = await Abac.getAbacAttributeById(_id); + const result = await Abac.getAbacAttributeById(_id, getActorFromUser(this.user)); return API.v1.success(result); }, ) @@ -287,7 +301,7 @@ const abacEndpoints = API.v1 }, async function action() { const { _id } = this.urlParams; - await Abac.deleteAbacAttributeById(_id); + await Abac.deleteAbacAttributeById(_id, getActorFromUser(this.user)); return API.v1.success(); }, ) @@ -327,12 +341,15 @@ const abacEndpoints = API.v1 const { offset, count } = await getPaginationItems(this.queryParams as Record); const { filter, filterType } = this.queryParams; - const result = await Abac.listAbacRooms({ - offset, - count, - filter, - filterType, - }); + const result = await Abac.listAbacRooms( + { + offset, + count, + filter, + filterType, + }, + getActorFromUser(this.user), + ); return API.v1.success(result); }, diff --git a/ee/packages/abac/src/audit.ts b/ee/packages/abac/src/audit.ts new file mode 100644 index 0000000000000..835c06d958e1b --- /dev/null +++ b/ee/packages/abac/src/audit.ts @@ -0,0 +1,185 @@ +import type { AbacActor } from '@rocket.chat/core-services'; +import type { + ExtractDataToParams, + IAbacAttributeDefinition, + IAuditServerActor, + IAuditServerEventType, + IRoom, + IServerEvents, + IUser, +} from '@rocket.chat/core-typings'; +import { ServerEvents } from '@rocket.chat/models'; + +type MinimalUser = Pick; +type MinimalRoom = Pick; + +export type AbacAuditReason = 'ldap-sync' | 'room-attributes-change' | 'system' | 'api' | 'realtime-policy-eval'; + +export type AbacAttributeDefinitionChangeType = 'created' | 'updated' | 'deleted' | 'key-changed' | 'values-changed'; + +export type AbacAttributeDefinitionDiff = { + added?: string[]; + removed?: string[]; + renamedFrom?: string; +}; + +// Since user attributes can grow without limits, we're only logging the diffs +interface IServerEventAbacSubjectAttributeChanged + extends IAuditServerEventType< + { key: 'subject'; value: MinimalUser } | { key: 'reason'; value: AbacAuditReason } | { key: 'diff'; value: IAbacAttributeDefinition[] } + > { + t: 'abac.subject.attribute.changed'; +} + +interface IServerEventAbacObjectAttributeChanged + extends IAuditServerEventType< + | { key: 'room'; value: MinimalRoom } + | { key: 'reason'; value: AbacAuditReason } + | { key: 'previous'; value: IAbacAttributeDefinition[] } + | { key: 'current'; value: IAbacAttributeDefinition[] | null } + > { + t: 'abac.object.attribute.changed'; +} + +interface IServerEventAbacAttributeChanged + extends IAuditServerEventType< + | { key: 'attributeKey'; value: string } + | { key: 'reason'; value: AbacAuditReason } + | { key: 'change'; value: AbacAttributeDefinitionChangeType } + | { key: 'current'; value: IAbacAttributeDefinition | null | undefined } + | { key: 'diff'; value: IAbacAttributeDefinition | undefined } + > { + t: 'abac.attribute.changed'; +} + +interface IServerEventAbacActionPerformed + extends IAuditServerEventType< + | { key: 'action'; value: 'revoked-object-access' } + | { key: 'reason'; value: AbacAuditReason } + | { key: 'subject'; value: MinimalUser | undefined } + | { key: 'object'; value: MinimalRoom | undefined } + > { + t: 'abac.action.performed'; +} + +type ValidEvents = 'abac.subject.attribute.changed' | 'abac.object.attribute.changed' | 'abac.attribute.changed' | 'abac.action.performed'; + +declare module '@rocket.chat/core-typings' { + interface IServerEvents { + 'abac.subject.attribute.changed': IServerEventAbacSubjectAttributeChanged; + 'abac.object.attribute.changed': IServerEventAbacObjectAttributeChanged; + 'abac.attribute.changed': IServerEventAbacAttributeChanged; + 'abac.action.performed': IServerEventAbacActionPerformed; + } +} + +type EventParamsMap = { + [K in ValidEvents]: ExtractDataToParams; +}; + +type EventPayload = EventParamsMap[K]; + +export type AbacAuditEventName = ValidEvents; + +export type AbacAuditEventPayload = EventPayload; + +async function audit(event: K, payload: EventPayload, actor: IAuditServerActor): Promise { + return ServerEvents.createAuditServerEvent(event, payload, actor); +} + +export const Audit = { + attributeCreated: async (attribute: IAbacAttributeDefinition, actor: AbacActor) => { + return audit( + 'abac.attribute.changed', + { + attributeKey: attribute.key, + reason: 'api', + change: 'created', + current: attribute, + } as EventPayload<'abac.attribute.changed'>, + { type: 'user', ...(actor as any) }, + ); + }, + attributeUpdated: async (current: IAbacAttributeDefinition, diff: IAbacAttributeDefinition, actor: AbacActor) => { + return audit( + 'abac.attribute.changed', + { + attributeKey: current.key, + reason: 'api', + change: 'updated', + current, + diff, + }, + { type: 'user', ...(actor as any) }, + ); + }, + attributeDeleted: async (attribute: IAbacAttributeDefinition, actor: AbacActor) => { + return audit( + 'abac.attribute.changed', + { + attributeKey: attribute.key, + reason: 'api', + change: 'deleted', + current: null, + diff: attribute, + }, + { type: 'user', ...(actor as any) }, + ); + }, + objectAttributeChanged: async ( + minimalRoom: MinimalRoom, + previous: IAbacAttributeDefinition[], + current: IAbacAttributeDefinition[], + actor: AbacActor, + ) => { + return audit( + 'abac.object.attribute.changed', + { + room: minimalRoom, + reason: 'api', + previous, + current, + }, + { type: 'user', ...(actor as any) }, + ); + }, + objectAttributeRemoved: async (minimalRoom: MinimalRoom, previous: IAbacAttributeDefinition[], actor: AbacActor) => { + return audit( + 'abac.object.attribute.changed', + { + room: minimalRoom, + reason: 'api', + previous, + current: null, + }, + { type: 'user', ...(actor as any) }, + ); + }, + actionPerformed: async ( + subject: MinimalUser | undefined, + object: MinimalRoom | undefined, + reason: AbacAuditReason = 'room-attributes-change', + ) => { + return audit( + 'abac.action.performed', + { + action: 'revoked-object-access', + reason, + subject, + object, + }, + { type: 'system' }, + ); + }, + subjectAttributeChanged: async (diff: IAbacAttributeDefinition[], subject: MinimalUser) => { + return audit( + 'abac.subject.attribute.changed', + { + subject, + reason: 'ldap-sync', + diff, + }, + { type: 'system' }, + ); + }, +}; diff --git a/ee/packages/abac/src/can-access-object.spec.ts b/ee/packages/abac/src/can-access-object.spec.ts index 2cffb79953d66..bea1f0ff804b4 100644 --- a/ee/packages/abac/src/can-access-object.spec.ts +++ b/ee/packages/abac/src/can-access-object.spec.ts @@ -21,6 +21,9 @@ jest.mock('@rocket.chat/models', () => ({ findOne: (...args: any[]) => mockUsersFindOne(...args), findOneById: (...args: any[]) => mockUsersFindOneById(...args), }, + ServerEvents: { + createAuditServerEvent: jest.fn(), + }, })); jest.mock('@rocket.chat/core-services', () => ({ diff --git a/ee/packages/abac/src/helper.ts b/ee/packages/abac/src/helper.ts index 283e83514f195..2edc2c8bca421 100644 --- a/ee/packages/abac/src/helper.ts +++ b/ee/packages/abac/src/helper.ts @@ -32,3 +32,47 @@ export const extractAttribute = (ldapUser: ILDAPEntry, ldapKey: string, abacKey: } return { key: abacKey, values: Array.from(valuesSet) }; }; + +export function diffAttributes(a: IAbacAttributeDefinition[] = [], b: IAbacAttributeDefinition[] = []): IAbacAttributeDefinition[] { + if (!a?.length && b?.length) { + return b; + } + + if (!b?.length) { + return []; + } + + const mapA = new Map(a.map((item) => [item.key, new Set(item.values)])); + const mapB = new Map(b.map((item) => [item.key, new Set(item.values)])); + + const diff: IAbacAttributeDefinition[] = []; + + // Check keys in A + for (const [key, valuesA] of mapA) { + const valuesB = mapB.get(key); + + if (!valuesB) { + // key removed + diff.push({ key, values: [...valuesA] }); + continue; + } + + const setA = valuesA; + const setB = valuesB; + + const changed = [...setA].some((v) => !setB.has(v)) || [...setB].some((v) => !setA.has(v)); + + if (changed) { + diff.push({ key, values: [...setB] }); + } + } + + // Check keys added in B + for (const [key, valuesB] of mapB) { + if (!mapA.has(key)) { + diff.push({ key, values: [...valuesB] }); + } + } + + return diff; +} diff --git a/ee/packages/abac/src/index.spec.ts b/ee/packages/abac/src/index.spec.ts index 6dbc49ca6ddfd..4778c5b3bbc77 100644 --- a/ee/packages/abac/src/index.spec.ts +++ b/ee/packages/abac/src/index.spec.ts @@ -2,6 +2,8 @@ import type { IAbacAttributeDefinition } from '@rocket.chat/core-typings'; import { AbacService } from './index'; +const fakeActor = { _id: 'test-user', username: 'testuser', type: 'user' }; + const mockFindOneByIdAndType = jest.fn(); const mockUpdateAbacConfigurationById = jest.fn(); const mockAbacInsertOne = jest.fn(); @@ -20,6 +22,7 @@ const mockUsersFind = jest.fn(); const mockUsersUpdateOne = jest.fn(); const mockUsersSetAbacAttributesById = jest.fn(); const mockUsersUnsetAbacAttributesById = jest.fn(); +const mockAbacFindOneAndUpdate = jest.fn(); jest.mock('@rocket.chat/models', () => ({ Rooms: { @@ -39,6 +42,7 @@ jest.mock('@rocket.chat/models', () => ({ findOneById: (...args: any[]) => mockAbacFindOne(...args), // map findOneById calls to same mock findOneByKey: (...args: any[]) => mockAbacFindOne(...args), // map findOneByKey to same mock updateOne: (...args: any[]) => mockAbacUpdateOne(...args), + findOneAndUpdate: (...args: any[]) => mockAbacFindOneAndUpdate(...args), deleteOne: (...args: any[]) => mockAbacDeleteOne(...args), removeById: (...args: any[]) => mockAbacDeleteOne(...args), find: (...args: any[]) => mockAbacFind(...args), @@ -50,6 +54,9 @@ jest.mock('@rocket.chat/models', () => ({ findOneAndUpdate: (...args: any[]) => mockUsersUpdateOne(...args), updateOne: (...args: any[]) => mockUsersUpdateOne(...args), }, + ServerEvents: { + createAuditServerEvent: jest.fn(), + }, })); // Partial mock for @rocket.chat/core-services: keep real MeteorError, override ServiceClass and Room @@ -379,33 +386,33 @@ describe('AbacService (unit)', () => { describe('addAbacAttribute', () => { it('inserts attribute when valid', async () => { const attribute = { key: 'Valid_Key-1', values: ['v1', 'v2'] }; - await service.addAbacAttribute(attribute); + await service.addAbacAttribute(attribute, fakeActor); expect(mockAbacInsertOne).toHaveBeenCalledTimes(1); expect(mockAbacInsertOne).toHaveBeenCalledWith(attribute); }); it('accepts key with spaces (no key pattern validation in service)', async () => { const attribute = { key: 'Invalid Key!', values: ['v1'] }; - await service.addAbacAttribute(attribute as any); + await service.addAbacAttribute(attribute as any, fakeActor); expect(mockAbacInsertOne).toHaveBeenCalledWith(attribute); }); it('throws error-invalid-attribute-values for empty values array', async () => { const attribute = { key: 'ValidKey', values: [] as string[] }; - await expect(service.addAbacAttribute(attribute)).rejects.toThrow('error-invalid-attribute-values'); + await expect(service.addAbacAttribute(attribute, fakeActor)).rejects.toThrow('error-invalid-attribute-values'); expect(mockAbacInsertOne).not.toHaveBeenCalled(); }); it('throws error-duplicate-attribute-key when duplicate index error occurs', async () => { const attribute = { key: 'DupKey', values: ['a'] }; mockAbacInsertOne.mockRejectedValueOnce(new Error('E11000 duplicate key error collection: abac_attributes')); - await expect(service.addAbacAttribute(attribute)).rejects.toThrow('error-duplicate-attribute-key'); + await expect(service.addAbacAttribute(attribute, fakeActor)).rejects.toThrow('error-duplicate-attribute-key'); }); it('propagates unexpected insert errors', async () => { const attribute = { key: 'OtherKey', values: ['x'] }; mockAbacInsertOne.mockRejectedValueOnce(new Error('network-failure')); - await expect(service.addAbacAttribute(attribute)).rejects.toThrow('network-failure'); + await expect(service.addAbacAttribute(attribute, fakeActor)).rejects.toThrow('network-failure'); }); describe('listAbacAttributes', () => { @@ -517,7 +524,7 @@ describe('AbacService (unit)', () => { }); it('returns early (no-op) when neither key nor values provided', async () => { - await service.updateAbacAttributeById('id1', {} as any); + await service.updateAbacAttributeById('id1', {} as any, fakeActor); expect(mockAbacFindOne).not.toHaveBeenCalled(); expect(mockAbacUpdateOne).not.toHaveBeenCalled(); expect(mockRoomsIsAbacAttributeInUse).not.toHaveBeenCalled(); @@ -525,7 +532,7 @@ describe('AbacService (unit)', () => { it('throws error-attribute-not-found when attribute does not exist', async () => { mockAbacFindOne.mockResolvedValueOnce(null); - await expect(service.updateAbacAttributeById('idMissing', { key: 'newKey' })).rejects.toThrow('error-attribute-not-found'); + await expect(service.updateAbacAttributeById('idMissing', { key: 'newKey' }, fakeActor)).rejects.toThrow('error-attribute-not-found'); expect(mockAbacFindOne).toHaveBeenCalledWith('idMissing', { projection: { key: 1, values: 1 } }); }); @@ -535,7 +542,7 @@ describe('AbacService (unit)', () => { .mockResolvedValueOnce(null); // duplicate key check mockRoomsIsAbacAttributeInUse.mockResolvedValueOnce(false); mockAbacUpdateOne.mockResolvedValueOnce({ modifiedCount: 1 }); - await service.updateAbacAttributeById('id2', { key: 'Invalid Key!' }); + await service.updateAbacAttributeById('id2', { key: 'Invalid Key!' }, fakeActor); expect(mockAbacUpdateOne).toHaveBeenCalledWith({ _id: 'id2' }, { $set: { key: 'Invalid Key!' } }); }); @@ -544,9 +551,9 @@ describe('AbacService (unit)', () => { mockRoomsIsAbacAttributeInUse.mockReset(); mockAbacUpdateOne.mockReset(); mockAbacFindOne.mockResolvedValueOnce({ _id: 'id3', key: 'Key3', values: ['x'] }); - await expect(service.updateAbacAttributeById('id3', { values: [] })).rejects.toThrow('error-invalid-attribute-values'); + await expect(service.updateAbacAttributeById('id3', { values: [] }, fakeActor)).rejects.toThrow('error-invalid-attribute-values'); expect(mockRoomsIsAbacAttributeInUse).not.toHaveBeenCalled(); - expect(mockAbacUpdateOne).not.toHaveBeenCalled(); + expect(mockAbacFindOneAndUpdate).not.toHaveBeenCalled(); }); it('throws error-attribute-in-use when key changes and old definition is in use', async () => { @@ -557,9 +564,9 @@ describe('AbacService (unit)', () => { .mockResolvedValueOnce({ _id: 'id4', key: 'Old', values: ['v1', 'v2'] }) // findOneById .mockResolvedValueOnce(null); // duplicate key check mockRoomsIsAbacAttributeInUse.mockResolvedValueOnce(true); - await expect(service.updateAbacAttributeById('id4', { key: 'New' })).rejects.toThrow('error-attribute-in-use'); + await expect(service.updateAbacAttributeById('id4', { key: 'New' }, fakeActor)).rejects.toThrow('error-attribute-in-use'); expect(mockRoomsIsAbacAttributeInUse).toHaveBeenCalledWith('Old', ['v1', 'v2']); - expect(mockAbacUpdateOne).not.toHaveBeenCalled(); + expect(mockAbacFindOneAndUpdate).not.toHaveBeenCalled(); }); it('updates key when changed and not in use', async () => { @@ -568,7 +575,7 @@ describe('AbacService (unit)', () => { .mockResolvedValueOnce(null); // duplicate key check mockRoomsIsAbacAttributeInUse.mockResolvedValueOnce(false); mockAbacUpdateOne.mockResolvedValueOnce({ modifiedCount: 1 }); - await service.updateAbacAttributeById('id5', { key: 'NewKey' }); + await service.updateAbacAttributeById('id5', { key: 'NewKey' }, fakeActor); expect(mockAbacUpdateOne).toHaveBeenCalledWith({ _id: 'id5' }, { $set: { key: 'NewKey' } }); }); @@ -578,23 +585,23 @@ describe('AbacService (unit)', () => { mockAbacUpdateOne.mockReset(); mockAbacFindOne.mockResolvedValueOnce({ _id: 'id6', key: 'Attr', values: ['a', 'b', 'c'] }); mockRoomsIsAbacAttributeInUse.mockResolvedValueOnce(true); // removed value in use - await expect(service.updateAbacAttributeById('id6', { values: ['a', 'c'] })).rejects.toThrow('error-attribute-in-use'); + await expect(service.updateAbacAttributeById('id6', { values: ['a', 'c'] }, fakeActor)).rejects.toThrow('error-attribute-in-use'); expect(mockRoomsIsAbacAttributeInUse).toHaveBeenCalledWith('Attr', ['b']); - expect(mockAbacUpdateOne).not.toHaveBeenCalled(); + expect(mockAbacFindOneAndUpdate).not.toHaveBeenCalled(); }); it('updates values when removing some that are not in use', async () => { mockAbacFindOne.mockResolvedValueOnce({ _id: 'id7', key: 'Attr', values: ['a', 'b', 'c'] }); mockRoomsIsAbacAttributeInUse.mockResolvedValueOnce(false); // removal safe mockAbacUpdateOne.mockResolvedValueOnce({ modifiedCount: 1 }); - await service.updateAbacAttributeById('id7', { values: ['a', 'c'] }); + await service.updateAbacAttributeById('id7', { values: ['a', 'c'] }, fakeActor); expect(mockAbacUpdateOne).toHaveBeenCalledWith({ _id: 'id7' }, { $set: { values: ['a', 'c'] } }); }); it('updates values when only adding (no removal) without in-use check', async () => { mockAbacFindOne.mockResolvedValueOnce({ _id: 'id8', key: 'Attr', values: ['a'] }); mockAbacUpdateOne.mockResolvedValueOnce({ modifiedCount: 1 }); - await service.updateAbacAttributeById('id8', { values: ['a', 'b'] }); + await service.updateAbacAttributeById('id8', { values: ['a', 'b'] }, fakeActor); expect(mockRoomsIsAbacAttributeInUse).not.toHaveBeenCalled(); expect(mockAbacUpdateOne).toHaveBeenCalledWith({ _id: 'id8' }, { $set: { values: ['a', 'b'] } }); }); @@ -608,7 +615,7 @@ describe('AbacService (unit)', () => { .mockResolvedValueOnce(null); // duplicate key check mockRoomsIsAbacAttributeInUse.mockResolvedValueOnce(false); mockAbacUpdateOne.mockRejectedValueOnce(new Error('E11000 duplicate key error collection')); - await expect(service.updateAbacAttributeById('id9', { key: 'NewKey' })).rejects.toThrow('error-duplicate-attribute-key'); + await expect(service.updateAbacAttributeById('id9', { key: 'NewKey' }, fakeActor)).rejects.toThrow('error-duplicate-attribute-key'); }); it('propagates unexpected update errors', async () => { @@ -620,7 +627,7 @@ describe('AbacService (unit)', () => { .mockResolvedValueOnce(null); // duplicate key check mockRoomsIsAbacAttributeInUse.mockResolvedValueOnce(false); mockAbacUpdateOne.mockRejectedValueOnce(new Error('write-failed')); - await expect(service.updateAbacAttributeById('id10', { key: 'Another' })).rejects.toThrow('write-failed'); + await expect(service.updateAbacAttributeById('id10', { key: 'Another' }, fakeActor)).rejects.toThrow('write-failed'); }); describe('deleteAbacAttributeById', () => { @@ -635,7 +642,7 @@ describe('AbacService (unit)', () => { mockRoomsIsAbacAttributeInUse.mockReset(); mockAbacDeleteOne.mockReset(); mockAbacFindOne.mockResolvedValueOnce(null); - await expect(service.deleteAbacAttributeById('missing')).rejects.toThrow('error-attribute-not-found'); + await expect(service.deleteAbacAttributeById('missing', fakeActor)).rejects.toThrow('error-attribute-not-found'); }); it('throws error-attribute-in-use when attribute is referenced by a room', async () => { @@ -644,7 +651,7 @@ describe('AbacService (unit)', () => { mockAbacDeleteOne.mockReset(); mockAbacFindOne.mockResolvedValueOnce({ _id: 'id11', key: 'KeyInUse', values: ['a', 'b'] }); mockRoomsIsAbacAttributeInUse.mockResolvedValueOnce(true); - await expect(service.deleteAbacAttributeById('id11')).rejects.toThrow('error-attribute-in-use'); + await expect(service.deleteAbacAttributeById('id11', fakeActor)).rejects.toThrow('error-attribute-in-use'); expect(mockAbacDeleteOne).not.toHaveBeenCalled(); }); @@ -655,7 +662,7 @@ describe('AbacService (unit)', () => { mockAbacFindOne.mockResolvedValueOnce({ _id: 'id12', key: 'FreeKey', values: ['x'] }); mockRoomsIsAbacAttributeInUse.mockResolvedValueOnce(false); mockAbacDeleteOne.mockResolvedValueOnce({ deletedCount: 1 }); - await service.deleteAbacAttributeById('id12'); + await service.deleteAbacAttributeById('id12', fakeActor); expect(mockAbacDeleteOne).toHaveBeenCalledWith('id12'); }); }); @@ -709,31 +716,39 @@ describe('AbacService (unit)', () => { it('throws error-room-not-found when room does not exist', async () => { mockFindOneByIdAndType.mockResolvedValueOnce(null); - await expect(service.setRoomAbacAttributes('missing', { dept: ['eng'] })).rejects.toThrow('error-room-not-found'); + await expect(service.setRoomAbacAttributes('missing', { dept: ['eng'] }, fakeActor)).rejects.toThrow('error-room-not-found'); expect(mockSetAbacAttributesById).not.toHaveBeenCalled(); }); it('throws error-cannot-convert-default-room-to-abac when room is default', async () => { mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [], default: true }); - await expect(service.setRoomAbacAttributes('r1', { dept: ['eng'] })).rejects.toThrow('error-cannot-convert-default-room-to-abac'); + await expect(service.setRoomAbacAttributes('r1', { dept: ['eng'] }, fakeActor)).rejects.toThrow( + 'error-cannot-convert-default-room-to-abac', + ); expect(mockSetAbacAttributesById).not.toHaveBeenCalled(); }); it('throws error-cannot-convert-default-room-to-abac when room is teamDefault', async () => { mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [], teamDefault: true }); - await expect(service.setRoomAbacAttributes('r1', { dept: ['eng'] })).rejects.toThrow('error-cannot-convert-default-room-to-abac'); + await expect(service.setRoomAbacAttributes('r1', { dept: ['eng'] }, fakeActor)).rejects.toThrow( + 'error-cannot-convert-default-room-to-abac', + ); expect(mockSetAbacAttributesById).not.toHaveBeenCalled(); }); it('throws error-invalid-attribute-key for invalid key format', async () => { mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [] }); - await expect(service.setRoomAbacAttributes('r1', { 'bad key': ['v'] } as any)).rejects.toThrow('error-invalid-attribute-key'); + await expect(service.setRoomAbacAttributes('r1', { 'bad key': ['v'] } as any, fakeActor)).rejects.toThrow( + 'error-invalid-attribute-key', + ); expect(mockSetAbacAttributesById).not.toHaveBeenCalled(); }); it('throws error-attribute-definition-not-found for empty value array', async () => { mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [] }); - await expect(service.setRoomAbacAttributes('r1', { dept: [] as any })).rejects.toThrow('error-attribute-definition-not-found'); + await expect(service.setRoomAbacAttributes('r1', { dept: [] as any }, fakeActor)).rejects.toThrow( + 'error-attribute-definition-not-found', + ); expect(mockSetAbacAttributesById).not.toHaveBeenCalled(); }); @@ -741,7 +756,9 @@ describe('AbacService (unit)', () => { mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [] }); // Return empty list so size mismatch triggers not-found mockAbacFind.mockReturnValueOnce({ toArray: async () => [] }); - await expect(service.setRoomAbacAttributes('r1', { dept: ['eng'] })).rejects.toThrow('error-attribute-definition-not-found'); + await expect(service.setRoomAbacAttributes('r1', { dept: ['eng'] }, fakeActor)).rejects.toThrow( + 'error-attribute-definition-not-found', + ); expect(mockSetAbacAttributesById).not.toHaveBeenCalled(); }); @@ -750,7 +767,9 @@ describe('AbacService (unit)', () => { mockAbacFind.mockReturnValueOnce({ toArray: async () => [{ key: 'dept', values: ['eng'] }], // 'sales' not allowed }); - await expect(service.setRoomAbacAttributes('r1', { dept: ['eng', 'sales'] })).rejects.toThrow('error-invalid-attribute-values'); + await expect(service.setRoomAbacAttributes('r1', { dept: ['eng', 'sales'] }, fakeActor)).rejects.toThrow( + 'error-invalid-attribute-values', + ); expect(mockSetAbacAttributesById).not.toHaveBeenCalled(); }); @@ -760,7 +779,7 @@ describe('AbacService (unit)', () => { toArray: async () => [{ key: 'dept', values: ['eng', 'sales'] }], }); - await service.setRoomAbacAttributes('r1', { dept: ['eng', 'eng', 'sales'] }); + await service.setRoomAbacAttributes('r1', { dept: ['eng', 'eng', 'sales'] }, fakeActor); expect(mockSetAbacAttributesById).toHaveBeenCalledWith('r1', [{ key: 'dept', values: ['eng', 'eng', 'sales'] }]); expect((service as any).onRoomAttributesChanged).toHaveBeenCalledWith(expect.objectContaining({ _id: 'r1' }), [ @@ -775,7 +794,7 @@ describe('AbacService (unit)', () => { toArray: async () => [{ key: 'dept', values: ['eng', 'sales'] }], }); - await service.setRoomAbacAttributes('r1', { dept: ['eng'] }); // removing 'sales' + await service.setRoomAbacAttributes('r1', { dept: ['eng'] }, fakeActor); // removing 'sales' expect((service as any).onRoomAttributesChanged).not.toHaveBeenCalled(); expect(mockSetAbacAttributesById).toHaveBeenCalledWith('r1', [{ key: 'dept', values: ['eng'] }]); @@ -788,7 +807,7 @@ describe('AbacService (unit)', () => { toArray: async () => [{ key: 'dept', values: ['eng', 'sales'] }], }); - await service.setRoomAbacAttributes('r1', { dept: ['eng', 'sales'] }); // adding sales + await service.setRoomAbacAttributes('r1', { dept: ['eng', 'sales'] }, fakeActor); // adding sales expect((service as any).onRoomAttributesChanged).toHaveBeenCalledWith(expect.objectContaining({ _id: 'r1' }), [ { key: 'dept', values: ['eng', 'sales'] }, @@ -848,19 +867,19 @@ describe('AbacService (unit)', () => { it('throws error-room-not-found if room missing', async () => { mockFindOneByIdAndType.mockResolvedValueOnce(null); - await expect(service.updateRoomAbacAttributeValues('missing', 'dept', ['eng'])).rejects.toThrow('error-room-not-found'); + await expect(service.updateRoomAbacAttributeValues('missing', 'dept', ['eng'], fakeActor)).rejects.toThrow('error-room-not-found'); }); it('throws error-cannot-convert-default-room-to-abac when room is default', async () => { mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [], default: true }); - await expect(service.updateRoomAbacAttributeValues('r1', 'dept', ['eng'])).rejects.toThrow( + await expect(service.updateRoomAbacAttributeValues('r1', 'dept', ['eng'], fakeActor)).rejects.toThrow( 'error-cannot-convert-default-room-to-abac', ); }); it('throws error-cannot-convert-default-room-to-abac when room is teamDefault', async () => { mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [], teamDefault: true }); - await expect(service.updateRoomAbacAttributeValues('r1', 'dept', ['eng'])).rejects.toThrow( + await expect(service.updateRoomAbacAttributeValues('r1', 'dept', ['eng'], fakeActor)).rejects.toThrow( 'error-cannot-convert-default-room-to-abac', ); }); @@ -868,26 +887,28 @@ describe('AbacService (unit)', () => { it('throws error-invalid-attribute-values if adding new key exceeds max attributes', async () => { const existing = Array.from({ length: 10 }, (_, i) => ({ key: `k${i}`, values: ['x'] })); mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: existing }); - await expect(service.updateRoomAbacAttributeValues('r1', 'newKey', ['val'])).rejects.toThrow('error-invalid-attribute-values'); + await expect(service.updateRoomAbacAttributeValues('r1', 'newKey', ['val'], fakeActor)).rejects.toThrow( + 'error-invalid-attribute-values', + ); }); it('adds new key using updateSingleAbacAttributeValuesById when within limit', async () => { mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [{ key: 'other', values: ['x'] }] }); - await service.updateRoomAbacAttributeValues('r1', 'dept', ['eng']); + await service.updateRoomAbacAttributeValues('r1', 'dept', ['eng'], fakeActor); expect(mockUpdateSingleAbacAttributeValuesById).toHaveBeenCalledWith('r1', 'dept', ['eng']); expect(mockUpdateAbacAttributeValuesArrayFilteredById).not.toHaveBeenCalled(); }); it('does nothing when values array is identical (no update, no hook)', async () => { mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [{ key: 'dept', values: ['eng', 'sales'] }] }); - await service.updateRoomAbacAttributeValues('r1', 'dept', ['eng', 'sales']); + await service.updateRoomAbacAttributeValues('r1', 'dept', ['eng', 'sales'], fakeActor); expect(mockUpdateAbacAttributeValuesArrayFilteredById).not.toHaveBeenCalled(); expect((service as any).onRoomAttributesChanged).not.toHaveBeenCalled(); }); it('updates existing key (addition only) and triggers hook when a value is added', async () => { mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [{ key: 'dept', values: ['eng'] }] }); - await service.updateRoomAbacAttributeValues('r1', 'dept', ['eng', 'sales']); + await service.updateRoomAbacAttributeValues('r1', 'dept', ['eng', 'sales'], fakeActor); expect(mockUpdateAbacAttributeValuesArrayFilteredById).toHaveBeenCalledWith('r1', 'dept', ['eng', 'sales']); expect((service as any).onRoomAttributesChanged).toHaveBeenCalledWith(expect.objectContaining({ _id: 'r1' }), [ { key: 'dept', values: ['eng', 'sales'] }, @@ -897,7 +918,7 @@ describe('AbacService (unit)', () => { it('updates existing key and does NOT trigger hook when a value is removed', async () => { // Existing attribute loses one value; hook should NOT fire per new behavior mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [{ key: 'dept', values: ['eng', 'sales'] }] }); - await service.updateRoomAbacAttributeValues('r1', 'dept', ['eng']); + await service.updateRoomAbacAttributeValues('r1', 'dept', ['eng'], fakeActor); expect(mockUpdateAbacAttributeValuesArrayFilteredById).toHaveBeenCalledWith('r1', 'dept', ['eng']); expect((service as any).onRoomAttributesChanged).not.toHaveBeenCalled(); }); @@ -905,7 +926,9 @@ describe('AbacService (unit)', () => { it('validates against global definitions (invalid value)', async () => { mockAbacFind.mockReturnValueOnce({ toArray: async () => [{ key: 'dept', values: ['eng'] }] }); mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [] }); - await expect(service.updateRoomAbacAttributeValues('r1', 'dept', ['eng', 'sales'])).rejects.toThrow('error-invalid-attribute-values'); + await expect(service.updateRoomAbacAttributeValues('r1', 'dept', ['eng', 'sales'], fakeActor)).rejects.toThrow( + 'error-invalid-attribute-values', + ); }); }); @@ -918,25 +941,29 @@ describe('AbacService (unit)', () => { it('throws error-room-not-found when room does not exist', async () => { mockFindOneByIdAndType.mockResolvedValueOnce(null); - await expect((service as any).removeRoomAbacAttribute('missing', 'dept')).rejects.toThrow('error-room-not-found'); + await expect((service as any).removeRoomAbacAttribute('missing', 'dept', fakeActor)).rejects.toThrow('error-room-not-found'); expect(mockRemoveAbacAttributeByRoomIdAndKey).not.toHaveBeenCalled(); }); it('throws error-cannot-convert-default-room-to-abac when room is default', async () => { mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [], default: true }); - await expect((service as any).removeRoomAbacAttribute('r1', 'dept')).rejects.toThrow('error-cannot-convert-default-room-to-abac'); + await expect((service as any).removeRoomAbacAttribute('r1', 'dept', fakeActor)).rejects.toThrow( + 'error-cannot-convert-default-room-to-abac', + ); expect(mockRemoveAbacAttributeByRoomIdAndKey).not.toHaveBeenCalled(); }); it('throws error-cannot-convert-default-room-to-abac when room is teamDefault', async () => { mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [], teamDefault: true }); - await expect((service as any).removeRoomAbacAttribute('r1', 'dept')).rejects.toThrow('error-cannot-convert-default-room-to-abac'); + await expect((service as any).removeRoomAbacAttribute('r1', 'dept', fakeActor)).rejects.toThrow( + 'error-cannot-convert-default-room-to-abac', + ); expect(mockRemoveAbacAttributeByRoomIdAndKey).not.toHaveBeenCalled(); }); it('returns early (no update, no hook) when attribute key not present', async () => { mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [{ key: 'other', values: ['x'] }] }); - await (service as any).removeRoomAbacAttribute('r1', 'dept'); + await (service as any).removeRoomAbacAttribute('r1', 'dept', fakeActor); expect(mockRemoveAbacAttributeByRoomIdAndKey).not.toHaveBeenCalled(); expect((service as any).onRoomAttributesChanged).not.toHaveBeenCalled(); }); @@ -948,7 +975,7 @@ describe('AbacService (unit)', () => { { key: 'other', values: ['x'] }, ]; mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: existing }); - await (service as any).removeRoomAbacAttribute('r1', 'dept'); + await (service as any).removeRoomAbacAttribute('r1', 'dept', fakeActor); expect(mockRemoveAbacAttributeByRoomIdAndKey).toHaveBeenCalledWith('r1', 'dept'); expect((service as any).onRoomAttributesChanged).not.toHaveBeenCalled(); }); @@ -966,26 +993,28 @@ describe('AbacService (unit)', () => { it('throws error-invalid-attribute-values when more than 10 values provided', async () => { const values = Array.from({ length: 11 }, (_, i) => `v${i}`); - await expect((service as any).replaceRoomAbacAttributeByKey('r1', 'dept', values)).rejects.toThrow( + await expect((service as any).replaceRoomAbacAttributeByKey('r1', 'dept', values, fakeActor)).rejects.toThrow( 'error-invalid-attribute-values', ); }); it('throws error-room-not-found if room missing', async () => { mockFindOneByIdAndType.mockResolvedValueOnce(null); - await expect((service as any).replaceRoomAbacAttributeByKey('missing', 'dept', ['eng'])).rejects.toThrow('error-room-not-found'); + await expect((service as any).replaceRoomAbacAttributeByKey('missing', 'dept', ['eng'], fakeActor)).rejects.toThrow( + 'error-room-not-found', + ); }); it('throws error-cannot-convert-default-room-to-abac when room is default', async () => { mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [], default: true }); - await expect((service as any).replaceRoomAbacAttributeByKey('r1', 'dept', ['eng'])).rejects.toThrow( + await expect((service as any).replaceRoomAbacAttributeByKey('r1', 'dept', ['eng'], fakeActor)).rejects.toThrow( 'error-cannot-convert-default-room-to-abac', ); }); it('throws error-cannot-convert-default-room-to-abac when room is teamDefault', async () => { mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [], teamDefault: true }); - await expect((service as any).replaceRoomAbacAttributeByKey('r1', 'dept', ['eng'])).rejects.toThrow( + await expect((service as any).replaceRoomAbacAttributeByKey('r1', 'dept', ['eng'], fakeActor)).rejects.toThrow( 'error-cannot-convert-default-room-to-abac', ); }); @@ -993,7 +1022,7 @@ describe('AbacService (unit)', () => { it('throws error-invalid-attribute-values if adding new key exceeds max attributes', async () => { const existing = Array.from({ length: 10 }, (_, i) => ({ key: `k${i}`, values: ['x'] })); mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: existing }); - await expect((service as any).replaceRoomAbacAttributeByKey('r1', 'dept', ['eng'])).rejects.toThrow( + await expect((service as any).replaceRoomAbacAttributeByKey('r1', 'dept', ['eng'], fakeActor)).rejects.toThrow( 'error-invalid-attribute-values', ); expect(mockInsertAbacAttributeIfNotExistsById).not.toHaveBeenCalled(); @@ -1005,7 +1034,7 @@ describe('AbacService (unit)', () => { const updatedDoc = { abacAttributes: [...existing, { key: 'dept', values: ['eng'] }] }; mockInsertAbacAttributeIfNotExistsById.mockResolvedValueOnce(updatedDoc); - await (service as any).replaceRoomAbacAttributeByKey('r1', 'dept', ['eng']); + await (service as any).replaceRoomAbacAttributeByKey('r1', 'dept', ['eng'], fakeActor); expect(mockInsertAbacAttributeIfNotExistsById).toHaveBeenCalledWith('r1', 'dept', ['eng']); expect(mockUpdateAbacAttributeValuesArrayFilteredById).not.toHaveBeenCalled(); @@ -1021,7 +1050,7 @@ describe('AbacService (unit)', () => { mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: existing }); mockUpdateAbacAttributeValuesArrayFilteredById.mockResolvedValueOnce(updatedDoc); - await (service as any).replaceRoomAbacAttributeByKey('r1', 'dept', ['eng', 'sales']); + await (service as any).replaceRoomAbacAttributeByKey('r1', 'dept', ['eng', 'sales'], fakeActor); expect(mockUpdateAbacAttributeValuesArrayFilteredById).toHaveBeenCalledWith('r1', 'dept', ['eng', 'sales']); expect(mockInsertAbacAttributeIfNotExistsById).not.toHaveBeenCalled(); @@ -1036,7 +1065,7 @@ describe('AbacService (unit)', () => { mockAbacFind.mockReturnValueOnce({ toArray: async () => [{ key: 'dept', values: ['eng'] }] }); mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [] }); - await expect((service as any).replaceRoomAbacAttributeByKey('r1', 'dept', ['eng', 'sales'])).rejects.toThrow( + await expect((service as any).replaceRoomAbacAttributeByKey('r1', 'dept', ['eng', 'sales'], fakeActor)).rejects.toThrow( 'error-invalid-attribute-values', ); expect(mockInsertAbacAttributeIfNotExistsById).not.toHaveBeenCalled(); @@ -1056,21 +1085,25 @@ describe('AbacService (unit)', () => { // Ensure definitions exist to pass definition check, but room missing mockAbacFind.mockReturnValueOnce({ toArray: async () => [{ key: 'dept', values: ['eng'] }] }); mockFindOneByIdAndType.mockResolvedValueOnce(null); - await expect(service.addRoomAbacAttributeByKey('missing', 'dept', ['eng'])).rejects.toThrow('error-room-not-found'); + await expect(service.addRoomAbacAttributeByKey('missing', 'dept', ['eng'], fakeActor)).rejects.toThrow('error-room-not-found'); expect(mockInsertAbacAttributeIfNotExistsById).not.toHaveBeenCalled(); }); it('throws error-cannot-convert-default-room-to-abac when room is default', async () => { mockAbacFind.mockReturnValueOnce({ toArray: async () => [{ key: 'dept', values: ['eng'] }] }); mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [], default: true }); - await expect(service.addRoomAbacAttributeByKey('r1', 'dept', ['eng'])).rejects.toThrow('error-cannot-convert-default-room-to-abac'); + await expect(service.addRoomAbacAttributeByKey('r1', 'dept', ['eng'], fakeActor)).rejects.toThrow( + 'error-cannot-convert-default-room-to-abac', + ); expect(mockInsertAbacAttributeIfNotExistsById).not.toHaveBeenCalled(); }); it('throws error-cannot-convert-default-room-to-abac when room is teamDefault', async () => { mockAbacFind.mockReturnValueOnce({ toArray: async () => [{ key: 'dept', values: ['eng'] }] }); mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [], teamDefault: true }); - await expect(service.addRoomAbacAttributeByKey('r1', 'dept', ['eng'])).rejects.toThrow('error-cannot-convert-default-room-to-abac'); + await expect(service.addRoomAbacAttributeByKey('r1', 'dept', ['eng'], fakeActor)).rejects.toThrow( + 'error-cannot-convert-default-room-to-abac', + ); expect(mockInsertAbacAttributeIfNotExistsById).not.toHaveBeenCalled(); }); @@ -1078,7 +1111,9 @@ describe('AbacService (unit)', () => { // No definitions returned mockAbacFind.mockReturnValueOnce({ toArray: async () => [] }); mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [] }); - await expect(service.addRoomAbacAttributeByKey('r1', 'dept', ['eng'])).rejects.toThrow('error-attribute-definition-not-found'); + await expect(service.addRoomAbacAttributeByKey('r1', 'dept', ['eng'], fakeActor)).rejects.toThrow( + 'error-attribute-definition-not-found', + ); expect(mockInsertAbacAttributeIfNotExistsById).not.toHaveBeenCalled(); }); @@ -1088,7 +1123,9 @@ describe('AbacService (unit)', () => { _id: 'r1', abacAttributes: [{ key: 'dept', values: ['eng'] }], }); - await expect(service.addRoomAbacAttributeByKey('r1', 'dept', ['sales'])).rejects.toThrow('error-duplicate-attribute-key'); + await expect(service.addRoomAbacAttributeByKey('r1', 'dept', ['sales'], fakeActor)).rejects.toThrow( + 'error-duplicate-attribute-key', + ); expect(mockInsertAbacAttributeIfNotExistsById).not.toHaveBeenCalled(); }); @@ -1096,7 +1133,7 @@ describe('AbacService (unit)', () => { const existing = Array.from({ length: 10 }, (_, i) => ({ key: `k${i}`, values: ['x'] })); mockAbacFind.mockReturnValueOnce({ toArray: async () => [{ key: 'dept', values: ['eng'] }] }); mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: existing }); - await expect(service.addRoomAbacAttributeByKey('r1', 'dept', ['eng'])).rejects.toThrow('error-invalid-attribute-values'); + await expect(service.addRoomAbacAttributeByKey('r1', 'dept', ['eng'], fakeActor)).rejects.toThrow('error-invalid-attribute-values'); expect(mockInsertAbacAttributeIfNotExistsById).not.toHaveBeenCalled(); }); @@ -1107,7 +1144,7 @@ describe('AbacService (unit)', () => { mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: existing }); mockInsertAbacAttributeIfNotExistsById.mockResolvedValueOnce(updatedDoc); - await service.addRoomAbacAttributeByKey('r1', 'dept', ['eng', 'sales']); + await service.addRoomAbacAttributeByKey('r1', 'dept', ['eng', 'sales'], fakeActor); expect(mockInsertAbacAttributeIfNotExistsById).toHaveBeenCalledWith('r1', 'dept', ['eng', 'sales']); expect((service as any).onRoomAttributesChanged).toHaveBeenCalledWith( @@ -1122,7 +1159,7 @@ describe('AbacService (unit)', () => { mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: existing }); mockInsertAbacAttributeIfNotExistsById.mockResolvedValueOnce(undefined); - await service.addRoomAbacAttributeByKey('r1', 'dept', ['eng']); + await service.addRoomAbacAttributeByKey('r1', 'dept', ['eng'], fakeActor); expect(mockInsertAbacAttributeIfNotExistsById).toHaveBeenCalledWith('r1', 'dept', ['eng']); expect((service as any).onRoomAttributesChanged).toHaveBeenCalledWith(expect.objectContaining({ _id: 'r1' }), [ @@ -1134,7 +1171,9 @@ describe('AbacService (unit)', () => { it('rejects when provided value not allowed by definition', async () => { mockAbacFind.mockReturnValueOnce({ toArray: async () => [{ key: 'dept', values: ['eng'] }] }); mockFindOneByIdAndType.mockResolvedValueOnce({ _id: 'r1', abacAttributes: [] }); - await expect(service.addRoomAbacAttributeByKey('r1', 'dept', ['eng', 'sales'])).rejects.toThrow('error-invalid-attribute-values'); + await expect(service.addRoomAbacAttributeByKey('r1', 'dept', ['eng', 'sales'], fakeActor)).rejects.toThrow( + 'error-invalid-attribute-values', + ); expect(mockInsertAbacAttributeIfNotExistsById).not.toHaveBeenCalled(); }); }); diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index fd7bf8dd02816..5a42b4a46d625 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -1,5 +1,5 @@ import { MeteorError, Room, ServiceClass } from '@rocket.chat/core-services'; -import type { IAbacService } from '@rocket.chat/core-services'; +import type { AbacActor, IAbacService } from '@rocket.chat/core-services'; import { AbacAccessOperation, AbacObjectType } from '@rocket.chat/core-typings'; import type { IAbacAttribute, IAbacAttributeDefinition, IRoom, AtLeast, IUser, ILDAPEntry } from '@rocket.chat/core-typings'; import { Logger } from '@rocket.chat/logger'; @@ -8,7 +8,8 @@ import { escapeRegExp } from '@rocket.chat/string-helpers'; import type { Document, UpdateFilter } from 'mongodb'; import pLimit from 'p-limit'; -import { extractAttribute } from './helper'; +import { Audit } from './audit'; +import { diffAttributes, extractAttribute } from './helper'; // Limit concurrent user removals to avoid overloading the server with too many operations at once const limit = pLimit(20); @@ -64,20 +65,20 @@ export class AbacService extends ServiceClass implements IAbacService { await this.onSubjectAttributesChanged(finalUser!, finalAttributes); } - this.logger.debug({ - msg: 'LDAP subject attributes synced to user', - userId: user._id, - finalAttributes, - }); + const diff = diffAttributes(user?.abacAttributes, finalAttributes); + if (diff.length) { + void Audit.subjectAttributeChanged(diff, { _id: user._id, username: user.username }); + } } - async addAbacAttribute(attribute: IAbacAttributeDefinition): Promise { + async addAbacAttribute(attribute: IAbacAttributeDefinition, actor: AbacActor): Promise { if (!attribute.values.length) { throw new Error('error-invalid-attribute-values'); } try { await AbacAttributes.insertOne(attribute); + void Audit.attributeCreated(attribute, actor); } catch (e) { if (e instanceof Error && e.message.includes('E11000')) { throw new Error('error-duplicate-attribute-key'); @@ -185,7 +186,7 @@ export class AbacService extends ServiceClass implements IAbacService { }; } - async updateAbacAttributeById(_id: string, update: { key?: string; values?: string[] }): Promise { + async updateAbacAttributeById(_id: string, update: { key?: string; values?: string[] }, actor: AbacActor): Promise { if (!update.key && !update.values) { return; } @@ -228,10 +229,7 @@ export class AbacService extends ServiceClass implements IAbacService { try { await AbacAttributes.updateOne({ _id }, { $set: modifier }); - this.logger.debug({ - msg: 'Abac attribute updated', - ...update, - }); + void Audit.attributeUpdated(existing, modifier as IAbacAttributeDefinition, actor); } catch (e) { if (e instanceof Error && e.message.includes('E11000')) { throw new Error('error-duplicate-attribute-key'); @@ -240,7 +238,7 @@ export class AbacService extends ServiceClass implements IAbacService { } } - async deleteAbacAttributeById(_id: string): Promise { + async deleteAbacAttributeById(_id: string, actor: AbacActor): Promise { const existing = await AbacAttributes.findOneById(_id, { projection: { key: 1, values: 1 } }); if (!existing) { throw new Error('error-attribute-not-found'); @@ -252,6 +250,7 @@ export class AbacService extends ServiceClass implements IAbacService { } await AbacAttributes.removeById(_id); + void Audit.attributeDeleted(existing, actor); } async getAbacAttributeById(_id: string): Promise<{ key: string; values: string[]; usage: Record }> { @@ -283,7 +282,7 @@ export class AbacService extends ServiceClass implements IAbacService { return Rooms.isAbacAttributeInUse(key, attribute.values || []); } - async setRoomAbacAttributes(rid: string, attributes: Record): Promise { + async setRoomAbacAttributes(rid: string, attributes: Record, actor: AbacActor): Promise { const room = await Rooms.findOneByIdAndType>( rid, 'p', @@ -298,8 +297,9 @@ export class AbacService extends ServiceClass implements IAbacService { throw new Error('error-cannot-convert-default-room-to-abac'); } - if (!Object.keys(attributes).length) { + if (!Object.keys(attributes).length && room.abacAttributes?.length) { await Rooms.unsetAbacAttributesById(rid); + void Audit.objectAttributeRemoved({ _id: room._id }, room.abacAttributes, actor); return; } @@ -308,6 +308,7 @@ export class AbacService extends ServiceClass implements IAbacService { await this.ensureAttributeDefinitionsExist(normalized); const updated = await Rooms.setAbacAttributesById(rid, normalized); + void Audit.objectAttributeChanged({ _id: room._id }, room.abacAttributes || [], normalized, actor); const previous: IAbacAttributeDefinition[] = room.abacAttributes || []; if (this.didAttributesChange(previous, normalized)) { @@ -385,7 +386,7 @@ export class AbacService extends ServiceClass implements IAbacService { } } - async updateRoomAbacAttributeValues(rid: string, key: string, values: string[]): Promise { + async updateRoomAbacAttributeValues(rid: string, key: string, values: string[], actor: AbacActor): Promise { const room = await Rooms.findOneByIdAndType>( rid, 'p', @@ -413,6 +414,7 @@ export class AbacService extends ServiceClass implements IAbacService { if (isNewKey) { await Rooms.updateSingleAbacAttributeValuesById(rid, key, values); + void Audit.objectAttributeChanged({ _id: room._id }, room.abacAttributes || [], [{ key, values }], actor); const next = [...previous, { key, values }]; await this.onRoomAttributesChanged(room, next); @@ -426,6 +428,7 @@ export class AbacService extends ServiceClass implements IAbacService { } await Rooms.updateAbacAttributeValuesArrayFilteredById(rid, key, values); + void Audit.objectAttributeChanged({ _id: room._id }, room.abacAttributes || [], [{ key, values }], actor); if (this.wereAttributeValuesAdded(prevValues, values)) { const next = previous.map((a, i) => (i === existingIndex ? { key, values } : a)); @@ -438,7 +441,7 @@ export class AbacService extends ServiceClass implements IAbacService { return newValues.some((v) => !prevSet.has(v)); } - async removeRoomAbacAttribute(rid: string, key: string): Promise { + async removeRoomAbacAttribute(rid: string, key: string, actor: AbacActor): Promise { const room = await Rooms.findOneByIdAndType>(rid, 'p', { projection: { abacAttributes: 1, default: 1, teamDefault: 1 }, }); @@ -459,18 +462,21 @@ export class AbacService extends ServiceClass implements IAbacService { // if is the last attribute, just remove all if (previous.length === 1) { await Rooms.unsetAbacAttributesById(rid); + void Audit.objectAttributeRemoved({ _id: room._id }, previous, actor); + return; } await Rooms.removeAbacAttributeByRoomIdAndKey(rid, key); - this.logger.debug({ - msg: 'Room ABAC attribute removed', - rid, - key, - }); + void Audit.objectAttributeChanged( + { _id: room._id }, + previous, + previous.filter((a) => a.key !== key), + actor, + ); } - async addRoomAbacAttributeByKey(rid: string, key: string, values: string[]): Promise { + async addRoomAbacAttributeByKey(rid: string, key: string, values: string[], actor: AbacActor): Promise { await this.ensureAttributeDefinitionsExist([{ key, values }]); const room = await Rooms.findOneByIdAndType>( @@ -500,10 +506,12 @@ export class AbacService extends ServiceClass implements IAbacService { const updated = await Rooms.insertAbacAttributeIfNotExistsById(rid, key, values); const next = updated?.abacAttributes || [...previous, { key, values }]; + void Audit.objectAttributeChanged({ _id: room._id }, previous, next, actor); + await this.onRoomAttributesChanged(room, next); } - async replaceRoomAbacAttributeByKey(rid: string, key: string, values: string[]): Promise { + async replaceRoomAbacAttributeByKey(rid: string, key: string, values: string[], actor: AbacActor): Promise { await this.ensureAttributeDefinitionsExist([{ key, values }]); const room = await Rooms.findOneByIdAndType>( @@ -527,6 +535,7 @@ export class AbacService extends ServiceClass implements IAbacService { const updated = await Rooms.updateAbacAttributeValuesArrayFilteredById(rid, key, values); const prevValues = room.abacAttributes?.find((a) => a.key === key)?.values ?? []; + void Audit.objectAttributeChanged({ _id: room._id }, room.abacAttributes || [], updated?.abacAttributes || [], actor); if (this.wereAttributeValuesAdded(prevValues, values)) { await this.onRoomAttributesChanged(room, updated?.abacAttributes || []); } @@ -539,6 +548,7 @@ export class AbacService extends ServiceClass implements IAbacService { } const updated = await Rooms.insertAbacAttributeIfNotExistsById(rid, key, values); + void Audit.objectAttributeChanged({ _id: room._id }, room.abacAttributes || [], updated?.abacAttributes || [], actor); await this.onRoomAttributesChanged(room, updated?.abacAttributes || []); } @@ -632,6 +642,7 @@ export class AbacService extends ServiceClass implements IAbacService { const userRemovalPromises = []; for await (const doc of cursor) { usersToRemove.push(doc._id); + void Audit.actionPerformed({ _id: doc._id, username: doc.username }, { _id: rid }); userRemovalPromises.push( limit(() => Room.removeUserFromRoom(rid, doc, { @@ -721,6 +732,8 @@ export class AbacService extends ServiceClass implements IAbacService { skipAppPreEvents: true, customSystemMessage: 'abac-removed-user-from-room' as const, }); + + void Audit.actionPerformed({ _id: user._id, username: fullUser.username }, { _id: room._id }, 'realtime-policy-eval'); return false; } @@ -768,6 +781,7 @@ export class AbacService extends ServiceClass implements IAbacService { const removalPromises: Promise[] = []; for await (const room of cursor) { + void Audit.actionPerformed({ _id: user._id, username: user.username }, { _id: room._id }, 'ldap-sync'); removalPromises.push( limit(() => Room.removeUserFromRoom(room._id, user, { @@ -791,6 +805,7 @@ export class AbacService extends ServiceClass implements IAbacService { const removalPromises: Promise[] = []; for await (const room of cursor) { + void Audit.actionPerformed({ _id: user._id, username: user.username }, { _id: room._id }, 'ldap-sync'); removalPromises.push( limit(() => Room.removeUserFromRoom(room._id, user, { diff --git a/ee/packages/abac/src/user-auto-removal.spec.ts b/ee/packages/abac/src/user-auto-removal.spec.ts index d249e9af0df9d..875cff2cb7679 100644 --- a/ee/packages/abac/src/user-auto-removal.spec.ts +++ b/ee/packages/abac/src/user-auto-removal.spec.ts @@ -28,11 +28,13 @@ describe('AbacService integration (onRoomAttributesChanged)', () => { let defsCol: Collection; const rid = 'r1'; + const fakeActor = { _id: 'test-user', username: 'testuser', type: 'user' }; + const insertDefinitions = async (defs: { key: string; values: string[] }[]) => { const svc = new AbacService(); await Promise.all( defs.map((def) => - svc.addAbacAttribute({ key: def.key, values: def.values }).catch((e: any) => { + svc.addAbacAttribute({ key: def.key, values: def.values }, fakeActor).catch((e: any) => { if (e instanceof Error && e.message === 'error-duplicate-attribute-key') { return; } @@ -118,7 +120,7 @@ describe('AbacService integration (onRoomAttributesChanged)', () => { const debugSpy = (service as any).logger.debug as jest.Mock; const changeSpy = jest.spyOn(service as any, 'onRoomAttributesChanged'); - await service.setRoomAbacAttributes(rid, { dept: ['eng', 'sales'] }); + await service.setRoomAbacAttributes(rid, { dept: ['eng', 'sales'] }, fakeActor); // Assert the protected hook received the full room object (first arg) instead of just the id expect(changeSpy).toHaveBeenCalledTimes(1); @@ -155,7 +157,7 @@ describe('AbacService integration (onRoomAttributesChanged)', () => { ]); const debugSpy = (service as any).logger.debug as jest.Mock; - await service.setRoomAbacAttributes(rid, { dept: ['eng', 'eng', 'sales'] }); + await service.setRoomAbacAttributes(rid, { dept: ['eng', 'eng', 'sales'] }, fakeActor); const evaluationCalls = debugSpy.mock.calls .map((c: any[]) => c[0]) @@ -183,7 +185,7 @@ describe('AbacService integration (onRoomAttributesChanged)', () => { ]); const debugSpy = (service as any).logger.debug as jest.Mock; - await service.updateRoomAbacAttributeValues(rid, 'dept', ['eng', 'sales']); + await service.updateRoomAbacAttributeValues(rid, 'dept', ['eng', 'sales'], fakeActor); const evaluationCalls = debugSpy.mock.calls .map((c: any[]) => c[0]) @@ -211,7 +213,7 @@ describe('AbacService integration (onRoomAttributesChanged)', () => { ]); const debugSpy = (service as any).logger.debug as jest.Mock; - await service.updateRoomAbacAttributeValues(rid, 'dept', ['eng']); // removal only + await service.updateRoomAbacAttributeValues(rid, 'dept', ['eng'], fakeActor); // removal only const evaluationCalls = debugSpy.mock.calls .map((c: any[]) => c[0]) @@ -273,10 +275,14 @@ describe('AbacService integration (onRoomAttributesChanged)', () => { ]); const debugSpy = (service as any).logger.debug as jest.Mock; - await service.setRoomAbacAttributes(rid, { - dept: ['eng', 'sales'], - region: ['emea'], - }); + await service.setRoomAbacAttributes( + rid, + { + dept: ['eng', 'sales'], + region: ['emea'], + }, + fakeActor, + ); const evaluationCalls = debugSpy.mock.calls .map((c: any[]) => c[0]) @@ -308,7 +314,7 @@ describe('AbacService integration (onRoomAttributesChanged)', () => { ]); const debugSpy = (service as any).logger.debug as jest.Mock; - await service.setRoomAbacAttributes(rid, { dept: ['eng', 'sales'] }); + await service.setRoomAbacAttributes(rid, { dept: ['eng', 'sales'] }, fakeActor); const firstEval = debugSpy.mock.calls.map((c: any[]) => c[0]).filter((a: any) => a && a.msg === 'Room ABAC attributes changed'); expect(firstEval.length).toBe(1); expect(firstEval[0].usersToRemove.sort()).toEqual(['u2']); @@ -321,7 +327,7 @@ describe('AbacService integration (onRoomAttributesChanged)', () => { // Reset mock counts for clarity debugSpy.mockClear(); - await service.setRoomAbacAttributes(rid, { dept: ['eng', 'sales'] }); + await service.setRoomAbacAttributes(rid, { dept: ['eng', 'sales'] }, fakeActor); const secondEval = debugSpy.mock.calls.map((c: any[]) => c[0]).filter((a: any) => a && a.msg === 'Room ABAC attributes changed'); expect(secondEval.length).toBe(0); @@ -344,7 +350,7 @@ describe('AbacService integration (onRoomAttributesChanged)', () => { ]); const debugSpy = (service as any).logger.debug as jest.Mock; - await service.setRoomAbacAttributes(rid, { dept: ['eng', 'sales', 'hr'] }); + await service.setRoomAbacAttributes(rid, { dept: ['eng', 'sales', 'hr'] }, fakeActor); const evaluationCalls = debugSpy.mock.calls .map((c: any[]) => c[0]) @@ -370,7 +376,7 @@ describe('AbacService integration (onRoomAttributesChanged)', () => { ]); const debugSpy = (service as any).logger.debug as jest.Mock; - await service.setRoomAbacAttributes(rid, { region: ['emea'] }); + await service.setRoomAbacAttributes(rid, { region: ['emea'] }, fakeActor); const evaluationCalls = debugSpy.mock.calls .map((c: any[]) => c[0]) @@ -405,7 +411,7 @@ describe('AbacService integration (onRoomAttributesChanged)', () => { await insertUsers(bulk); const debugSpy = (service as any).logger.debug as jest.Mock; - await service.setRoomAbacAttributes(rid, { dept: ['eng', 'sales'] }); + await service.setRoomAbacAttributes(rid, { dept: ['eng', 'sales'] }, fakeActor); const evaluationCalls = debugSpy.mock.calls .map((c: any[]) => c[0]) diff --git a/packages/core-services/src/index.ts b/packages/core-services/src/index.ts index b1923d2732669..68bfe62196f89 100644 --- a/packages/core-services/src/index.ts +++ b/packages/core-services/src/index.ts @@ -53,7 +53,7 @@ import type { IVoipFreeSwitchService } from './types/IVoipFreeSwitchService'; import type { IVoipService } from './types/IVoipService'; export { AppStatusReport } from './types/IAppsEngineService'; -export { IAbacService } from './types/IAbacService'; +export { IAbacService, AbacActor } from './types/IAbacService'; export { asyncLocalStorage } from './lib/asyncLocalStorage'; export { MeteorError, isMeteorError } from './MeteorError'; export { api } from './api'; diff --git a/packages/core-services/src/types/IAbacService.ts b/packages/core-services/src/types/IAbacService.ts index 6a8755d2cc3f0..81c7c0ef451fa 100644 --- a/packages/core-services/src/types/IAbacService.ts +++ b/packages/core-services/src/types/IAbacService.ts @@ -8,35 +8,43 @@ import type { ILDAPEntry, } from '@rocket.chat/core-typings'; +export type AbacActor = Pick; + export interface IAbacService { - addAbacAttribute(attribute: IAbacAttributeDefinition): Promise; - listAbacAttributes(filters?: { - key?: string; - values?: string; - offset?: number; - count?: number; - }): Promise<{ attributes: IAbacAttribute[]; offset: number; count: number; total: number }>; - listAbacRooms(filters?: { - offset?: number; - count?: number; - filter?: string; - filterType?: 'all' | 'roomName' | 'attribute' | 'value'; - }): Promise<{ rooms: IRoom[]; offset: number; count: number; total: number }>; - updateAbacAttributeById(_id: string, update: { key?: string; values?: string[] }): Promise; - deleteAbacAttributeById(_id: string): Promise; + addAbacAttribute(attribute: IAbacAttributeDefinition, actor?: AbacActor): Promise; + listAbacAttributes( + filters?: { + key?: string; + values?: string; + offset?: number; + count?: number; + }, + actor?: AbacActor, + ): Promise<{ attributes: IAbacAttribute[]; offset: number; count: number; total: number }>; + listAbacRooms( + filters?: { + offset?: number; + count?: number; + filter?: string; + filterType?: 'all' | 'roomName' | 'attribute' | 'value'; + }, + actor?: AbacActor, + ): Promise<{ rooms: IRoom[]; offset: number; count: number; total: number }>; + updateAbacAttributeById(_id: string, update: { key?: string; values?: string[] }, actor?: AbacActor): Promise; + deleteAbacAttributeById(_id: string, actor?: AbacActor): Promise; // Usage represents if the attribute values are in use or not. If no values are in use, the attribute is not in use. - getAbacAttributeById(_id: string): Promise<{ key: string; values: string[]; usage: Record }>; + getAbacAttributeById(_id: string, actor?: AbacActor): Promise<{ key: string; values: string[]; usage: Record }>; isAbacAttributeInUseByKey(key: string): Promise; - setRoomAbacAttributes(rid: string, attributes: Record): Promise; - removeRoomAbacAttribute(rid: string, key: string): Promise; - addRoomAbacAttributeByKey(rid: string, key: string, values: string[]): Promise; - replaceRoomAbacAttributeByKey(rid: string, key: string, values: string[]): Promise; - checkUsernamesMatchAttributes(usernames: string[], attributes: IAbacAttributeDefinition[]): Promise; + setRoomAbacAttributes(rid: string, attributes: Record, actor?: AbacActor): Promise; + removeRoomAbacAttribute(rid: string, key: string, actor?: AbacActor): Promise; + addRoomAbacAttributeByKey(rid: string, key: string, values: string[], actor?: AbacActor): Promise; + replaceRoomAbacAttributeByKey(rid: string, key: string, values: string[], actor?: AbacActor): Promise; + checkUsernamesMatchAttributes(usernames: string[], attributes: IAbacAttributeDefinition[], actor?: AbacActor): Promise; canAccessObject( room: Pick, user: Pick, action: AbacAccessOperation, objectType: AbacObjectType, ): Promise; - addSubjectAttributes(user: IUser, ldapUser: ILDAPEntry, map: Record): Promise; + addSubjectAttributes(user: IUser, ldapUser: ILDAPEntry, map: Record, actor?: AbacActor): Promise; } From 393013eaa2e8999dd46aa46ba6d8d2c45554a63d Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Fri, 21 Nov 2025 11:09:50 -0600 Subject: [PATCH 03/15] more audit --- apps/meteor/tests/end-to-end/api/abac.ts | 121 ++++++++++++++++++++++- ee/packages/abac/src/audit.ts | 44 ++++++++- ee/packages/abac/src/index.ts | 19 ++-- 3 files changed, 170 insertions(+), 14 deletions(-) diff --git a/apps/meteor/tests/end-to-end/api/abac.ts b/apps/meteor/tests/end-to-end/api/abac.ts index e35d06f6c7a90..4d7d12fb7e646 100644 --- a/apps/meteor/tests/end-to-end/api/abac.ts +++ b/apps/meteor/tests/end-to-end/api/abac.ts @@ -141,8 +141,7 @@ const addAbacAttributesToUserDirectly = async (userId: string, abacAttributes: I }); it('POST should fail creating duplicate key', async () => { - const response = await request.get(`${v1}/abac/attributes`).set(credentials).expect(200); - console.log(response.body); + await request.get(`${v1}/abac/attributes`).set(credentials).expect(200); await request .post(`${v1}/abac/attributes`) .set(credentials) @@ -276,6 +275,39 @@ const addAbacAttributesToUserDirectly = async (userId: string, abacAttributes: I expect(res.body).to.have.property('key', updatedKey); }); }); + + it('PUT should update key only (key-updated)', async () => { + const testKey = `${initialKey}_update_test`; + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: testKey, values: ['val1', 'val2'] }) + .expect(200); + + const listRes = await request.get(`${v1}/abac/attributes`).query({ key: testKey }).set(credentials).expect(200); + + const attr = listRes.body.attributes.find((a: any) => a.key === testKey); + expect(attr).to.exist; + const attrId = attr._id; + + const newKey = `${initialKey}_update_test_renamed`; + await request + .put(`${v1}/abac/attributes/${attrId}`) + .set(credentials) + .send({ key: newKey }) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(`${v1}/abac/attributes/${attrId}`) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('key', newKey); + }); + }); }); describe('Room Attribute Operations', () => { @@ -374,6 +406,60 @@ const addAbacAttributesToUserDirectly = async (userId: string, abacAttributes: I }); }); + describe('DELETE room attribute key (dedicated room)', () => { + let dedicatedRoom: IRoom; + + before(async () => { + dedicatedRoom = (await createRoom({ type: 'p', name: `abac-dedicated-${Date.now()}` })).body.group; + }); + + after(async () => { + await deleteRoom({ type: 'p', roomId: dedicatedRoom._id }); + }); + + it('should succeed and remove only the specified key (key-removed)', async () => { + const key1 = `${initialKey}_room1`; + const key2 = `${initialKey}_room2`; + + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: key1, values: ['value1'] }) + .expect(200); + + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: key2, values: ['value2'] }) + .expect(200); + + await addAbacAttributesToUserDirectly(credentials['X-User-Id'], [ + { key: key1, values: ['value1'] }, + { key: key2, values: ['value2'] }, + ]); + + await request + .post(`${v1}/abac/rooms/${dedicatedRoom._id}/attributes`) + .set(credentials) + .send({ attributes: { [key1]: ['value1'], [key2]: ['value2'] } }) + .expect(200); + + await request + .delete(`${v1}/abac/rooms/${dedicatedRoom._id}/attributes/${key1}`) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + const roomRes = await request.get(`${v1}/rooms.info?roomId=${dedicatedRoom._id}`).set(credentials); + expect(roomRes.status).to.equal(200); + const abacAttrs = roomRes.body.room?.abacAttributes || []; + expect(abacAttrs).to.be.an('array').that.has.lengthOf(1); + expect(abacAttrs[0]).to.have.property('key', key2); + }); + }); + it('DELETE room attribute key should succeed and clear usage/inUse=false', async () => { await request .delete(`${v1}/abac/rooms/${testRoom._id}/attributes/${updatedKey}`) @@ -555,6 +641,37 @@ const addAbacAttributesToUserDirectly = async (userId: string, abacAttributes: I }); }); + it('DELETE attribute definition should remove the key (key-removed)', async () => { + const testKey = `${initialKey}_delete_test`; + await request + .post(`${v1}/abac/attributes`) + .set(credentials) + .send({ key: testKey, values: ['del1', 'del2'] }) + .expect(200); + + const listRes = await request.get(`${v1}/abac/attributes`).query({ key: testKey }).set(credentials).expect(200); + + const attr = listRes.body.attributes.find((a: any) => a.key === testKey); + expect(attr).to.exist; + const attrId = attr._id; + + await request + .delete(`${v1}/abac/attributes/${attrId}`) + .set(credentials) + .expect(200) + .expect((res) => { + expect(res.body).to.have.property('success', true); + }); + + await request + .get(`${v1}/abac/attributes/${attrId}`) + .set(credentials) + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('error', 'error-attribute-not-found'); + }); + }); + after(async () => { await deleteRoom({ type: 'p', roomId: privateDefaultRoomId }); await deleteTeam(credentials, teamName); diff --git a/ee/packages/abac/src/audit.ts b/ee/packages/abac/src/audit.ts index 835c06d958e1b..d6a9710fbc42b 100644 --- a/ee/packages/abac/src/audit.ts +++ b/ee/packages/abac/src/audit.ts @@ -15,7 +15,16 @@ type MinimalRoom = Pick; export type AbacAuditReason = 'ldap-sync' | 'room-attributes-change' | 'system' | 'api' | 'realtime-policy-eval'; -export type AbacAttributeDefinitionChangeType = 'created' | 'updated' | 'deleted' | 'key-changed' | 'values-changed'; +export type AbacAttributeDefinitionChangeType = + | 'created' + | 'updated' + | 'deleted' + | 'all-deleted' + | 'key-removed' + | 'key-renamed' + | 'value-removed' + | 'key-added' + | 'key-updated'; export type AbacAttributeDefinitionDiff = { added?: string[]; @@ -37,6 +46,7 @@ interface IServerEventAbacObjectAttributeChanged | { key: 'reason'; value: AbacAuditReason } | { key: 'previous'; value: IAbacAttributeDefinition[] } | { key: 'current'; value: IAbacAttributeDefinition[] | null } + | { key: 'change'; value: AbacAttributeDefinitionChangeType } > { t: 'abac.object.attribute.changed'; } @@ -62,7 +72,12 @@ interface IServerEventAbacActionPerformed t: 'abac.action.performed'; } -type ValidEvents = 'abac.subject.attribute.changed' | 'abac.object.attribute.changed' | 'abac.attribute.changed' | 'abac.action.performed'; +type ValidEvents = + | 'abac.subject.attribute.changed' + | 'abac.object.attribute.changed' + | 'abac.attribute.changed' + | 'abac.action.performed' + | 'abac.object.attributes.removed'; declare module '@rocket.chat/core-typings' { interface IServerEvents { @@ -70,6 +85,7 @@ declare module '@rocket.chat/core-typings' { 'abac.object.attribute.changed': IServerEventAbacObjectAttributeChanged; 'abac.attribute.changed': IServerEventAbacAttributeChanged; 'abac.action.performed': IServerEventAbacActionPerformed; + 'abac.object.attributes.removed': IServerEventAbacObjectAttributeChanged; } } @@ -130,6 +146,7 @@ export const Audit = { minimalRoom: MinimalRoom, previous: IAbacAttributeDefinition[], current: IAbacAttributeDefinition[], + change: AbacAttributeDefinitionChangeType, actor: AbacActor, ) => { return audit( @@ -137,18 +154,39 @@ export const Audit = { { room: minimalRoom, reason: 'api', + change, previous, current, }, { type: 'user', ...(actor as any) }, ); }, - objectAttributeRemoved: async (minimalRoom: MinimalRoom, previous: IAbacAttributeDefinition[], actor: AbacActor) => { + objectAttributeRemoved: async ( + minimalRoom: MinimalRoom, + previous: IAbacAttributeDefinition[], + current: IAbacAttributeDefinition[], + change: AbacAttributeDefinitionChangeType, + actor: AbacActor, + ) => { return audit( 'abac.object.attribute.changed', { room: minimalRoom, reason: 'api', + change, + previous, + current, + }, + { type: 'user', ...(actor as any) }, + ); + }, + objectAttributesRemoved: async (minimalRoom: MinimalRoom, previous: IAbacAttributeDefinition[], actor: AbacActor) => { + return audit( + 'abac.object.attributes.removed', + { + room: minimalRoom, + reason: 'api', + change: 'all-deleted', previous, current: null, }, diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index 5a42b4a46d625..b75901ecc7176 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -299,7 +299,7 @@ export class AbacService extends ServiceClass implements IAbacService { if (!Object.keys(attributes).length && room.abacAttributes?.length) { await Rooms.unsetAbacAttributesById(rid); - void Audit.objectAttributeRemoved({ _id: room._id }, room.abacAttributes, actor); + void Audit.objectAttributesRemoved({ _id: room._id }, room.abacAttributes, actor); return; } @@ -308,7 +308,7 @@ export class AbacService extends ServiceClass implements IAbacService { await this.ensureAttributeDefinitionsExist(normalized); const updated = await Rooms.setAbacAttributesById(rid, normalized); - void Audit.objectAttributeChanged({ _id: room._id }, room.abacAttributes || [], normalized, actor); + void Audit.objectAttributeChanged({ _id: room._id }, room.abacAttributes || [], normalized, 'updated', actor); const previous: IAbacAttributeDefinition[] = room.abacAttributes || []; if (this.didAttributesChange(previous, normalized)) { @@ -414,7 +414,7 @@ export class AbacService extends ServiceClass implements IAbacService { if (isNewKey) { await Rooms.updateSingleAbacAttributeValuesById(rid, key, values); - void Audit.objectAttributeChanged({ _id: room._id }, room.abacAttributes || [], [{ key, values }], actor); + void Audit.objectAttributeChanged({ _id: room._id }, room.abacAttributes || [], [{ key, values }], 'key-added', actor); const next = [...previous, { key, values }]; await this.onRoomAttributesChanged(room, next); @@ -428,7 +428,7 @@ export class AbacService extends ServiceClass implements IAbacService { } await Rooms.updateAbacAttributeValuesArrayFilteredById(rid, key, values); - void Audit.objectAttributeChanged({ _id: room._id }, room.abacAttributes || [], [{ key, values }], actor); + void Audit.objectAttributeChanged({ _id: room._id }, room.abacAttributes || [], [{ key, values }], 'key-updated', actor); if (this.wereAttributeValuesAdded(prevValues, values)) { const next = previous.map((a, i) => (i === existingIndex ? { key, values } : a)); @@ -462,16 +462,17 @@ export class AbacService extends ServiceClass implements IAbacService { // if is the last attribute, just remove all if (previous.length === 1) { await Rooms.unsetAbacAttributesById(rid); - void Audit.objectAttributeRemoved({ _id: room._id }, previous, actor); + void Audit.objectAttributesRemoved({ _id: room._id }, previous, actor); return; } await Rooms.removeAbacAttributeByRoomIdAndKey(rid, key); - void Audit.objectAttributeChanged( + void Audit.objectAttributeRemoved( { _id: room._id }, previous, previous.filter((a) => a.key !== key), + 'key-removed', actor, ); } @@ -506,7 +507,7 @@ export class AbacService extends ServiceClass implements IAbacService { const updated = await Rooms.insertAbacAttributeIfNotExistsById(rid, key, values); const next = updated?.abacAttributes || [...previous, { key, values }]; - void Audit.objectAttributeChanged({ _id: room._id }, previous, next, actor); + void Audit.objectAttributeChanged({ _id: room._id }, previous, next, 'key-added', actor); await this.onRoomAttributesChanged(room, next); } @@ -535,7 +536,7 @@ export class AbacService extends ServiceClass implements IAbacService { const updated = await Rooms.updateAbacAttributeValuesArrayFilteredById(rid, key, values); const prevValues = room.abacAttributes?.find((a) => a.key === key)?.values ?? []; - void Audit.objectAttributeChanged({ _id: room._id }, room.abacAttributes || [], updated?.abacAttributes || [], actor); + void Audit.objectAttributeChanged({ _id: room._id }, room.abacAttributes || [], updated?.abacAttributes || [], 'key-updated', actor); if (this.wereAttributeValuesAdded(prevValues, values)) { await this.onRoomAttributesChanged(room, updated?.abacAttributes || []); } @@ -548,7 +549,7 @@ export class AbacService extends ServiceClass implements IAbacService { } const updated = await Rooms.insertAbacAttributeIfNotExistsById(rid, key, values); - void Audit.objectAttributeChanged({ _id: room._id }, room.abacAttributes || [], updated?.abacAttributes || [], actor); + void Audit.objectAttributeChanged({ _id: room._id }, room.abacAttributes || [], updated?.abacAttributes || [], 'key-added', actor); await this.onRoomAttributesChanged(room, updated?.abacAttributes || []); } From 458a704d5f0796c2601891d312049f9ea16bed0d Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Fri, 21 Nov 2025 11:33:58 -0600 Subject: [PATCH 04/15] audit change --- ee/packages/abac/src/audit.ts | 12 ++++++------ ee/packages/abac/src/index.ts | 9 +-------- 2 files changed, 7 insertions(+), 14 deletions(-) diff --git a/ee/packages/abac/src/audit.ts b/ee/packages/abac/src/audit.ts index d6a9710fbc42b..24574d7e6d4e1 100644 --- a/ee/packages/abac/src/audit.ts +++ b/ee/packages/abac/src/audit.ts @@ -113,7 +113,7 @@ export const Audit = { change: 'created', current: attribute, } as EventPayload<'abac.attribute.changed'>, - { type: 'user', ...(actor as any) }, + { type: 'user', _id: actor._id, username: actor.username!, ip: '0.0.0.0', useragent: '' }, ); }, attributeUpdated: async (current: IAbacAttributeDefinition, diff: IAbacAttributeDefinition, actor: AbacActor) => { @@ -126,7 +126,7 @@ export const Audit = { current, diff, }, - { type: 'user', ...(actor as any) }, + { type: 'user', _id: actor._id, username: actor.username!, ip: '0.0.0.0', useragent: '' }, ); }, attributeDeleted: async (attribute: IAbacAttributeDefinition, actor: AbacActor) => { @@ -139,7 +139,7 @@ export const Audit = { current: null, diff: attribute, }, - { type: 'user', ...(actor as any) }, + { type: 'user', _id: actor._id, username: actor.username!, ip: '0.0.0.0', useragent: '' }, ); }, objectAttributeChanged: async ( @@ -158,7 +158,7 @@ export const Audit = { previous, current, }, - { type: 'user', ...(actor as any) }, + { type: 'user', _id: actor._id, username: actor.username!, ip: '0.0.0.0', useragent: '' }, ); }, objectAttributeRemoved: async ( @@ -177,7 +177,7 @@ export const Audit = { previous, current, }, - { type: 'user', ...(actor as any) }, + { type: 'user', _id: actor._id, username: actor.username!, ip: '0.0.0.0', useragent: '' }, ); }, objectAttributesRemoved: async (minimalRoom: MinimalRoom, previous: IAbacAttributeDefinition[], actor: AbacActor) => { @@ -190,7 +190,7 @@ export const Audit = { previous, current: null, }, - { type: 'user', ...(actor as any) }, + { type: 'user', _id: actor._id, username: actor.username!, ip: '0.0.0.0', useragent: '' }, ); }, actionPerformed: async ( diff --git a/ee/packages/abac/src/index.ts b/ee/packages/abac/src/index.ts index b75901ecc7176..6d3c236e6a9db 100644 --- a/ee/packages/abac/src/index.ts +++ b/ee/packages/abac/src/index.ts @@ -643,7 +643,7 @@ export class AbacService extends ServiceClass implements IAbacService { const userRemovalPromises = []; for await (const doc of cursor) { usersToRemove.push(doc._id); - void Audit.actionPerformed({ _id: doc._id, username: doc.username }, { _id: rid }); + void Audit.actionPerformed({ _id: doc._id, username: doc.username }, { _id: rid }, 'room-attributes-change'); userRemovalPromises.push( limit(() => Room.removeUserFromRoom(rid, doc, { @@ -654,13 +654,6 @@ export class AbacService extends ServiceClass implements IAbacService { ); } - this.logger.debug({ - msg: 'Room ABAC attributes changed', - rid, - newAttributes, - usersToRemove, - }); - if (!usersToRemove.length) { return; } From 994cdf40a49b74df72124958d52aa0b934a5575d Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Fri, 21 Nov 2025 11:58:27 -0600 Subject: [PATCH 05/15] fix unit --- .../abac/src/user-auto-removal.spec.ts | 109 +++++++++--------- 1 file changed, 52 insertions(+), 57 deletions(-) diff --git a/ee/packages/abac/src/user-auto-removal.spec.ts b/ee/packages/abac/src/user-auto-removal.spec.ts index 875cff2cb7679..f2b68080acf1c 100644 --- a/ee/packages/abac/src/user-auto-removal.spec.ts +++ b/ee/packages/abac/src/user-auto-removal.spec.ts @@ -4,6 +4,7 @@ import type { Collection, Db } from 'mongodb'; import { MongoClient } from 'mongodb'; import { MongoMemoryServer } from 'mongodb-memory-server'; +import { Audit } from './audit'; import { AbacService } from './index'; jest.mock('@rocket.chat/core-services', () => ({ @@ -77,6 +78,8 @@ describe('AbacService integration (onRoomAttributesChanged)', () => { }; let debugSpy: jest.SpyInstance; + let auditSpy: jest.SpyInstance; + beforeAll(async () => { mongo = await MongoMemoryServer.create(); client = await MongoClient.connect(mongo.getUri(), {}); @@ -87,6 +90,7 @@ describe('AbacService integration (onRoomAttributesChanged)', () => { service = new AbacService(); debugSpy = jest.spyOn((service as any).logger, 'debug').mockImplementation(() => undefined); + auditSpy = jest.spyOn(Audit, 'actionPerformed').mockResolvedValue(); roomsCol = db.collection('rocketchat_room'); usersCol = db.collection('users'); @@ -101,6 +105,7 @@ describe('AbacService integration (onRoomAttributesChanged)', () => { beforeEach(async () => { await Promise.all([roomsCol.deleteMany({}), usersCol.deleteMany({}), defsCol.deleteMany({})]); debugSpy.mockClear(); + auditSpy.mockClear(); }); describe('setRoomAbacAttributes - new key addition', () => { @@ -117,25 +122,22 @@ describe('AbacService integration (onRoomAttributesChanged)', () => { ]), ]); - const debugSpy = (service as any).logger.debug as jest.Mock; const changeSpy = jest.spyOn(service as any, 'onRoomAttributesChanged'); await service.setRoomAbacAttributes(rid, { dept: ['eng', 'sales'] }, fakeActor); - // Assert the protected hook received the full room object (first arg) instead of just the id expect(changeSpy).toHaveBeenCalledTimes(1); expect(changeSpy.mock.calls[0][0]).toMatchObject({ _id: rid }); expect(Array.isArray((changeSpy as any).mock.calls[0][0].abacAttributes)).toBe(true); - const evaluationCalls = debugSpy.mock.calls.map((c) => c[0]).filter((arg) => arg && arg.msg === 'Room ABAC attributes changed'); - - expect(evaluationCalls.length).toBe(1); - const payload = evaluationCalls[0]; - expect(payload.rid).toBe(rid); - expect(payload.newAttributes).toEqual([{ key: 'dept', values: ['eng', 'sales'] }]); - expect(payload.usersToRemove.sort()).toEqual(['u2', 'u3']); // only non compliant + expect(auditSpy).toHaveBeenCalledTimes(2); + const auditedUsers = auditSpy.mock.calls.map((call) => call[0]._id).sort(); + const auditedRooms = new Set(auditSpy.mock.calls.map((call) => call[1]._id)); + const auditedActions = new Set(auditSpy.mock.calls.map((call) => call[2])); + expect(auditedUsers).toEqual(['u2', 'u3']); + expect(auditedRooms).toEqual(new Set([rid])); + expect(auditedActions).toEqual(new Set(['room-attributes-change'])); - // Assert membership actually updated const remaining = await usersCol .find({ _id: { $in: ['u1', 'u2', 'u3', 'u4'] } }, { projection: { __rooms: 1 } }) .toArray() @@ -156,14 +158,12 @@ describe('AbacService integration (onRoomAttributesChanged)', () => { ]), ]); - const debugSpy = (service as any).logger.debug as jest.Mock; await service.setRoomAbacAttributes(rid, { dept: ['eng', 'eng', 'sales'] }, fakeActor); - const evaluationCalls = debugSpy.mock.calls - .map((c: any[]) => c[0]) - .filter((arg: any) => arg && arg.msg === 'Room ABAC attributes changed'); - expect(evaluationCalls.length).toBe(1); - expect(evaluationCalls[0].usersToRemove.sort()).toEqual(['u2']); + expect(auditSpy).toHaveBeenCalledTimes(1); + expect(auditSpy.mock.calls[0][0]).toMatchObject({ _id: 'u2', username: 'u2' }); + expect(auditSpy.mock.calls[0][1]).toMatchObject({ _id: rid }); + expect(auditSpy.mock.calls[0][2]).toBe('room-attributes-change'); const u1 = await usersCol.findOne({ _id: 'u1' }, { projection: { __rooms: 1 } }); const u2 = await usersCol.findOne({ _id: 'u2' }, { projection: { __rooms: 1 } }); @@ -184,14 +184,12 @@ describe('AbacService integration (onRoomAttributesChanged)', () => { ]), ]); - const debugSpy = (service as any).logger.debug as jest.Mock; await service.updateRoomAbacAttributeValues(rid, 'dept', ['eng', 'sales'], fakeActor); - const evaluationCalls = debugSpy.mock.calls - .map((c: any[]) => c[0]) - .filter((arg: any) => arg && arg.msg === 'Room ABAC attributes changed'); - expect(evaluationCalls.length).toBe(1); - expect(evaluationCalls[0].usersToRemove.sort()).toEqual(['u2']); + expect(auditSpy).toHaveBeenCalledTimes(1); + expect(auditSpy.mock.calls[0][0]).toMatchObject({ _id: 'u2', username: 'u2' }); + expect(auditSpy.mock.calls[0][1]).toMatchObject({ _id: rid }); + expect(auditSpy.mock.calls[0][2]).toBe('room-attributes-change'); const users = await usersCol .find({ _id: { $in: ['u1', 'u2', 'u3'] } }, { projection: { __rooms: 1 } }) @@ -212,13 +210,9 @@ describe('AbacService integration (onRoomAttributesChanged)', () => { ]), ]); - const debugSpy = (service as any).logger.debug as jest.Mock; await service.updateRoomAbacAttributeValues(rid, 'dept', ['eng'], fakeActor); // removal only - const evaluationCalls = debugSpy.mock.calls - .map((c: any[]) => c[0]) - .filter((arg: any) => arg && arg.msg === 'Re-evaluating room subscriptions'); - expect(evaluationCalls.length).toBe(0); + expect(auditSpy).not.toHaveBeenCalled(); // nobody removed because removal only does not trigger reevaluation const u1 = await usersCol.findOne({ _id: 'u1' }, { projection: { __rooms: 1 } }); @@ -274,7 +268,6 @@ describe('AbacService integration (onRoomAttributesChanged)', () => { ]), ]); - const debugSpy = (service as any).logger.debug as jest.Mock; await service.setRoomAbacAttributes( rid, { @@ -284,11 +277,13 @@ describe('AbacService integration (onRoomAttributesChanged)', () => { fakeActor, ); - const evaluationCalls = debugSpy.mock.calls - .map((c: any[]) => c[0]) - .filter((arg: any) => arg && arg.msg === 'Room ABAC attributes changed'); - expect(evaluationCalls.length).toBe(1); - expect(evaluationCalls[0].usersToRemove.sort()).toEqual(['u2', 'u3', 'u5']); + expect(auditSpy).toHaveBeenCalledTimes(3); + const auditedUsers = auditSpy.mock.calls.map((call) => call[0]._id).sort(); + expect(auditedUsers).toEqual(['u2', 'u3', 'u5']); + const auditedRooms = new Set(auditSpy.mock.calls.map((call) => call[1]._id)); + expect(auditedRooms).toEqual(new Set([rid])); + const auditedActions = new Set(auditSpy.mock.calls.map((call) => call[2])); + expect(auditedActions).toEqual(new Set(['room-attributes-change'])); const memberships = await usersCol .find({ _id: { $in: ['u1', 'u2', 'u3', 'u4', 'u5'] } }, { projection: { __rooms: 1 } }) @@ -313,11 +308,11 @@ describe('AbacService integration (onRoomAttributesChanged)', () => { ]), ]); - const debugSpy = (service as any).logger.debug as jest.Mock; await service.setRoomAbacAttributes(rid, { dept: ['eng', 'sales'] }, fakeActor); - const firstEval = debugSpy.mock.calls.map((c: any[]) => c[0]).filter((a: any) => a && a.msg === 'Room ABAC attributes changed'); - expect(firstEval.length).toBe(1); - expect(firstEval[0].usersToRemove.sort()).toEqual(['u2']); + expect(auditSpy).toHaveBeenCalledTimes(1); + expect(auditSpy.mock.calls[0][0]).toMatchObject({ _id: 'u2', username: 'u2' }); + expect(auditSpy.mock.calls[0][1]).toMatchObject({ _id: rid }); + expect(auditSpy.mock.calls[0][2]).toBe('room-attributes-change'); let u1 = await usersCol.findOne({ _id: 'u1' }, { projection: { __rooms: 1 } }); let u2 = await usersCol.findOne({ _id: 'u2' }, { projection: { __rooms: 1 } }); @@ -326,10 +321,10 @@ describe('AbacService integration (onRoomAttributesChanged)', () => { // Reset mock counts for clarity debugSpy.mockClear(); + auditSpy.mockClear(); await service.setRoomAbacAttributes(rid, { dept: ['eng', 'sales'] }, fakeActor); - const secondEval = debugSpy.mock.calls.map((c: any[]) => c[0]).filter((a: any) => a && a.msg === 'Room ABAC attributes changed'); - expect(secondEval.length).toBe(0); + expect(auditSpy).not.toHaveBeenCalled(); u1 = await usersCol.findOne({ _id: 'u1' }, { projection: { __rooms: 1 } }); u2 = await usersCol.findOne({ _id: 'u2' }, { projection: { __rooms: 1 } }); @@ -349,14 +344,12 @@ describe('AbacService integration (onRoomAttributesChanged)', () => { ]), ]); - const debugSpy = (service as any).logger.debug as jest.Mock; await service.setRoomAbacAttributes(rid, { dept: ['eng', 'sales', 'hr'] }, fakeActor); - const evaluationCalls = debugSpy.mock.calls - .map((c: any[]) => c[0]) - .filter((arg: any) => arg && arg.msg === 'Room ABAC attributes changed'); - expect(evaluationCalls.length).toBe(1); - expect(evaluationCalls[0].usersToRemove.sort()).toEqual(['u2']); + expect(auditSpy).toHaveBeenCalledTimes(1); + expect(auditSpy.mock.calls[0][0]).toMatchObject({ _id: 'u2', username: 'u2' }); + expect(auditSpy.mock.calls[0][1]).toMatchObject({ _id: rid }); + expect(auditSpy.mock.calls[0][2]).toBe('room-attributes-change'); const u1 = await usersCol.findOne({ _id: 'u1' }, { projection: { __rooms: 1 } }); const u2 = await usersCol.findOne({ _id: 'u2' }, { projection: { __rooms: 1 } }); @@ -375,14 +368,15 @@ describe('AbacService integration (onRoomAttributesChanged)', () => { ]), ]); - const debugSpy = (service as any).logger.debug as jest.Mock; await service.setRoomAbacAttributes(rid, { region: ['emea'] }, fakeActor); - const evaluationCalls = debugSpy.mock.calls - .map((c: any[]) => c[0]) - .filter((arg: any) => arg && arg.msg === 'Room ABAC attributes changed'); - expect(evaluationCalls.length).toBe(1); - expect(evaluationCalls[0].usersToRemove.sort()).toEqual(['u2', 'u3']); + expect(auditSpy).toHaveBeenCalledTimes(2); + const auditedUsers = auditSpy.mock.calls.map((call) => call[0]._id).sort(); + expect(auditedUsers).toEqual(['u2', 'u3']); + const auditedRooms = new Set(auditSpy.mock.calls.map((call) => call[1]._id)); + expect(auditedRooms).toEqual(new Set([rid])); + const auditedActions = new Set(auditSpy.mock.calls.map((call) => call[2])); + expect(auditedActions).toEqual(new Set(['room-attributes-change'])); const memberships = await usersCol .find({ _id: { $in: ['u1', 'u2', 'u3'] } }, { projection: { __rooms: 1 } }) @@ -410,20 +404,21 @@ describe('AbacService integration (onRoomAttributesChanged)', () => { } await insertUsers(bulk); - const debugSpy = (service as any).logger.debug as jest.Mock; await service.setRoomAbacAttributes(rid, { dept: ['eng', 'sales'] }, fakeActor); - const evaluationCalls = debugSpy.mock.calls - .map((c: any[]) => c[0]) - .filter((arg: any) => arg && arg.msg === 'Room ABAC attributes changed'); - expect(evaluationCalls.length).toBe(1); - const removed = evaluationCalls[0].usersToRemove; + expect(auditSpy).toHaveBeenCalledTimes(150); + const removed = auditSpy.mock.calls.map((call) => call[0]._id); expect(removed.length).toBe(150); expect(removed).toContain('u1'); expect(removed).toContain('u299'); expect(removed).not.toContain('u0'); expect(removed).not.toContain('u298'); + const auditedRooms = new Set(auditSpy.mock.calls.map((call) => call[1]._id)); + expect(auditedRooms).toEqual(new Set([rid])); + const auditedActions = new Set(auditSpy.mock.calls.map((call) => call[2])); + expect(auditedActions).toEqual(new Set(['room-attributes-change'])); + const remainingCount = await usersCol.countDocuments({ __rooms: rid }); expect(remainingCount).toBe(150); }); From 5a363a84f961c7003493fe558f779a319c583df3 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Fri, 21 Nov 2025 15:22:10 -0600 Subject: [PATCH 06/15] audit endpoint --- apps/meteor/ee/server/api/abac/index.ts | 55 ++++++++++- apps/meteor/ee/server/api/abac/schemas.ts | 85 ++++++++++++++++- ee/packages/abac/src/audit.ts | 95 ++----------------- .../src/ServerAudit/IAuditServerAbacAction.ts | 80 ++++++++++++++++ packages/core-typings/src/index.ts | 2 + 5 files changed, 229 insertions(+), 88 deletions(-) create mode 100644 packages/core-typings/src/ServerAudit/IAuditServerAbacAction.ts diff --git a/apps/meteor/ee/server/api/abac/index.ts b/apps/meteor/ee/server/api/abac/index.ts index 7ccb1a557ce6f..37c0dc13d5363 100644 --- a/apps/meteor/ee/server/api/abac/index.ts +++ b/apps/meteor/ee/server/api/abac/index.ts @@ -1,8 +1,9 @@ import { Abac } from '@rocket.chat/core-services'; import type { AbacActor } from '@rocket.chat/core-services'; import type { IUser } from '@rocket.chat/core-typings'; -import { Users } from '@rocket.chat/models'; +import { ServerEvents, Users } from '@rocket.chat/models'; import { validateUnauthorizedErrorResponse } from '@rocket.chat/rest-typings/src/v1/Ajv'; +import { convertSubObjectsIntoPaths } from '@rocket.chat/tools'; import { GenericSuccessSchema, @@ -19,6 +20,8 @@ import { GenericErrorSchema, GETAbacRoomsListQueryValidator, GETAbacRoomsResponseValidator, + GETAbacAuditEventsQuerySchema, + GETAbacAuditEventsResponseSchema, } from './schemas'; import { API } from '../../../../app/api/server'; import type { ExtractRoutesFromAPI } from '../../../../app/api/server/ApiClass'; @@ -353,6 +356,56 @@ const abacEndpoints = API.v1 return API.v1.success(result); }, + ) + .get( + 'abac/audit', + { + response: { + 200: GETAbacAuditEventsResponseSchema, + 400: GenericErrorSchema, + 401: validateUnauthorizedErrorResponse, + 403: validateUnauthorizedErrorResponse, + }, + query: GETAbacAuditEventsQuerySchema, + authRequired: true, + permissionsRequired: ['abac-management'], + license: ['abac', 'auditing'], + }, + async function action() { + const { start, end, actor } = this.queryParams; + + const { offset, count } = await getPaginationItems(this.queryParams as Record); + const { sort } = await this.parseJsonQuery(); + const _sort = { ts: sort?.ts ? sort?.ts : -1 }; + + const { cursor, totalCount } = ServerEvents.findPaginated( + { + ...(actor && convertSubObjectsIntoPaths({ actor })), + ts: { + $gte: start ? new Date(start) : new Date(0), + $lte: end ? new Date(end) : new Date(), + }, + t: { + $in: ['abac.attribute.changed', 'abac.object.attribute.changed', 'abac.object.attributes.removed', 'abac.action.performed'], + }, + }, + { + sort: _sort, + skip: offset, + limit: count, + allowDiskUse: true, + }, + ); + + const [events, total] = await Promise.all([cursor.toArray(), totalCount]); + + return API.v1.success({ + events, + count: events.length, + offset, + total, + }); + }, ); export type AbacEndpoints = ExtractRoutesFromAPI; diff --git a/apps/meteor/ee/server/api/abac/schemas.ts b/apps/meteor/ee/server/api/abac/schemas.ts index c32338b3e8c25..4ddddf5cc91b9 100644 --- a/apps/meteor/ee/server/api/abac/schemas.ts +++ b/apps/meteor/ee/server/api/abac/schemas.ts @@ -1,4 +1,4 @@ -import type { IAbacAttribute, IAbacAttributeDefinition, IRoom } from '@rocket.chat/core-typings'; +import type { IAbacAttribute, IAbacAttributeDefinition, IAuditServerActor, IRoom } from '@rocket.chat/core-typings'; import type { PaginatedResult, PaginatedRequest } from '@rocket.chat/rest-typings'; import { ajv } from '@rocket.chat/rest-typings'; @@ -149,6 +149,89 @@ const GetAbacAttributeIsInUseResponse = { export const GETAbacAttributeIsInUseResponseSchema = ajv.compile<{ inUse: boolean }>(GetAbacAttributeIsInUseResponse); +const GetAbacAuditEventsQuerySchemaObject = { + type: 'object', + properties: { + start: { type: 'string', format: 'date-time', nullable: true }, + end: { type: 'string', format: 'date-time', nullable: true }, + offset: { type: 'number', nullable: true }, + count: { type: 'number', nullable: true }, + actor: { + type: 'object', + nullable: true, + properties: { + type: { + type: 'string', + nullable: true, + }, + _id: { + type: 'string', + nullable: true, + }, + username: { + type: 'string', + nullable: true, + }, + ip: { + type: 'string', + nullable: true, + }, + useragent: { + type: 'string', + nullable: true, + }, + reason: { + type: 'string', + nullable: true, + }, + }, + }, + }, + additionalProperties: false, +}; + +export const GETAbacAuditEventsQuerySchema = ajv.compile< + PaginatedRequest<{ + start?: string; + end?: string; + actor?: IAuditServerActor; + }> +>(GetAbacAuditEventsQuerySchemaObject); + +const GetAbacAuditEventsResponseSchemaObject = { + type: 'object', + properties: { + success: { type: 'boolean', enum: [true] }, + events: { + type: 'array', + items: { + type: 'object', + }, + }, + count: { + type: 'number', + description: 'The number of events returned in this response.', + }, + offset: { + type: 'number', + description: 'The number of events that were skipped in this response.', + }, + total: { + type: 'number', + description: 'The total number of events that match the query.', + }, + }, + required: ['events', 'count', 'offset', 'total'], + additionalProperties: false, +}; + +export const GETAbacAuditEventsResponseSchema = ajv.compile<{ + events: Record[]; + count: number; + offset: number; + total: number; +}>(GetAbacAuditEventsResponseSchemaObject); + const PostRoomAbacAttributesBody = { type: 'object', properties: { diff --git a/ee/packages/abac/src/audit.ts b/ee/packages/abac/src/audit.ts index 24574d7e6d4e1..4817f826993d4 100644 --- a/ee/packages/abac/src/audit.ts +++ b/ee/packages/abac/src/audit.ts @@ -3,103 +3,26 @@ import type { ExtractDataToParams, IAbacAttributeDefinition, IAuditServerActor, - IAuditServerEventType, - IRoom, IServerEvents, - IUser, + ValidAbacEvents, + AbacAttributeDefinitionChangeType, + AbacAuditReason, + MinimalRoom, + MinimalUser, } from '@rocket.chat/core-typings'; import { ServerEvents } from '@rocket.chat/models'; -type MinimalUser = Pick; -type MinimalRoom = Pick; - -export type AbacAuditReason = 'ldap-sync' | 'room-attributes-change' | 'system' | 'api' | 'realtime-policy-eval'; - -export type AbacAttributeDefinitionChangeType = - | 'created' - | 'updated' - | 'deleted' - | 'all-deleted' - | 'key-removed' - | 'key-renamed' - | 'value-removed' - | 'key-added' - | 'key-updated'; - -export type AbacAttributeDefinitionDiff = { - added?: string[]; - removed?: string[]; - renamedFrom?: string; -}; - -// Since user attributes can grow without limits, we're only logging the diffs -interface IServerEventAbacSubjectAttributeChanged - extends IAuditServerEventType< - { key: 'subject'; value: MinimalUser } | { key: 'reason'; value: AbacAuditReason } | { key: 'diff'; value: IAbacAttributeDefinition[] } - > { - t: 'abac.subject.attribute.changed'; -} - -interface IServerEventAbacObjectAttributeChanged - extends IAuditServerEventType< - | { key: 'room'; value: MinimalRoom } - | { key: 'reason'; value: AbacAuditReason } - | { key: 'previous'; value: IAbacAttributeDefinition[] } - | { key: 'current'; value: IAbacAttributeDefinition[] | null } - | { key: 'change'; value: AbacAttributeDefinitionChangeType } - > { - t: 'abac.object.attribute.changed'; -} - -interface IServerEventAbacAttributeChanged - extends IAuditServerEventType< - | { key: 'attributeKey'; value: string } - | { key: 'reason'; value: AbacAuditReason } - | { key: 'change'; value: AbacAttributeDefinitionChangeType } - | { key: 'current'; value: IAbacAttributeDefinition | null | undefined } - | { key: 'diff'; value: IAbacAttributeDefinition | undefined } - > { - t: 'abac.attribute.changed'; -} - -interface IServerEventAbacActionPerformed - extends IAuditServerEventType< - | { key: 'action'; value: 'revoked-object-access' } - | { key: 'reason'; value: AbacAuditReason } - | { key: 'subject'; value: MinimalUser | undefined } - | { key: 'object'; value: MinimalRoom | undefined } - > { - t: 'abac.action.performed'; -} - -type ValidEvents = - | 'abac.subject.attribute.changed' - | 'abac.object.attribute.changed' - | 'abac.attribute.changed' - | 'abac.action.performed' - | 'abac.object.attributes.removed'; - -declare module '@rocket.chat/core-typings' { - interface IServerEvents { - 'abac.subject.attribute.changed': IServerEventAbacSubjectAttributeChanged; - 'abac.object.attribute.changed': IServerEventAbacObjectAttributeChanged; - 'abac.attribute.changed': IServerEventAbacAttributeChanged; - 'abac.action.performed': IServerEventAbacActionPerformed; - 'abac.object.attributes.removed': IServerEventAbacObjectAttributeChanged; - } -} - type EventParamsMap = { - [K in ValidEvents]: ExtractDataToParams; + [K in ValidAbacEvents]: ExtractDataToParams; }; -type EventPayload = EventParamsMap[K]; +type EventPayload = EventParamsMap[K]; -export type AbacAuditEventName = ValidEvents; +export type AbacAuditEventName = ValidAbacEvents; export type AbacAuditEventPayload = EventPayload; -async function audit(event: K, payload: EventPayload, actor: IAuditServerActor): Promise { +async function audit(event: K, payload: EventPayload, actor: IAuditServerActor): Promise { return ServerEvents.createAuditServerEvent(event, payload, actor); } diff --git a/packages/core-typings/src/ServerAudit/IAuditServerAbacAction.ts b/packages/core-typings/src/ServerAudit/IAuditServerAbacAction.ts new file mode 100644 index 0000000000000..8f87cd2092ea9 --- /dev/null +++ b/packages/core-typings/src/ServerAudit/IAuditServerAbacAction.ts @@ -0,0 +1,80 @@ +import type { IUser, IRoom, IAuditServerEventType, IAbacAttributeDefinition } from '..'; + +export type MinimalUser = Pick; +export type MinimalRoom = Pick; + +export type AbacAuditReason = 'ldap-sync' | 'room-attributes-change' | 'system' | 'api' | 'realtime-policy-eval'; + +export type AbacAttributeDefinitionChangeType = + | 'created' + | 'updated' + | 'deleted' + | 'all-deleted' + | 'key-removed' + | 'key-renamed' + | 'value-removed' + | 'key-added' + | 'key-updated'; + +export type AbacAttributeDefinitionDiff = { + added?: string[]; + removed?: string[]; + renamedFrom?: string; +}; + +// Since user attributes can grow without limits, we're only logging the diffs +interface IServerEventAbacSubjectAttributeChanged + extends IAuditServerEventType< + { key: 'subject'; value: MinimalUser } | { key: 'reason'; value: AbacAuditReason } | { key: 'diff'; value: IAbacAttributeDefinition[] } + > { + t: 'abac.subject.attribute.changed'; +} + +interface IServerEventAbacObjectAttributeChanged + extends IAuditServerEventType< + | { key: 'room'; value: MinimalRoom } + | { key: 'reason'; value: AbacAuditReason } + | { key: 'previous'; value: IAbacAttributeDefinition[] } + | { key: 'current'; value: IAbacAttributeDefinition[] | null } + | { key: 'change'; value: AbacAttributeDefinitionChangeType } + > { + t: 'abac.object.attribute.changed'; +} + +interface IServerEventAbacAttributeChanged + extends IAuditServerEventType< + | { key: 'attributeKey'; value: string } + | { key: 'reason'; value: AbacAuditReason } + | { key: 'change'; value: AbacAttributeDefinitionChangeType } + | { key: 'current'; value: IAbacAttributeDefinition | null | undefined } + | { key: 'diff'; value: IAbacAttributeDefinition | undefined } + > { + t: 'abac.attribute.changed'; +} + +interface IServerEventAbacActionPerformed + extends IAuditServerEventType< + | { key: 'action'; value: 'revoked-object-access' } + | { key: 'reason'; value: AbacAuditReason } + | { key: 'subject'; value: MinimalUser | undefined } + | { key: 'object'; value: MinimalRoom | undefined } + > { + t: 'abac.action.performed'; +} + +export type ValidAbacEvents = + | 'abac.subject.attribute.changed' + | 'abac.object.attribute.changed' + | 'abac.attribute.changed' + | 'abac.action.performed' + | 'abac.object.attributes.removed'; + +declare module '../IServerEvent' { + interface IServerEvents { + 'abac.subject.attribute.changed': IServerEventAbacSubjectAttributeChanged; + 'abac.object.attribute.changed': IServerEventAbacObjectAttributeChanged; + 'abac.attribute.changed': IServerEventAbacAttributeChanged; + 'abac.action.performed': IServerEventAbacActionPerformed; + 'abac.object.attributes.removed': IServerEventAbacObjectAttributeChanged; + } +} diff --git a/packages/core-typings/src/index.ts b/packages/core-typings/src/index.ts index 2246a3704dae6..9c5bfc5dd64be 100644 --- a/packages/core-typings/src/index.ts +++ b/packages/core-typings/src/index.ts @@ -1,5 +1,6 @@ import './ServerAudit/IAuditServerSettingEvent'; import './ServerAudit/IAuditUserChangedEvent'; +import './ServerAudit/IAuditServerAbacAction'; export * from './ServerAudit/IAuditUserChangedEvent'; export * from './Apps'; @@ -150,5 +151,6 @@ export * from './mediaCalls'; export * from './ICallHistoryItem'; export * from './IAbacAttribute'; export * from './Abac'; +export * from './ServerAudit/IAuditServerAbacAction'; export { schemas } from './Ajv'; From ca4f1f68308c7a07e5b86c4d8c618f66766403cf Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 24 Nov 2025 10:28:05 -0600 Subject: [PATCH 07/15] debug --- .github/workflows/ci.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2727da790aaca..be2fcad4603d8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -260,6 +260,8 @@ jobs: - uses: actions/checkout@v6 - uses: ./.github/actions/meteor-build + env: + YARN_DEBUG_NETWORK: 1 with: node-version: ${{ needs.release-versions.outputs.node-version }} deno-version: ${{ needs.release-versions.outputs.deno-version }} From 8fe915fa0f933535ebd2e45e34269749d8e5c790 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 24 Nov 2025 10:51:13 -0600 Subject: [PATCH 08/15] fix --- .github/workflows/ci.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index be2fcad4603d8..78731eaf4c448 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -261,7 +261,9 @@ jobs: - uses: ./.github/actions/meteor-build env: - YARN_DEBUG_NETWORK: 1 + YARN_HTTP_TIMEOUT: 60000 + YARN_HTTP_RETRY: 5 + YARN_HTTP_RETRY_DELAY: 1000 with: node-version: ${{ needs.release-versions.outputs.node-version }} deno-version: ${{ needs.release-versions.outputs.deno-version }} From 48f29a9e9ae21e2ffb277564c31e3d06d6cfc65d Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 24 Nov 2025 11:01:47 -0600 Subject: [PATCH 09/15] fas: --- .github/workflows/ci.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 78731eaf4c448..c5e3890fac5b5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -263,7 +263,6 @@ jobs: env: YARN_HTTP_TIMEOUT: 60000 YARN_HTTP_RETRY: 5 - YARN_HTTP_RETRY_DELAY: 1000 with: node-version: ${{ needs.release-versions.outputs.node-version }} deno-version: ${{ needs.release-versions.outputs.deno-version }} From 14725b875e396bb05f0125da84c118973731bffb Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 24 Nov 2025 12:57:10 -0600 Subject: [PATCH 10/15] fix test --- ee/packages/abac/src/subject-attributes-validations.spec.ts | 4 ++++ ee/packages/abac/src/user-auto-removal.spec.ts | 3 +++ 2 files changed, 7 insertions(+) diff --git a/ee/packages/abac/src/subject-attributes-validations.spec.ts b/ee/packages/abac/src/subject-attributes-validations.spec.ts index 1c9e7a4aad865..8e83a57cb4370 100644 --- a/ee/packages/abac/src/subject-attributes-validations.spec.ts +++ b/ee/packages/abac/src/subject-attributes-validations.spec.ts @@ -14,6 +14,10 @@ beforeAll(async () => { mongo = await MongoMemoryServer.create(); client = await MongoClient.connect(mongo.getUri(), {}); db = client.db('abac_global'); + + // @ts-expect-error - ignore + await db.collection('abac_dummy_init').insertOne({ _id: 'init', createdAt: new Date() }); + registerServiceModels(db); }, 30_000); diff --git a/ee/packages/abac/src/user-auto-removal.spec.ts b/ee/packages/abac/src/user-auto-removal.spec.ts index f2b68080acf1c..44297ac34097b 100644 --- a/ee/packages/abac/src/user-auto-removal.spec.ts +++ b/ee/packages/abac/src/user-auto-removal.spec.ts @@ -85,6 +85,9 @@ describe('AbacService integration (onRoomAttributesChanged)', () => { client = await MongoClient.connect(mongo.getUri(), {}); db = client.db('abac_integration'); + // @ts-expect-error - ignore + await db.collection('abac_dummy_init').insertOne({ _id: 'init', createdAt: new Date() }); + // Hack to register the models in here with a custom database without having to call every model by one registerServiceModels(db as any); From 045f829b6e71766830d85bd9f793e3ffa9d515c9 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Mon, 24 Nov 2025 14:25:18 -0600 Subject: [PATCH 11/15] revert --- .github/workflows/ci.yml | 3 --- 1 file changed, 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c5e3890fac5b5..2727da790aaca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -260,9 +260,6 @@ jobs: - uses: actions/checkout@v6 - uses: ./.github/actions/meteor-build - env: - YARN_HTTP_TIMEOUT: 60000 - YARN_HTTP_RETRY: 5 with: node-version: ${{ needs.release-versions.outputs.node-version }} deno-version: ${{ needs.release-versions.outputs.deno-version }} From cb5378590a483990a4f99fca95e67e6790861275 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Thu, 27 Nov 2025 13:42:23 -0600 Subject: [PATCH 12/15] fix type --- apps/meteor/ee/server/api/abac/schemas.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/meteor/ee/server/api/abac/schemas.ts b/apps/meteor/ee/server/api/abac/schemas.ts index 4ddddf5cc91b9..3e4b76774567c 100644 --- a/apps/meteor/ee/server/api/abac/schemas.ts +++ b/apps/meteor/ee/server/api/abac/schemas.ts @@ -1,4 +1,4 @@ -import type { IAbacAttribute, IAbacAttributeDefinition, IAuditServerActor, IRoom } from '@rocket.chat/core-typings'; +import type { IAbacAttribute, IAbacAttributeDefinition, IAuditServerActor, IRoom, IServerEvent } from '@rocket.chat/core-typings'; import type { PaginatedResult, PaginatedRequest } from '@rocket.chat/rest-typings'; import { ajv } from '@rocket.chat/rest-typings'; @@ -226,7 +226,7 @@ const GetAbacAuditEventsResponseSchemaObject = { }; export const GETAbacAuditEventsResponseSchema = ajv.compile<{ - events: Record[]; + events: IServerEvent[]; count: number; offset: number; total: number; From 9998ea7772be77fde739bb0228ae3370591dbb58 Mon Sep 17 00:00:00 2001 From: Tasso Date: Fri, 28 Nov 2025 10:45:50 -0300 Subject: [PATCH 13/15] Review type naming --- ee/packages/abac/src/audit.ts | 10 +++++----- .../src/ServerAudit/IAuditServerAbacAction.ts | 13 +++++-------- yarn.lock | 19 ------------------- 3 files changed, 10 insertions(+), 32 deletions(-) diff --git a/ee/packages/abac/src/audit.ts b/ee/packages/abac/src/audit.ts index 4817f826993d4..46c171e2e3150 100644 --- a/ee/packages/abac/src/audit.ts +++ b/ee/packages/abac/src/audit.ts @@ -4,7 +4,7 @@ import type { IAbacAttributeDefinition, IAuditServerActor, IServerEvents, - ValidAbacEvents, + AbacAuditServerEventKey, AbacAttributeDefinitionChangeType, AbacAuditReason, MinimalRoom, @@ -13,16 +13,16 @@ import type { import { ServerEvents } from '@rocket.chat/models'; type EventParamsMap = { - [K in ValidAbacEvents]: ExtractDataToParams; + [K in AbacAuditServerEventKey]: ExtractDataToParams; }; -type EventPayload = EventParamsMap[K]; +type EventPayload = EventParamsMap[K]; -export type AbacAuditEventName = ValidAbacEvents; +export type AbacAuditEventName = AbacAuditServerEventKey; export type AbacAuditEventPayload = EventPayload; -async function audit(event: K, payload: EventPayload, actor: IAuditServerActor): Promise { +async function audit(event: K, payload: EventPayload, actor: IAuditServerActor): Promise { return ServerEvents.createAuditServerEvent(event, payload, actor); } diff --git a/packages/core-typings/src/ServerAudit/IAuditServerAbacAction.ts b/packages/core-typings/src/ServerAudit/IAuditServerAbacAction.ts index 8f87cd2092ea9..fca23a0764699 100644 --- a/packages/core-typings/src/ServerAudit/IAuditServerAbacAction.ts +++ b/packages/core-typings/src/ServerAudit/IAuditServerAbacAction.ts @@ -1,4 +1,4 @@ -import type { IUser, IRoom, IAuditServerEventType, IAbacAttributeDefinition } from '..'; +import type { IUser, IRoom, IAuditServerEventType, IAbacAttributeDefinition, IServerEvents } from '..'; export type MinimalUser = Pick; export type MinimalRoom = Pick; @@ -62,13 +62,6 @@ interface IServerEventAbacActionPerformed t: 'abac.action.performed'; } -export type ValidAbacEvents = - | 'abac.subject.attribute.changed' - | 'abac.object.attribute.changed' - | 'abac.attribute.changed' - | 'abac.action.performed' - | 'abac.object.attributes.removed'; - declare module '../IServerEvent' { interface IServerEvents { 'abac.subject.attribute.changed': IServerEventAbacSubjectAttributeChanged; @@ -78,3 +71,7 @@ declare module '../IServerEvent' { 'abac.object.attributes.removed': IServerEventAbacObjectAttributeChanged; } } + +// Utility type to extract all ABAC-related server event names +// (ensures that only events prefixed with "abac." are included) +export type AbacAuditServerEventKey = Extract; diff --git a/yarn.lock b/yarn.lock index 05bffc33597f4..a953f64e21f0d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -30096,15 +30096,6 @@ __metadata: languageName: node linkType: hard -"p-limit@npm:3.1.0, p-limit@npm:^3.0.1, p-limit@npm:^3.0.2, p-limit@npm:^3.1.0": - version: 3.1.0 - resolution: "p-limit@npm:3.1.0" - dependencies: - yocto-queue: "npm:^0.1.0" - checksum: 10/7c3690c4dbf62ef625671e20b7bdf1cbc9534e83352a2780f165b0d3ceba21907e77ad63401708145ca4e25bfc51636588d89a8c0aeb715e6c37d1c066430360 - languageName: node - linkType: hard - "p-limit@npm:^2.0.0, p-limit@npm:^2.2.0": version: 2.3.0 resolution: "p-limit@npm:2.3.0" @@ -38710,16 +38701,6 @@ __metadata: languageName: node linkType: hard -"yauzl@npm:^3.2.0": - version: 3.2.0 - resolution: "yauzl@npm:3.2.0" - dependencies: - buffer-crc32: "npm:~0.2.3" - pend: "npm:~1.2.0" - checksum: 10/a3cd2bfcf7590673bb35750f2a4e5107e3cc939d32d98a072c0673fe42329e390f471b4a53dbbd72512229099b18aa3b79e6ddb87a73b3a17446080c903a2c4b - languageName: node - linkType: hard - "yn@npm:3.1.1": version: 3.1.1 resolution: "yn@npm:3.1.1" From 84e7662fb057f1f0c58a27a11d953a9fd8798042 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Fri, 28 Nov 2025 08:13:52 -0600 Subject: [PATCH 14/15] fix type --- .../core-services/src/types/IAbacService.ts | 23 +++++++++++-------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/packages/core-services/src/types/IAbacService.ts b/packages/core-services/src/types/IAbacService.ts index 81c7c0ef451fa..ba7b1562a91ba 100644 --- a/packages/core-services/src/types/IAbacService.ts +++ b/packages/core-services/src/types/IAbacService.ts @@ -11,7 +11,7 @@ import type { export type AbacActor = Pick; export interface IAbacService { - addAbacAttribute(attribute: IAbacAttributeDefinition, actor?: AbacActor): Promise; + addAbacAttribute(attribute: IAbacAttributeDefinition, actor: AbacActor | undefined): Promise; listAbacAttributes( filters?: { key?: string; @@ -30,21 +30,24 @@ export interface IAbacService { }, actor?: AbacActor, ): Promise<{ rooms: IRoom[]; offset: number; count: number; total: number }>; - updateAbacAttributeById(_id: string, update: { key?: string; values?: string[] }, actor?: AbacActor): Promise; - deleteAbacAttributeById(_id: string, actor?: AbacActor): Promise; + updateAbacAttributeById(_id: string, update: { key?: string; values?: string[] }, actor: AbacActor | undefined): Promise; + deleteAbacAttributeById(_id: string, actor: AbacActor | undefined): Promise; // Usage represents if the attribute values are in use or not. If no values are in use, the attribute is not in use. - getAbacAttributeById(_id: string, actor?: AbacActor): Promise<{ key: string; values: string[]; usage: Record }>; + getAbacAttributeById( + _id: string, + actor: AbacActor | undefined, + ): Promise<{ key: string; values: string[]; usage: Record }>; isAbacAttributeInUseByKey(key: string): Promise; - setRoomAbacAttributes(rid: string, attributes: Record, actor?: AbacActor): Promise; - removeRoomAbacAttribute(rid: string, key: string, actor?: AbacActor): Promise; - addRoomAbacAttributeByKey(rid: string, key: string, values: string[], actor?: AbacActor): Promise; - replaceRoomAbacAttributeByKey(rid: string, key: string, values: string[], actor?: AbacActor): Promise; - checkUsernamesMatchAttributes(usernames: string[], attributes: IAbacAttributeDefinition[], actor?: AbacActor): Promise; + setRoomAbacAttributes(rid: string, attributes: Record, actor: AbacActor | undefined): Promise; + removeRoomAbacAttribute(rid: string, key: string, actor: AbacActor | undefined): Promise; + addRoomAbacAttributeByKey(rid: string, key: string, values: string[], actor: AbacActor | undefined): Promise; + replaceRoomAbacAttributeByKey(rid: string, key: string, values: string[], actor: AbacActor | undefined): Promise; + checkUsernamesMatchAttributes(usernames: string[], attributes: IAbacAttributeDefinition[], actor: AbacActor | undefined): Promise; canAccessObject( room: Pick, user: Pick, action: AbacAccessOperation, objectType: AbacObjectType, ): Promise; - addSubjectAttributes(user: IUser, ldapUser: ILDAPEntry, map: Record, actor?: AbacActor): Promise; + addSubjectAttributes(user: IUser, ldapUser: ILDAPEntry, map: Record, actor: AbacActor | undefined): Promise; } From 4a34e00e22c0dc56562b4696991ce0ed39f2a493 Mon Sep 17 00:00:00 2001 From: Kevin Aleman Date: Fri, 28 Nov 2025 08:23:52 -0600 Subject: [PATCH 15/15] coderabbit was not that crazy --- ee/packages/abac/src/audit.ts | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/ee/packages/abac/src/audit.ts b/ee/packages/abac/src/audit.ts index 46c171e2e3150..e1b076f188607 100644 --- a/ee/packages/abac/src/audit.ts +++ b/ee/packages/abac/src/audit.ts @@ -116,11 +116,7 @@ export const Audit = { { type: 'user', _id: actor._id, username: actor.username!, ip: '0.0.0.0', useragent: '' }, ); }, - actionPerformed: async ( - subject: MinimalUser | undefined, - object: MinimalRoom | undefined, - reason: AbacAuditReason = 'room-attributes-change', - ) => { + actionPerformed: async (subject: MinimalUser, object: MinimalRoom, reason: AbacAuditReason = 'room-attributes-change') => { return audit( 'abac.action.performed', {