[permissions] Remove raw queries and restrict its usage (#12360)

Closes https://github.com/twentyhq/core-team-issues/issues/748

In the frame of the work on permissions we

- remove all raw queries possible to use repositories instead
- forbid usage workspaceDataSource.executeRawQueries()
- restrict usage of workspaceDataSource.query() to force developers to
pass on shouldBypassPermissionChecks to use it.

---------

Co-authored-by: greptile-apps[bot] <165735046+greptile-apps[bot]@users.noreply.github.com>
This commit is contained in:
Marie
2025-06-02 10:53:51 +02:00
committed by GitHub
parent 1ef7b7a474
commit 9706f0df13
49 changed files with 495 additions and 754 deletions

View File

@ -2,16 +2,10 @@ import { Module } from '@nestjs/common';
import { AuditModule } from 'src/engine/core-modules/audit/audit.module';
import { CreateAuditLogFromInternalEvent } from 'src/engine/core-modules/audit/jobs/create-audit-log-from-internal-event';
import { ObjectMetadataRepositoryModule } from 'src/engine/object-metadata-repository/object-metadata-repository.module';
import { TimelineActivityModule } from 'src/modules/timeline/timeline-activity.module';
import { WorkspaceMemberWorkspaceEntity } from 'src/modules/workspace-member/standard-objects/workspace-member.workspace-entity';
@Module({
imports: [
ObjectMetadataRepositoryModule.forFeature([WorkspaceMemberWorkspaceEntity]),
TimelineActivityModule,
AuditModule,
],
imports: [TimelineActivityModule, AuditModule],
providers: [CreateAuditLogFromInternalEvent],
})
export class AuditJobModule {}

View File

@ -1,11 +1,11 @@
import { Module } from '@nestjs/common';
import { WorkspaceDataSourceModule } from 'src/engine/workspace-datasource/workspace-datasource.module';
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
import { RecordPositionService } from './services/record-position.service';
@Module({
imports: [WorkspaceDataSourceModule],
imports: [TwentyORMModule],
providers: [RecordPositionService],
exports: [RecordPositionService],
})

View File

@ -1,24 +1,31 @@
import { Test, TestingModule } from '@nestjs/testing';
import { RecordPositionService } from 'src/engine/core-modules/record-position/services/record-position.service';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
describe('RecordPositionService', () => {
let workspaceDataSourceService;
let twentyORMGlobalManager: jest.Mocked<TwentyORMGlobalManager>;
let mockRepository: any;
let service: RecordPositionService;
beforeEach(async () => {
workspaceDataSourceService = {
getSchemaName: jest.fn().mockReturnValue('schemaName'),
executeRawQuery: jest.fn().mockResolvedValue([{ position: 1 }]),
mockRepository = {
findOneBy: jest.fn(),
update: jest.fn(),
minimum: jest.fn().mockResolvedValue(1),
maximum: jest.fn().mockResolvedValue(1),
};
twentyORMGlobalManager = {
getRepositoryForWorkspace: jest.fn().mockResolvedValue(mockRepository),
} as unknown as jest.Mocked<TwentyORMGlobalManager>;
const module: TestingModule = await Test.createTestingModule({
providers: [
RecordPositionService,
{
provide: WorkspaceDataSourceService,
useValue: workspaceDataSourceService,
provide: TwentyORMGlobalManager,
useValue: twentyORMGlobalManager,
},
],
}).compile();
@ -30,7 +37,7 @@ describe('RecordPositionService', () => {
expect(service).toBeDefined();
});
describe('create', () => {
describe('buildRecordPosition', () => {
const objectMetadata = { isCustom: false, nameSingular: 'company' };
const workspaceId = 'workspaceId';

View File

@ -1,13 +1,6 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'class-validator';
import {
RecordPositionQueryArgs,
RecordPositionQueryType,
} from 'src/engine/core-modules/record-position/types/record-position-query.type';
import { buildRecordPositionQuery } from 'src/engine/core-modules/record-position/utils/build-record-position-query.util';
import { WorkspaceDataSourceService } from 'src/engine/workspace-datasource/workspace-datasource.service';
import { TwentyORMGlobalManager } from 'src/engine/twenty-orm/twenty-orm-global.manager';
export type RecordPositionServiceCreateArgs = {
value: number | 'first' | 'last';
@ -19,7 +12,7 @@ export type RecordPositionServiceCreateArgs = {
@Injectable()
export class RecordPositionService {
constructor(
private readonly workspaceDataSourceService: WorkspaceDataSourceService,
private readonly twentyORMGlobalManager: TwentyORMGlobalManager,
) {}
async buildRecordPosition({
@ -28,62 +21,101 @@ export class RecordPositionService {
workspaceId,
index = 0,
}: RecordPositionServiceCreateArgs): Promise<number> {
const dataSourceSchema =
this.workspaceDataSourceService.getSchemaName(workspaceId);
if (typeof value === 'number') {
return value;
}
if (value === 'first') {
const recordWithMinPosition =
await this.createAndExecuteRecordPositionQuery(
{
recordPositionQueryType: RecordPositionQueryType.FIND_MIN_POSITION,
},
objectMetadata,
dataSourceSchema,
workspaceId,
);
return isDefined(recordWithMinPosition?.position)
? recordWithMinPosition.position - index - 1
: 1;
}
const recordWithMaxPosition =
await this.createAndExecuteRecordPositionQuery(
{
recordPositionQueryType: RecordPositionQueryType.FIND_MAX_POSITION,
},
const recordWithMinPosition = await this.findMinPosition(
objectMetadata,
dataSourceSchema,
workspaceId,
);
return isDefined(recordWithMaxPosition?.position)
? recordWithMaxPosition.position + index + 1
: 1;
}
return recordWithMinPosition !== null
? recordWithMinPosition - index - 1
: 1;
}
private async createAndExecuteRecordPositionQuery(
recordPositionQueryArgs: RecordPositionQueryArgs,
objectMetadata: { isCustom: boolean; nameSingular: string },
dataSourceSchema: string,
workspaceId: string,
) {
const [query, params] = buildRecordPositionQuery(
recordPositionQueryArgs,
const recordWithMaxPosition = await this.findMaxPosition(
objectMetadata,
dataSourceSchema,
);
const records = await this.workspaceDataSourceService.executeRawQuery(
query,
params,
workspaceId,
);
return records?.[0];
return recordWithMaxPosition !== null
? recordWithMaxPosition + index + 1
: 1;
}
async findByPosition(
positionValue: number | null,
objectMetadata: { isCustom: boolean; nameSingular: string },
workspaceId: string,
): Promise<{ id: string; position: number } | null> {
const repository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
objectMetadata.nameSingular,
{
shouldBypassPermissionChecks: true,
},
);
const record = await repository.findOneBy({
position: positionValue,
});
return record ? { id: record.id, position: record.position } : null;
}
async updatePosition(
recordId: string,
positionValue: number,
objectMetadata: { isCustom: boolean; nameSingular: string },
workspaceId: string,
): Promise<void> {
const repository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
objectMetadata.nameSingular,
{
shouldBypassPermissionChecks: true,
},
);
await repository.update(recordId, {
position: positionValue,
});
}
private async findMinPosition(
objectMetadata: { isCustom: boolean; nameSingular: string },
workspaceId: string,
): Promise<number | null> {
const repository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
objectMetadata.nameSingular,
{
shouldBypassPermissionChecks: true,
},
);
return repository.minimum('position');
}
private async findMaxPosition(
objectMetadata: { isCustom: boolean; nameSingular: string },
workspaceId: string,
): Promise<number | null> {
const repository =
await this.twentyORMGlobalManager.getRepositoryForWorkspace(
workspaceId,
objectMetadata.nameSingular,
{
shouldBypassPermissionChecks: true,
},
);
return repository.maximum('position');
}
}

View File

@ -1,72 +0,0 @@
import { RecordPositionQueryType } from 'src/engine/core-modules/record-position/types/record-position-query.type';
import { buildRecordPositionQuery } from 'src/engine/core-modules/record-position/utils/build-record-position-query.util';
describe('buildRecordPositionQuery', () => {
const objectMetadataItem = {
isCustom: false,
nameSingular: 'company',
};
const dataSourceSchema = 'workspace_test';
it('should return query and params for FIND_BY_POSITION', async () => {
const positionValue = 1;
const queryType = RecordPositionQueryType.FIND_BY_POSITION;
const [query, params] = buildRecordPositionQuery(
{ positionValue, recordPositionQueryType: queryType },
objectMetadataItem,
dataSourceSchema,
);
expect(query).toEqual(
`SELECT id, position FROM ${dataSourceSchema}."${objectMetadataItem.nameSingular}"
WHERE "position" = $1`,
);
expect(params).toEqual([positionValue]);
});
it('should return query and params for FIND_MIN_POSITION', async () => {
const queryType = RecordPositionQueryType.FIND_MIN_POSITION;
const [query, params] = buildRecordPositionQuery(
{ recordPositionQueryType: queryType },
objectMetadataItem,
dataSourceSchema,
);
expect(query).toEqual(
`SELECT MIN(position) as position FROM ${dataSourceSchema}."${objectMetadataItem.nameSingular}"`,
);
expect(params).toEqual([]);
});
it('should return query and params for FIND_MAX_POSITION', async () => {
const queryType = RecordPositionQueryType.FIND_MAX_POSITION;
const [query, params] = buildRecordPositionQuery(
{ recordPositionQueryType: queryType },
objectMetadataItem,
dataSourceSchema,
);
expect(query).toEqual(
`SELECT MAX(position) as position FROM ${dataSourceSchema}."${objectMetadataItem.nameSingular}"`,
);
expect(params).toEqual([]);
});
it('should return query and params for UPDATE_POSITION', async () => {
const positionValue = 1;
const recordId = '1';
const queryType = RecordPositionQueryType.UPDATE_POSITION;
const [query, params] = buildRecordPositionQuery(
{ positionValue, recordId, recordPositionQueryType: queryType },
objectMetadataItem,
dataSourceSchema,
);
expect(query).toEqual(
`UPDATE ${dataSourceSchema}."${objectMetadataItem.nameSingular}"
SET "position" = $1
WHERE "id" = $2`,
);
expect(params).toEqual([positionValue, recordId]);
});
});

View File

@ -1,91 +0,0 @@
import {
FindByPositionQueryArgs,
RecordPositionQueryArgs,
RecordPositionQueryType,
UpdatePositionQueryArgs,
} from 'src/engine/core-modules/record-position/types/record-position-query.type';
import { computeTableName } from 'src/engine/utils/compute-table-name.util';
type RecordPositionQuery = string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
type RecordPositionQueryParams = any[];
export const buildRecordPositionQuery = (
recordPositionQueryArgs: RecordPositionQueryArgs,
objectMetadata: { isCustom: boolean; nameSingular: string },
dataSourceSchema: string,
): [RecordPositionQuery, RecordPositionQueryParams] => {
const tableName = computeTableName(
objectMetadata.nameSingular,
objectMetadata.isCustom,
);
switch (recordPositionQueryArgs.recordPositionQueryType) {
case RecordPositionQueryType.FIND_BY_POSITION:
return buildFindByPositionQuery(
recordPositionQueryArgs satisfies FindByPositionQueryArgs,
tableName,
dataSourceSchema,
);
case RecordPositionQueryType.FIND_MIN_POSITION:
return buildFindMinPositionQuery(tableName, dataSourceSchema);
case RecordPositionQueryType.FIND_MAX_POSITION:
return buildFindMaxPositionQuery(tableName, dataSourceSchema);
case RecordPositionQueryType.UPDATE_POSITION:
return buildUpdatePositionQuery(
recordPositionQueryArgs satisfies UpdatePositionQueryArgs,
tableName,
dataSourceSchema,
);
default:
throw new Error('Invalid RecordPositionQueryType');
}
};
const buildFindByPositionQuery = (
{ positionValue }: FindByPositionQueryArgs,
name: string,
dataSourceSchema: string,
): [RecordPositionQuery, RecordPositionQueryParams] => {
const positionStringParam = positionValue ? '= $1' : 'IS NULL';
return [
`SELECT id, position FROM ${dataSourceSchema}."${name}"
WHERE "position" ${positionStringParam}`,
positionValue ? [positionValue] : [],
];
};
const buildFindMaxPositionQuery = (
name: string,
dataSourceSchema: string,
): [RecordPositionQuery, RecordPositionQueryParams] => {
return [
`SELECT MAX(position) as position FROM ${dataSourceSchema}."${name}"`,
[],
];
};
const buildFindMinPositionQuery = (
name: string,
dataSourceSchema: string,
): [RecordPositionQuery, RecordPositionQueryParams] => {
return [
`SELECT MIN(position) as position FROM ${dataSourceSchema}."${name}"`,
[],
];
};
const buildUpdatePositionQuery = (
{ recordId, positionValue }: UpdatePositionQueryArgs,
name: string,
dataSourceSchema: string,
): [RecordPositionQuery, RecordPositionQueryParams] => {
return [
`UPDATE ${dataSourceSchema}."${name}"
SET "position" = $1
WHERE "id" = $2`,
[positionValue, recordId],
];
};