Handle dynamic predicates (#17287)

Fetch and send full workspace member to handle dynamic predicate. I only
tested that it did not break the current logic since the frontend does
not yet send queries we dynamic predicates.
This commit is contained in:
Thomas Trompette
2026-01-20 15:57:36 +01:00
committed by GitHub
parent 02a181a49f
commit e599d6c4d0
2 changed files with 210 additions and 18 deletions
@@ -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<FlatFieldMetadata>;
@@ -163,6 +177,29 @@ describe('WorkspaceEventEmitterService', () => {
rolesPermissions: overrides.rolesPermissions ?? mockRolesPermissions,
});
const createCacheMock = (
permissionsOverrides: {
flatFieldMetadataMaps?: FlatEntityMaps<FlatFieldMetadata>;
userWorkspaceRoleMap?: Record<string, string>;
rolesPermissions?: ObjectsPermissionsByRoleId;
} = {},
workspaceMemberMapsOverride?: {
byId: Record<string, unknown>;
idByUserId: Record<string, string>;
},
) => {
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<MockObjectRecordEvent> = {
@@ -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<MockObjectRecordEvent> = {
@@ -847,8 +884,8 @@ describe('WorkspaceEventEmitterService', () => {
>,
);
mockWorkspaceCacheService.getOrRecompute.mockResolvedValue(
createPermissionsContext(),
mockWorkspaceCacheService.getOrRecompute.mockImplementation(
createCacheMock(),
);
const eventBatch: WorkspaceEventBatch<MockObjectRecordEvent> = {
@@ -888,8 +925,8 @@ describe('WorkspaceEventEmitterService', () => {
>,
);
mockWorkspaceCacheService.getOrRecompute.mockResolvedValue(
createPermissionsContext({ userWorkspaceRoleMap: {} }),
mockWorkspaceCacheService.getOrRecompute.mockImplementation(
createCacheMock({ userWorkspaceRoleMap: {} }),
);
const eventBatch: WorkspaceEventBatch<MockObjectRecordEvent> = {
@@ -943,8 +980,8 @@ describe('WorkspaceEventEmitterService', () => {
>,
);
mockWorkspaceCacheService.getOrRecompute.mockResolvedValue(
createPermissionsContext(),
mockWorkspaceCacheService.getOrRecompute.mockImplementation(
createCacheMock(),
);
const eventBatch: WorkspaceEventBatch<MockObjectRecordEvent> = {
@@ -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<MockObjectRecordEvent> = {
name: 'company.created',
workspaceId,
objectMetadata: companyObjectMetadata,
events: [
createMockEvent({
properties: {
after: {
id: 'record-1',
name: 'Test Company',
locale: 'en',
},
},
}),
],
};
await service.publish(eventBatch as WorkspaceEventBatch<never>);
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<MockObjectRecordEvent> = {
name: 'company.created',
workspaceId,
objectMetadata: companyObjectMetadata,
events: [createMockEvent()],
};
await service.publish(eventBatch as WorkspaceEventBatch<never>);
expect(buildRowLevelPermissionRecordFilter).toHaveBeenCalledWith(
expect.objectContaining({
authContext: expect.objectContaining({
userWorkspaceId,
workspaceMemberId,
workspaceMember: undefined,
}),
}),
);
});
});
});
});
@@ -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<string, string>;
rolesPermissions: ObjectsPermissionsByRoleId;
},
flatWorkspaceMemberMaps: FlatWorkspaceMemberMaps,
): Promise<void> {
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<FlatFieldMetadata>;
},
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,
},
});
}