Add missing overrides on entityManager (#12471)

In this PR

1. Add missing override of insert() method on
WorkspaceSelectQueryBuilder to return our custom
WorkspaceInsertQueryBuilder with permission checks.
2. Replace override implementation of methods on WorkspaceEntityManager
that call createQueryBuilder at a nested internal layer of typeORM (i.e.
not directly in the initial implementation of EntityManager - unlike
findBy for instance -, but in calls done under the hood at a level which
would force us to override entire other classes to pass on our
permissionOptions. It is the case for methods which call typeORM's
EntityPersistExecutor for instance.), to validate permissions and then
allow the subsequent calls to be made without permission checks
3. adapt tests

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Marie
2025-06-11 12:50:10 +02:00
committed by GitHub
parent 0b406042a1
commit beba4b8313
6 changed files with 749 additions and 421 deletions

View File

@ -1,3 +1,4 @@
import { Entity } from '@microsoft/microsoft-graph-types';
import { isDefined } from 'class-validator'; import { isDefined } from 'class-validator';
import { ObjectRecordsPermissionsByRoleId } from 'twenty-shared/types'; import { ObjectRecordsPermissionsByRoleId } from 'twenty-shared/types';
import { import {
@ -9,6 +10,7 @@ import {
ReplicationMode, ReplicationMode,
SelectQueryBuilder, SelectQueryBuilder,
} from 'typeorm'; } from 'typeorm';
import { EntityManagerFactory } from 'typeorm/entity-manager/EntityManagerFactory';
import { FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface'; import { FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface';
import { WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/workspace-internal-context.interface'; import { WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/workspace-internal-context.interface';
@ -33,6 +35,7 @@ export class WorkspaceDataSource extends DataSource {
featureFlagMap: FeatureFlagMap; featureFlagMap: FeatureFlagMap;
rolesPermissionsVersion: string; rolesPermissionsVersion: string;
permissionsPerRoleId: ObjectRecordsPermissionsByRoleId; permissionsPerRoleId: ObjectRecordsPermissionsByRoleId;
dataSourceWithOverridenCreateQueryBuilder: WorkspaceDataSource;
constructor( constructor(
internalContext: WorkspaceInternalContext, internalContext: WorkspaceInternalContext,
@ -90,6 +93,58 @@ export class WorkspaceDataSource extends DataSource {
return queryRunner as any as WorkspaceQueryRunner; return queryRunner as any as WorkspaceQueryRunner;
} }
// Do not use, only for specific permission-related purpose
createQueryRunnerForEntityPersistExecutor(
mode = 'master' as ReplicationMode,
) {
if (this.dataSourceWithOverridenCreateQueryBuilder) {
const queryRunner = this.driver.createQueryRunner(mode);
const manager = new EntityManagerFactory().create(
this.dataSourceWithOverridenCreateQueryBuilder,
queryRunner,
);
Object.assign(queryRunner, { manager: manager });
return queryRunner;
}
const dataSourceWithOverridenCreateQueryBuilder = Object.assign(
Object.create(Object.getPrototypeOf(this)),
this,
{
createQueryBuilder: (
entityOrRunner: EntityTarget<Entity> | QueryRunner,
alias?: string,
queryRunner?: QueryRunner,
) => {
if (isDefined(alias) && typeof alias === 'string') {
const entity = entityOrRunner as EntityTarget<Entity>;
return this.createQueryBuilder(entity, alias, queryRunner, {
calledByWorkspaceEntityManager: true,
});
} else {
const runner = entityOrRunner as QueryRunner;
return this.createQueryBuilder(runner, {
calledByWorkspaceEntityManager: true,
});
}
},
},
);
const queryRunner = this.driver.createQueryRunner(mode);
const manager = new EntityManagerFactory().create(
dataSourceWithOverridenCreateQueryBuilder,
queryRunner,
);
Object.assign(queryRunner, { manager: manager });
return queryRunner;
}
override createQueryBuilder<Entity extends ObjectLiteral>( override createQueryBuilder<Entity extends ObjectLiteral>(
entityClass: EntityTarget<Entity>, entityClass: EntityTarget<Entity>,
alias: string, alias: string,

View File

@ -1,5 +1,7 @@
import { ObjectRecordsPermissions } from 'twenty-shared/types'; import { ObjectRecordsPermissions } from 'twenty-shared/types';
import { EntityManager } from 'typeorm'; import { EntityManager } from 'typeorm';
import { EntityPersistExecutor } from 'typeorm/persistence/EntityPersistExecutor';
import { PlainObjectToDatabaseEntityTransformer } from 'typeorm/query-builder/transformer/PlainObjectToDatabaseEntityTransformer';
import { WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/workspace-internal-context.interface'; import { WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/workspace-internal-context.interface';
@ -13,6 +15,19 @@ jest.mock('src/engine/twenty-orm/repository/permissions.utils', () => ({
validateOperationIsPermittedOrThrow: jest.fn(), validateOperationIsPermittedOrThrow: jest.fn(),
})); }));
const mockedWorkspaceUpdateQueryBuilder = {
set: jest.fn().mockImplementation(() => ({
where: jest.fn().mockReturnThis(),
whereInIds: jest.fn().mockReturnThis(),
execute: jest
.fn()
.mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] }),
})),
execute: jest
.fn()
.mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] }),
};
jest.mock('../repository/workspace-select-query-builder', () => ({ jest.mock('../repository/workspace-select-query-builder', () => ({
WorkspaceSelectQueryBuilder: jest.fn().mockImplementation(() => ({ WorkspaceSelectQueryBuilder: jest.fn().mockImplementation(() => ({
where: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(),
@ -23,6 +38,8 @@ jest.mock('../repository/workspace-select-query-builder', () => ({
.fn() .fn()
.mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] }), .mockResolvedValue({ affected: 1, raw: [], generatedMaps: [] }),
setFindOptions: jest.fn().mockReturnThis(), setFindOptions: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnValue(mockedWorkspaceUpdateQueryBuilder),
insert: jest.fn().mockReturnThis(),
})), })),
})); }));
@ -96,6 +113,14 @@ describe('WorkspaceEntityManager', () => {
release: jest.fn(), release: jest.fn(),
clearTable: jest.fn(), clearTable: jest.fn(),
}), }),
createQueryRunnerForEntityPersistExecutor: jest.fn().mockReturnValue({
connect: jest.fn(),
startTransaction: jest.fn(),
commitTransaction: jest.fn(),
rollbackTransaction: jest.fn(),
release: jest.fn(),
clearTable: jest.fn(),
}),
}; };
entityManager = new WorkspaceEntityManager( entityManager = new WorkspaceEntityManager(
@ -142,6 +167,14 @@ describe('WorkspaceEntityManager', () => {
.spyOn(EntityManager.prototype, 'preload') .spyOn(EntityManager.prototype, 'preload')
.mockImplementation(() => Promise.resolve({})); .mockImplementation(() => Promise.resolve({}));
jest
.spyOn(EntityPersistExecutor.prototype, 'execute')
.mockImplementation(() => Promise.resolve());
jest
.spyOn(PlainObjectToDatabaseEntityTransformer.prototype, 'transform')
.mockImplementation(() => Promise.resolve({}));
// Mock metadata methods // Mock metadata methods
const mockMetadata = { const mockMetadata = {
hasAllPrimaryKeys: jest.fn().mockReturnValue(true), hasAllPrimaryKeys: jest.fn().mockReturnValue(true),
@ -202,20 +235,14 @@ describe('WorkspaceEntityManager', () => {
}); });
describe('Update Methods', () => { describe('Update Methods', () => {
it('should call validatePermissions and validateOperationIsPermittedOrThrow for update', async () => { it('should call createQueryBuilder with permissionOptions for update', async () => {
await entityManager.update('test-entity', {}, {}, mockPermissionOptions); await entityManager.update('test-entity', {}, {}, mockPermissionOptions);
expect(entityManager['validatePermissions']).toHaveBeenCalledWith( expect(entityManager['createQueryBuilder']).toHaveBeenCalledWith(
'test-entity', undefined,
'update', undefined,
undefined,
mockPermissionOptions, mockPermissionOptions,
); );
expect(validateOperationIsPermittedOrThrow).toHaveBeenCalledWith({
entityName: 'test-entity',
operationType: 'update',
objectMetadataMaps: mockInternalContext.objectMetadataMaps,
objectRecordsPermissions:
mockPermissionOptions.objectRecordsPermissions,
});
}); });
}); });
@ -235,21 +262,5 @@ describe('WorkspaceEntityManager', () => {
mockPermissionOptions.objectRecordsPermissions, mockPermissionOptions.objectRecordsPermissions,
}); });
}); });
it('should call validatePermissions and validateOperationIsPermittedOrThrow for preload', async () => {
await entityManager.preload('test-entity', {}, mockPermissionOptions);
expect(entityManager['validatePermissions']).toHaveBeenCalledWith(
'test-entity',
'select',
mockPermissionOptions,
);
expect(validateOperationIsPermittedOrThrow).toHaveBeenCalledWith({
entityName: 'test-entity',
operationType: 'select',
objectMetadataMaps: mockInternalContext.objectMetadataMaps,
objectRecordsPermissions:
mockPermissionOptions.objectRecordsPermissions,
});
});
}); });
}); });

View File

@ -10,6 +10,7 @@ import {
} from 'src/engine/metadata-modules/permissions/permissions.exception'; } from 'src/engine/metadata-modules/permissions/permissions.exception';
import { validateQueryIsPermittedOrThrow } from 'src/engine/twenty-orm/repository/permissions.utils'; import { validateQueryIsPermittedOrThrow } from 'src/engine/twenty-orm/repository/permissions.utils';
import { WorkspaceDeleteQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-delete-query-builder'; import { WorkspaceDeleteQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-delete-query-builder';
import { WorkspaceInsertQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-insert-query-builder';
import { WorkspaceSoftDeleteQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-soft-delete-query-builder'; import { WorkspaceSoftDeleteQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-soft-delete-query-builder';
import { WorkspaceUpdateQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-update-query-builder'; import { WorkspaceUpdateQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-update-query-builder';
@ -99,6 +100,17 @@ export class WorkspaceSelectQueryBuilder<
return super.getManyAndCount(); return super.getManyAndCount();
} }
override insert(): WorkspaceInsertQueryBuilder<T> {
const insertQueryBuilder = super.insert();
return new WorkspaceInsertQueryBuilder<T>(
insertQueryBuilder,
this.objectRecordsPermissions,
this.internalContext,
this.shouldBypassPermissionChecks,
);
}
override update(): WorkspaceUpdateQueryBuilder<T>; override update(): WorkspaceUpdateQueryBuilder<T>;
override update( override update(

View File

@ -2,7 +2,7 @@ import { Test, TestingModule } from '@nestjs/testing';
import { WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager'; import { WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager';
import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory'; import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter'; import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { CalendarEventParticipantWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity'; import { CalendarEventParticipantWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity';
import { MatchParticipantService } from 'src/modules/match-participant/match-participant.service'; import { MatchParticipantService } from 'src/modules/match-participant/match-participant.service';
@ -12,7 +12,7 @@ import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/sta
describe('MatchParticipantService', () => { describe('MatchParticipantService', () => {
let service: MatchParticipantService<MessageParticipantWorkspaceEntity>; let service: MatchParticipantService<MessageParticipantWorkspaceEntity>;
let twentyORMManager: TwentyORMManager; let twentyORMGlobalManager: TwentyORMGlobalManager;
let workspaceEventEmitter: WorkspaceEventEmitter; let workspaceEventEmitter: WorkspaceEventEmitter;
let scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory; let scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory;
@ -95,22 +95,24 @@ describe('MatchParticipantService', () => {
providers: [ providers: [
MatchParticipantService, MatchParticipantService,
{ {
provide: TwentyORMManager, provide: TwentyORMGlobalManager,
useValue: { useValue: {
getRepository: jest.fn().mockImplementation((entityName) => { getRepositoryForWorkspace: jest
switch (entityName) { .fn()
case 'messageParticipant': .mockImplementation((_workspaceId, entityName) => {
return mockMessageParticipantRepository; switch (entityName) {
case 'calendarEventParticipant': case 'messageParticipant':
return mockCalendarEventParticipantRepository; return mockMessageParticipantRepository;
case 'person': case 'calendarEventParticipant':
return mockPersonRepository; return mockCalendarEventParticipantRepository;
case 'workspaceMember': case 'person':
return mockWorkspaceMemberRepository; return mockPersonRepository;
default: case 'workspaceMember':
return {}; return mockWorkspaceMemberRepository;
} default:
}), return {};
}
}),
}, },
}, },
{ {
@ -133,7 +135,9 @@ describe('MatchParticipantService', () => {
service = module.get< service = module.get<
MatchParticipantService<MessageParticipantWorkspaceEntity> MatchParticipantService<MessageParticipantWorkspaceEntity>
>(MatchParticipantService); >(MatchParticipantService);
twentyORMManager = module.get<TwentyORMManager>(TwentyORMManager); twentyORMGlobalManager = module.get<TwentyORMGlobalManager>(
TwentyORMGlobalManager,
);
workspaceEventEmitter = module.get<WorkspaceEventEmitter>( workspaceEventEmitter = module.get<WorkspaceEventEmitter>(
WorkspaceEventEmitter, WorkspaceEventEmitter,
); );
@ -287,7 +291,7 @@ describe('MatchParticipantService', () => {
const calendarService = const calendarService =
new MatchParticipantService<CalendarEventParticipantWorkspaceEntity>( new MatchParticipantService<CalendarEventParticipantWorkspaceEntity>(
workspaceEventEmitter, workspaceEventEmitter,
twentyORMManager, twentyORMGlobalManager,
scopedWorkspaceContextFactory, scopedWorkspaceContextFactory,
); );
@ -705,23 +709,25 @@ describe('MatchParticipantService', () => {
describe('getParticipantRepository', () => { describe('getParticipantRepository', () => {
it('should return message participant repository for messageParticipant', async () => { it('should return message participant repository for messageParticipant', async () => {
const repository = await (service as any).getParticipantRepository( const repository = await (service as any).getParticipantRepository(
mockWorkspaceId,
'messageParticipant', 'messageParticipant',
); );
expect(twentyORMManager.getRepository).toHaveBeenCalledWith( expect(
'messageParticipant', twentyORMGlobalManager.getRepositoryForWorkspace,
); ).toHaveBeenCalledWith(mockWorkspaceId, 'messageParticipant');
expect(repository).toBe(mockMessageParticipantRepository); expect(repository).toBe(mockMessageParticipantRepository);
}); });
it('should return calendar event participant repository for calendarEventParticipant', async () => { it('should return calendar event participant repository for calendarEventParticipant', async () => {
const repository = await (service as any).getParticipantRepository( const repository = await (service as any).getParticipantRepository(
mockWorkspaceId,
'calendarEventParticipant', 'calendarEventParticipant',
); );
expect(twentyORMManager.getRepository).toHaveBeenCalledWith( expect(
'calendarEventParticipant', twentyORMGlobalManager.getRepositoryForWorkspace,
); ).toHaveBeenCalledWith(mockWorkspaceId, 'calendarEventParticipant');
expect(repository).toBe(mockCalendarEventParticipantRepository); expect(repository).toBe(mockCalendarEventParticipantRepository);
}); });
}); });

View File

@ -4,7 +4,7 @@ import { Any, Equal } from 'typeorm';
import { WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager'; import { WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager';
import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory'; import { ScopedWorkspaceContextFactory } from 'src/engine/twenty-orm/factories/scoped-workspace-context.factory';
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager'; import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter'; import { WorkspaceEventEmitter } from 'src/engine/workspace-event-emitter/workspace-event-emitter';
import { CalendarEventParticipantWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity'; import { CalendarEventParticipantWorkspaceEntity } from 'src/modules/calendar/common/standard-objects/calendar-event-participant.workspace-entity';
import { addPersonEmailFiltersToQueryBuilder } from 'src/modules/match-participant/utils/add-person-email-filters-to-query-builder'; import { addPersonEmailFiltersToQueryBuilder } from 'src/modules/match-participant/utils/add-person-email-filters-to-query-builder';
@ -21,20 +21,23 @@ export class MatchParticipantService<
> { > {
constructor( constructor(
private readonly workspaceEventEmitter: WorkspaceEventEmitter, private readonly workspaceEventEmitter: WorkspaceEventEmitter,
private readonly twentyORMManager: TwentyORMManager, private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory, private readonly scopedWorkspaceContextFactory: ScopedWorkspaceContextFactory,
) {} ) {}
private async getParticipantRepository( private async getParticipantRepository(
workspaceId: string,
objectMetadataName: 'messageParticipant' | 'calendarEventParticipant', objectMetadataName: 'messageParticipant' | 'calendarEventParticipant',
) { ) {
if (objectMetadataName === 'messageParticipant') { if (objectMetadataName === 'messageParticipant') {
return await this.twentyORMManager.getRepository<MessageParticipantWorkspaceEntity>( return await this.twentyORMGlobalManager.getRepositoryForWorkspace<MessageParticipantWorkspaceEntity>(
workspaceId,
objectMetadataName, objectMetadataName,
); );
} }
return await this.twentyORMManager.getRepository<CalendarEventParticipantWorkspaceEntity>( return await this.twentyORMGlobalManager.getRepositoryForWorkspace<CalendarEventParticipantWorkspaceEntity>(
workspaceId,
objectMetadataName, objectMetadataName,
); );
} }
@ -52,14 +55,15 @@ export class MatchParticipantService<
return; return;
} }
const participantRepository =
await this.getParticipantRepository(objectMetadataName);
const workspaceId = this.scopedWorkspaceContextFactory.create().workspaceId; const workspaceId = this.scopedWorkspaceContextFactory.create().workspaceId;
if (!workspaceId) { if (!workspaceId) {
throw new Error('Workspace ID is required'); throw new Error('Workspace ID is required');
} }
const participantRepository = await this.getParticipantRepository(
workspaceId,
objectMetadataName,
);
const participantIds = participants.map((participant) => participant.id); const participantIds = participants.map((participant) => participant.id);
const uniqueParticipantsHandles = [ const uniqueParticipantsHandles = [
@ -67,8 +71,10 @@ export class MatchParticipantService<
]; ];
const personRepository = const personRepository =
await this.twentyORMManager.getRepository<PersonWorkspaceEntity>( await this.twentyORMGlobalManager.getRepositoryForWorkspace<PersonWorkspaceEntity>(
workspaceId,
'person', 'person',
{ shouldBypassPermissionChecks: true },
); );
const queryBuilder = addPersonEmailFiltersToQueryBuilder({ const queryBuilder = addPersonEmailFiltersToQueryBuilder({
@ -83,7 +89,8 @@ export class MatchParticipantService<
const people = await personRepository.formatResult(rawPeople); const people = await personRepository.formatResult(rawPeople);
const workspaceMemberRepository = const workspaceMemberRepository =
await this.twentyORMManager.getRepository<WorkspaceMemberWorkspaceEntity>( await this.twentyORMGlobalManager.getRepositoryForWorkspace<WorkspaceMemberWorkspaceEntity>(
workspaceId,
'workspaceMember', 'workspaceMember',
); );
@ -152,14 +159,15 @@ export class MatchParticipantService<
personId?: string; personId?: string;
workspaceMemberId?: string; workspaceMemberId?: string;
}) { }) {
const participantRepository =
await this.getParticipantRepository(objectMetadataName);
const workspaceId = this.scopedWorkspaceContextFactory.create().workspaceId; const workspaceId = this.scopedWorkspaceContextFactory.create().workspaceId;
if (!workspaceId) { if (!workspaceId) {
throw new Error('Workspace ID is required'); throw new Error('Workspace ID is required');
} }
const participantRepository = await this.getParticipantRepository(
workspaceId,
objectMetadataName,
);
if (personId) { if (personId) {
await participantRepository.update( await participantRepository.update(
@ -172,8 +180,10 @@ export class MatchParticipantService<
); );
const personRepository = const personRepository =
await this.twentyORMManager.getRepository<PersonWorkspaceEntity>( await this.twentyORMGlobalManager.getRepositoryForWorkspace<PersonWorkspaceEntity>(
workspaceId,
'person', 'person',
{ shouldBypassPermissionChecks: true },
); );
const queryBuilder = addPersonEmailFiltersToQueryBuilder({ const queryBuilder = addPersonEmailFiltersToQueryBuilder({
@ -253,8 +263,10 @@ export class MatchParticipantService<
throw new Error('Workspace ID is required'); throw new Error('Workspace ID is required');
} }
const participantRepository = const participantRepository = await this.getParticipantRepository(
await this.getParticipantRepository(objectMetadataName); workspaceId,
objectMetadataName,
);
const participantsToUpdate = await participantRepository.find({ const participantsToUpdate = await participantRepository.find({
where: { where: {
@ -340,8 +352,10 @@ export class MatchParticipantService<
throw new Error('Workspace ID is required'); throw new Error('Workspace ID is required');
} }
const participantRepository = const participantRepository = await this.getParticipantRepository(
await this.getParticipantRepository(objectMetadataName); workspaceId,
objectMetadataName,
);
const participantsToUpdate = await participantRepository.find({ const participantsToUpdate = await participantRepository.find({
where: { where: {