From 62c1cf78afe9ef5c7a783fef5da15c7a450aeeb2 Mon Sep 17 00:00:00 2001 From: Thomas Trompette Date: Mon, 3 Aug 2026 18:07:54 +0200 Subject: [PATCH] fix(server): scope personal favorite SSE events to their owner (#23712) ## Problem Fixes #20483. In a multi-user workspace, when a user creates/updates/deletes a **personal favorite** (a `navigationMenuItem` with a non-null `userWorkspaceId`), the metadata SSE event is broadcast workspace-wide. Every other connected user receives it and the favorite pops into their own sidebar in real time. Cross-user data-isolation leak. ## Root cause The delivery filter in `WorkspaceEventBroadcaster` already supports per-user scoping via `recipientUserWorkspaceIds`, but treats an **undefined** list as workspace-wide (delivered to every stream). `MetadataEventPublisher.publish` never set that field, so every favorite event fell into the workspace-wide default. The `agentChatThread` path already sets `recipientUserWorkspaceIds` explicitly and is scoped correctly; favorites simply never opted in. ## Fix In `MetadataEventPublisher`, resolve the owning `userWorkspaceId` for `navigationMenuItem` events (from `properties.after` on create/update, `properties.before` on delete) and set `recipientUserWorkspaceIds: [userWorkspaceId]` when present. Workspace-level items (`userWorkspaceId === null`) leave it unset and keep broadcasting to everyone. This mirrors the existing `agentChatThread` precedent and touches only the producer, not the broadcaster or any consumer. ## Testing Unit test (`metadata-event-publisher.spec.ts`) covers personal create/update/delete (scoped to owner), workspace-level (unscoped), and an unrelated metadata entity carrying a user id (unscoped). Also verified end to end against a local multi-user workspace (Tim and Jane, same workspace): each opened a live SSE stream (`/metadata` `onEventSubscription`) and Tim created favorites. | Case | Before fix | After fix | |------|-----------|-----------| | Personal favorite -> owner (Tim) | receives | receives | | Personal favorite -> other user (Jane) | **receives (leak)** | not received | | Workspace-level favorite -> other user (Jane) | receives | receives | - `nx typecheck twenty-server`: pass - oxlint + oxfmt on changed files: clean ## Scope / follow-up Favorites only. Two related items are intentionally out of scope and worth tracking separately: scoping other user-owned metadata (`view` via its visibility rules, `roleTarget`), and making the broadcaster's "no recipient list = everyone" default explicit rather than fail-open. --- .../metadata-event-publisher.spec.ts | 172 ++++++++++++++++++ .../metadata-event-publisher.ts | 36 +++- 2 files changed, 202 insertions(+), 6 deletions(-) create mode 100644 packages/twenty-server/src/engine/subscriptions/metadata-event/__tests__/metadata-event-publisher.spec.ts diff --git a/packages/twenty-server/src/engine/subscriptions/metadata-event/__tests__/metadata-event-publisher.spec.ts b/packages/twenty-server/src/engine/subscriptions/metadata-event/__tests__/metadata-event-publisher.spec.ts new file mode 100644 index 0000000000..76543f6c25 --- /dev/null +++ b/packages/twenty-server/src/engine/subscriptions/metadata-event/__tests__/metadata-event-publisher.spec.ts @@ -0,0 +1,172 @@ +import { Test, type TestingModule } from '@nestjs/testing'; + +import { I18nService } from 'src/engine/core-modules/i18n/i18n.service'; +import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; +import { NavigationMenuItemRecordIdentifierService } from 'src/engine/metadata-modules/navigation-menu-item/services/navigation-menu-item-record-identifier.service'; +import { MetadataEventPublisher } from 'src/engine/subscriptions/metadata-event/metadata-event-publisher'; +import { type MetadataEventBatch } from 'src/engine/subscriptions/metadata-event/types/metadata-event-batch.type'; +import { WorkspaceEventBroadcaster } from 'src/engine/subscriptions/workspace-event-broadcaster/workspace-event-broadcaster.service'; + +const OWNER_USER_WORKSPACE_ID = '20202020-0000-0000-0000-000000000001'; + +describe('MetadataEventPublisher', () => { + let publisher: MetadataEventPublisher; + const broadcast = jest.fn(); + + beforeEach(async () => { + jest.clearAllMocks(); + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + MetadataEventPublisher, + { provide: WorkspaceEventBroadcaster, useValue: { broadcast } }, + { + provide: WorkspaceManyOrAllFlatEntityMapsCacheService, + useValue: {}, + }, + { provide: NavigationMenuItemRecordIdentifierService, useValue: {} }, + { provide: I18nService, useValue: {} }, + ], + }).compile(); + + publisher = module.get(MetadataEventPublisher); + }); + + const publishAndGetFirstEvent = async (batch: object) => { + await publisher.publish(batch as unknown as MetadataEventBatch); + + return broadcast.mock.calls[0][0].events[0]; + }; + + it('scopes a personal favorite create event to its owner', async () => { + const event = await publishAndGetFirstEvent({ + name: 'metadata.navigationMenuItem.created', + workspaceId: 'workspace-1', + metadataName: 'navigationMenuItem', + type: 'created', + events: [ + { + metadataName: 'navigationMenuItem', + type: 'created', + recordId: 'nav-1', + properties: { + after: { id: 'nav-1', userWorkspaceId: OWNER_USER_WORKSPACE_ID }, + }, + }, + ], + }); + + expect(event.recipientUserWorkspaceIds).toEqual([OWNER_USER_WORKSPACE_ID]); + }); + + it('scopes a personal favorite update event to its owner', async () => { + const event = await publishAndGetFirstEvent({ + name: 'metadata.navigationMenuItem.updated', + workspaceId: 'workspace-1', + metadataName: 'navigationMenuItem', + type: 'updated', + events: [ + { + metadataName: 'navigationMenuItem', + type: 'updated', + recordId: 'nav-1', + properties: { + updatedFields: ['name'], + diff: {}, + before: { id: 'nav-1', userWorkspaceId: OWNER_USER_WORKSPACE_ID }, + after: { id: 'nav-1', userWorkspaceId: OWNER_USER_WORKSPACE_ID }, + }, + }, + ], + }); + + expect(event.recipientUserWorkspaceIds).toEqual([OWNER_USER_WORKSPACE_ID]); + }); + + it('scopes a personal favorite delete event to its owner using the pre-delete record', async () => { + const event = await publishAndGetFirstEvent({ + name: 'metadata.navigationMenuItem.deleted', + workspaceId: 'workspace-1', + metadataName: 'navigationMenuItem', + type: 'deleted', + events: [ + { + metadataName: 'navigationMenuItem', + type: 'deleted', + recordId: 'nav-1', + properties: { + before: { id: 'nav-1', userWorkspaceId: OWNER_USER_WORKSPACE_ID }, + }, + }, + ], + }); + + expect(event.recipientUserWorkspaceIds).toEqual([OWNER_USER_WORKSPACE_ID]); + }); + + it('broadcasts a workspace-level favorite (null owner) to everyone', async () => { + const event = await publishAndGetFirstEvent({ + name: 'metadata.navigationMenuItem.created', + workspaceId: 'workspace-1', + metadataName: 'navigationMenuItem', + type: 'created', + events: [ + { + metadataName: 'navigationMenuItem', + type: 'created', + recordId: 'nav-1', + properties: { + after: { id: 'nav-1', userWorkspaceId: null }, + }, + }, + ], + }); + + expect(event.recipientUserWorkspaceIds).toBeUndefined(); + }); + + it('does not scope a favorite with an empty-string owner', async () => { + const event = await publishAndGetFirstEvent({ + name: 'metadata.navigationMenuItem.created', + workspaceId: 'workspace-1', + metadataName: 'navigationMenuItem', + type: 'created', + events: [ + { + metadataName: 'navigationMenuItem', + type: 'created', + recordId: 'nav-1', + properties: { + after: { id: 'nav-1', userWorkspaceId: '' }, + }, + }, + ], + }); + + expect(event.recipientUserWorkspaceIds).toBeUndefined(); + }); + + it('does not scope unrelated metadata even when it carries a userWorkspaceId', async () => { + const event = await publishAndGetFirstEvent({ + name: 'metadata.view.created', + workspaceId: 'workspace-1', + metadataName: 'view', + type: 'created', + events: [ + { + metadataName: 'view', + type: 'created', + recordId: 'view-1', + properties: { + after: { + id: 'view-1', + createdByUserWorkspaceId: OWNER_USER_WORKSPACE_ID, + }, + }, + }, + ], + }); + + expect(event.recipientUserWorkspaceIds).toBeUndefined(); + }); +}); diff --git a/packages/twenty-server/src/engine/subscriptions/metadata-event/metadata-event-publisher.ts b/packages/twenty-server/src/engine/subscriptions/metadata-event/metadata-event-publisher.ts index e55ccce51c..b35c365c54 100644 --- a/packages/twenty-server/src/engine/subscriptions/metadata-event/metadata-event-publisher.ts +++ b/packages/twenty-server/src/engine/subscriptions/metadata-event/metadata-event-publisher.ts @@ -1,5 +1,6 @@ import { Injectable } from '@nestjs/common'; +import { isNonEmptyString } from '@sniptt/guards'; import { SOURCE_LOCALE } from 'twenty-shared/translations'; import { isDefined, isNonEmptyArray } from 'twenty-shared/utils'; @@ -34,15 +35,38 @@ export class MetadataEventPublisher { await this.workspaceEventBroadcaster.broadcast({ workspaceId: enrichedBatch.workspaceId, updatedCollectionHash: enrichedBatch.updatedCollectionHash, - events: enrichedBatch.events.map((event) => ({ - type: event.type, - entityName: event.metadataName, - recordId: event.recordId, - properties: event.properties as Record, - })), + events: enrichedBatch.events.map((event) => { + const ownerUserWorkspaceId = this.resolveOwnerUserWorkspaceId(event); + + return { + type: event.type, + entityName: event.metadataName, + recordId: event.recordId, + properties: event.properties as Record, + recipientUserWorkspaceIds: isNonEmptyString(ownerUserWorkspaceId) + ? [ownerUserWorkspaceId] + : undefined, + }; + }), }); } + private resolveOwnerUserWorkspaceId( + event: MetadataEventBatch['events'][number], + ): string | undefined { + if (event.metadataName !== 'navigationMenuItem') { + return undefined; + } + + const record = ( + event.type === 'deleted' + ? event.properties.before + : event.properties.after + ) as { userWorkspaceId?: string | null } | undefined; + + return record?.userWorkspaceId ?? undefined; + } + private async enrichMetadataEventBatch( metadataEventBatch: MetadataEventBatch, ): Promise {