diff --git a/packages/twenty-server/src/engine/workspace-event-emitter/__tests__/workspace-event-emitter.service.spec.ts b/packages/twenty-server/src/engine/workspace-event-emitter/__tests__/workspace-event-emitter.service.spec.ts index 4fa3c95426..23154a59a2 100644 --- a/packages/twenty-server/src/engine/workspace-event-emitter/__tests__/workspace-event-emitter.service.spec.ts +++ b/packages/twenty-server/src/engine/workspace-event-emitter/__tests__/workspace-event-emitter.service.spec.ts @@ -139,6 +139,20 @@ describe('WorkspaceEventEmitterService', () => { ...overrides, }); + const mockFlatWorkspaceMemberMaps = { + byId: { + 'test-workspace-member-id': { + id: 'test-workspace-member-id', + userId: 'test-user-id', + name: { firstName: 'Test', lastName: 'User' }, + locale: 'en', + }, + }, + idByUserId: { + 'test-user-id': 'test-workspace-member-id', + }, + }; + const createPermissionsContext = ( overrides: { flatFieldMetadataMaps?: FlatEntityMaps; @@ -163,6 +177,29 @@ describe('WorkspaceEventEmitterService', () => { rolesPermissions: overrides.rolesPermissions ?? mockRolesPermissions, }); + const createCacheMock = ( + permissionsOverrides: { + flatFieldMetadataMaps?: FlatEntityMaps; + userWorkspaceRoleMap?: Record; + rolesPermissions?: ObjectsPermissionsByRoleId; + } = {}, + workspaceMemberMapsOverride?: { + byId: Record; + idByUserId: Record; + }, + ) => { + return (_workspaceId: string, keys: string[]) => { + if (keys.includes('flatWorkspaceMemberMaps')) { + return Promise.resolve({ + flatWorkspaceMemberMaps: + workspaceMemberMapsOverride ?? mockFlatWorkspaceMemberMaps, + }); + } + + return Promise.resolve(createPermissionsContext(permissionsOverrides)); + }; + }; + beforeEach(async () => { jest.clearAllMocks(); @@ -185,7 +222,7 @@ describe('WorkspaceEventEmitterService', () => { }; mockWorkspaceCacheService = { - getOrRecompute: jest.fn().mockResolvedValue(createPermissionsContext()), + getOrRecompute: jest.fn().mockImplementation(createCacheMock()), }; (buildRowLevelPermissionRecordFilter as jest.Mock).mockReturnValue({}); @@ -278,8 +315,8 @@ describe('WorkspaceEventEmitterService', () => { }, }; - mockWorkspaceCacheService.getOrRecompute.mockResolvedValue( - createPermissionsContext({ rolesPermissions: permissionsWithoutRead }), + mockWorkspaceCacheService.getOrRecompute.mockImplementation( + createCacheMock({ rolesPermissions: permissionsWithoutRead }), ); const eventBatch: WorkspaceEventBatch = { @@ -395,8 +432,8 @@ describe('WorkspaceEventEmitterService', () => { restrictedField, ]); - mockWorkspaceCacheService.getOrRecompute.mockResolvedValue( - createPermissionsContext({ + mockWorkspaceCacheService.getOrRecompute.mockImplementation( + createCacheMock({ flatFieldMetadataMaps: fieldMetadataMapsWithRestricted, rolesPermissions: permissionsWithRestrictedFields, }), @@ -463,8 +500,8 @@ describe('WorkspaceEventEmitterService', () => { restrictedField, ]); - mockWorkspaceCacheService.getOrRecompute.mockResolvedValue( - createPermissionsContext({ + mockWorkspaceCacheService.getOrRecompute.mockImplementation( + createCacheMock({ flatFieldMetadataMaps: fieldMetadataMapsWithRestricted, rolesPermissions: permissionsWithRestrictedFields, }), @@ -524,8 +561,8 @@ describe('WorkspaceEventEmitterService', () => { restrictedField, ]); - mockWorkspaceCacheService.getOrRecompute.mockResolvedValue( - createPermissionsContext({ + mockWorkspaceCacheService.getOrRecompute.mockImplementation( + createCacheMock({ flatFieldMetadataMaps: fieldMetadataMapsWithRestricted, rolesPermissions: permissionsWithRestrictedFields, }), @@ -631,8 +668,8 @@ describe('WorkspaceEventEmitterService', () => { }); it('should not publish when user has no role assigned', async () => { - mockWorkspaceCacheService.getOrRecompute.mockResolvedValue( - createPermissionsContext({ userWorkspaceRoleMap: {} }), + mockWorkspaceCacheService.getOrRecompute.mockImplementation( + createCacheMock({ userWorkspaceRoleMap: {} }), ); const eventBatch: WorkspaceEventBatch = { @@ -847,8 +884,8 @@ describe('WorkspaceEventEmitterService', () => { >, ); - mockWorkspaceCacheService.getOrRecompute.mockResolvedValue( - createPermissionsContext(), + mockWorkspaceCacheService.getOrRecompute.mockImplementation( + createCacheMock(), ); const eventBatch: WorkspaceEventBatch = { @@ -888,8 +925,8 @@ describe('WorkspaceEventEmitterService', () => { >, ); - mockWorkspaceCacheService.getOrRecompute.mockResolvedValue( - createPermissionsContext({ userWorkspaceRoleMap: {} }), + mockWorkspaceCacheService.getOrRecompute.mockImplementation( + createCacheMock({ userWorkspaceRoleMap: {} }), ); const eventBatch: WorkspaceEventBatch = { @@ -943,8 +980,8 @@ describe('WorkspaceEventEmitterService', () => { >, ); - mockWorkspaceCacheService.getOrRecompute.mockResolvedValue( - createPermissionsContext(), + mockWorkspaceCacheService.getOrRecompute.mockImplementation( + createCacheMock(), ); const eventBatch: WorkspaceEventBatch = { @@ -975,6 +1012,147 @@ describe('WorkspaceEventEmitterService', () => { }), ); }); + + it('should pass full workspaceMember data from cache for dynamic predicates', async () => { + const workspaceMemberId = 'test-workspace-member-id'; + const customWorkspaceMember = { + id: workspaceMemberId, + userId: 'test-user-id', + name: { firstName: 'John', lastName: 'Doe' }, + locale: 'en', + colorScheme: 'light', + }; + + const customFlatWorkspaceMemberMaps = { + byId: { + [workspaceMemberId]: customWorkspaceMember, + }, + idByUserId: { + 'test-user-id': workspaceMemberId, + }, + }; + + const rlsFilter: RecordGqlOperationFilter = { + locale: { eq: 'en' }, + }; + + (buildRowLevelPermissionRecordFilter as jest.Mock).mockReturnValue( + rlsFilter, + ); + + const streamDataWithWorkspaceMember: EventStreamData = { + authContext: { + userWorkspaceId, + userId: 'test-user-id', + workspaceMemberId, + }, + workspaceId, + queries: { + 'query-1': { + objectNameSingular: 'company', + variables: {}, + }, + }, + createdAt: Date.now(), + }; + + mockEventStreamService.getStreamsData.mockResolvedValue( + new Map([[streamChannelId, streamDataWithWorkspaceMember]]) as Map< + string, + EventStreamData | undefined + >, + ); + + mockWorkspaceCacheService.getOrRecompute.mockImplementation( + createCacheMock({}, customFlatWorkspaceMemberMaps), + ); + + const eventBatch: WorkspaceEventBatch = { + name: 'company.created', + workspaceId, + objectMetadata: companyObjectMetadata, + events: [ + createMockEvent({ + properties: { + after: { + id: 'record-1', + name: 'Test Company', + locale: 'en', + }, + }, + }), + ], + }; + + await service.publish(eventBatch as WorkspaceEventBatch); + + expect(buildRowLevelPermissionRecordFilter).toHaveBeenCalledWith( + expect.objectContaining({ + authContext: expect.objectContaining({ + userWorkspaceId, + workspaceMemberId, + workspaceMember: customWorkspaceMember, + }), + }), + ); + }); + + it('should pass undefined workspaceMember when workspaceMemberId is not in cache', async () => { + const workspaceMemberId = 'non-existent-workspace-member-id'; + + const emptyFlatWorkspaceMemberMaps = { + byId: {}, + idByUserId: {}, + }; + + (buildRowLevelPermissionRecordFilter as jest.Mock).mockReturnValue({}); + + const streamDataWithWorkspaceMember: EventStreamData = { + authContext: { + userWorkspaceId, + userId: 'test-user-id', + workspaceMemberId, + }, + workspaceId, + queries: { + 'query-1': { + objectNameSingular: 'company', + variables: {}, + }, + }, + createdAt: Date.now(), + }; + + mockEventStreamService.getStreamsData.mockResolvedValue( + new Map([[streamChannelId, streamDataWithWorkspaceMember]]) as Map< + string, + EventStreamData | undefined + >, + ); + + mockWorkspaceCacheService.getOrRecompute.mockImplementation( + createCacheMock({}, emptyFlatWorkspaceMemberMaps), + ); + + const eventBatch: WorkspaceEventBatch = { + name: 'company.created', + workspaceId, + objectMetadata: companyObjectMetadata, + events: [createMockEvent()], + }; + + await service.publish(eventBatch as WorkspaceEventBatch); + + expect(buildRowLevelPermissionRecordFilter).toHaveBeenCalledWith( + expect.objectContaining({ + authContext: expect.objectContaining({ + userWorkspaceId, + workspaceMemberId, + workspaceMember: undefined, + }), + }), + ); + }); }); }); }); diff --git a/packages/twenty-server/src/engine/workspace-event-emitter/workspace-event-emitter.service.ts b/packages/twenty-server/src/engine/workspace-event-emitter/workspace-event-emitter.service.ts index 20cb98fe0b..50cc3631ba 100644 --- a/packages/twenty-server/src/engine/workspace-event-emitter/workspace-event-emitter.service.ts +++ b/packages/twenty-server/src/engine/workspace-event-emitter/workspace-event-emitter.service.ts @@ -9,6 +9,7 @@ import { import { combineFilters, isDefined } from 'twenty-shared/utils'; import { type SerializableAuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { type FlatWorkspaceMemberMaps } from 'src/engine/core-modules/user/types/flat-workspace-member-maps.type'; import { transformEventToWebhookEvent } from 'src/engine/core-modules/webhook/utils/transform-event-to-webhook-event'; import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; @@ -83,6 +84,11 @@ export class WorkspaceEventEmitterService { const permissionsContext = await this.fetchPermissionsContext(workspaceId); + const { flatWorkspaceMemberMaps } = + await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'flatWorkspaceMemberMaps', + ]); + const streamIdsToRemove: string[] = []; for (const [streamChannelId, streamData] of streamsData) { @@ -100,6 +106,7 @@ export class WorkspaceEventEmitterService { streamData, workspaceEventBatch, permissionsContext, + flatWorkspaceMemberMaps, ); } @@ -120,6 +127,7 @@ export class WorkspaceEventEmitterService { userWorkspaceRoleMap: Record; rolesPermissions: ObjectsPermissionsByRoleId; }, + flatWorkspaceMemberMaps: FlatWorkspaceMemberMaps, ): Promise { const { userWorkspaceId } = streamData.authContext; @@ -154,6 +162,7 @@ export class WorkspaceEventEmitterService { roleId, workspaceEventBatch.objectMetadata, permissionsContext, + flatWorkspaceMemberMaps, ); const restrictedFields = objectPermissions.restrictedFields; @@ -220,7 +229,12 @@ export class WorkspaceEventEmitterService { flatRowLevelPermissionPredicateGroupMaps: FlatRowLevelPermissionPredicateGroupMaps; flatFieldMetadataMaps: FlatEntityMaps; }, + flatWorkspaceMemberMaps: FlatWorkspaceMemberMaps, ): RecordGqlOperationFilter | null { + const workspaceMember = isDefined(subscriberAuthContext.workspaceMemberId) + ? flatWorkspaceMemberMaps.byId[subscriberAuthContext.workspaceMemberId] + : undefined; + return buildRowLevelPermissionRecordFilter({ flatRowLevelPermissionPredicateMaps: permissionsContext.flatRowLevelPermissionPredicateMaps, @@ -229,10 +243,10 @@ export class WorkspaceEventEmitterService { flatFieldMetadataMaps: permissionsContext.flatFieldMetadataMaps, objectMetadata, roleId, - // TODO(t.trompette): For dynamic predicates, we would need to load workspaceMember data authContext: { userWorkspaceId: subscriberAuthContext.userWorkspaceId, workspaceMemberId: subscriberAuthContext.workspaceMemberId, + workspaceMember, }, }); }