[permissions] Add read field permission check layer (part 1) (#13376)

In this PR, behind a feature flag, we add a permission layer check based
on the read permission.
It is done by computing a map of an object's fields, where keys are the
column names and values the fieldMetadata id, making them comparable to
the restricted fields ids list stored in the permission cache.

For mutations (create, update, delete, destroy), we need to check the
read permission on the returned field, as they may differ from the
updated field. The write field permission will be tackled in a different
PR.
This commit is contained in:
Marie
2025-07-23 17:25:34 +02:00
committed by GitHub
parent 1e5d2f9b21
commit ae6adb3a63
21 changed files with 1099 additions and 108 deletions

View File

@ -0,0 +1,80 @@
import { ProcessAggregateHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/process-aggregate.helper';
describe('ProcessAggregateHelper', () => {
describe('extractColumnNamesFromAggregateExpression', () => {
it('should extract column names from CONCAT expression', () => {
const selection =
'CASE WHEN COUNT(*) = 0 THEN NULL ELSE COUNT(*) - COUNT(NULLIF(CONCAT("firstName","lastName")) END, \'\')';
const result =
ProcessAggregateHelper.extractColumnNamesFromAggregateExpression(
selection,
);
expect(result).toEqual(['firstName', 'lastName']);
});
it('should extract column names from CONCAT expression - 2', () => {
const selection =
'CASE WHEN COUNT(*) = 0 THEN NULL ELSE COUNT(*) - COUNT(NULLIF(CONCAT("firstName")) END, \'\')';
const result =
ProcessAggregateHelper.extractColumnNamesFromAggregateExpression(
selection,
);
expect(result).toEqual(['firstName']);
});
it('should extract column names from CONCAT expression - 3', () => {
const selection =
'CASE WHEN COUNT(*) = 0 THEN NULL ELSE COUNT(*) - COUNT(NULLIF(CONCAT("firstName","lastName","nickName")) END, \'\')';
const result =
ProcessAggregateHelper.extractColumnNamesFromAggregateExpression(
selection,
);
expect(result).toEqual(['firstName', 'lastName', 'nickName']);
});
it('should extract column name from non-CONCAT expression', () => {
const selection =
'CASE WHEN COUNT(*) = 0 THEN NULL ELSE COUNT("firstName") END';
const result =
ProcessAggregateHelper.extractColumnNamesFromAggregateExpression(
selection,
);
expect(result).toEqual(['firstName']);
});
it('should extract column name from aggregate expression', () => {
const selection = 'AVG("amount")';
const result =
ProcessAggregateHelper.extractColumnNamesFromAggregateExpression(
selection,
);
expect(result).toEqual(['amount']);
});
it('should return null when no column names found', () => {
const selection = 'COUNT(*)';
const result =
ProcessAggregateHelper.extractColumnNamesFromAggregateExpression(
selection,
);
expect(result).toBeNull();
});
it('should extract column name from boolean expression', () => {
const selection =
'CASE WHEN "isActive"::boolean = TRUE THEN 1 ELSE NULL END';
const result =
ProcessAggregateHelper.extractColumnNamesFromAggregateExpression(
selection,
);
expect(result).toEqual(['isActive']);
});
});
});

View File

@ -1,5 +1,3 @@
import { Injectable } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { AggregateOperations } from 'src/engine/api/graphql/graphql-query-runner/constants/aggregate-operations.constant';
@ -7,9 +5,8 @@ import { AggregationField } from 'src/engine/api/graphql/workspace-schema-builde
import { WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder';
import { formatColumnNamesFromCompositeFieldAndSubfields } from 'src/engine/twenty-orm/utils/format-column-names-from-composite-field-and-subfield.util';
@Injectable()
export class ProcessAggregateHelper {
public addSelectedAggregatedFieldsQueriesToQueryBuilder = ({
public static addSelectedAggregatedFieldsQueriesToQueryBuilder = ({
selectedAggregatedFields,
queryBuilder,
}: {
@ -110,4 +107,32 @@ export class ProcessAggregateHelper {
}
}
};
public static extractColumnNamesFromAggregateExpression = (
selection: string,
): string[] | null => {
// Match content between CONCAT(" and ") - handle multiple columns
const concatMatches = selection.match(
/CONCAT\("([^"]+)"(?:,"([^"]+)")*\)/g,
);
if (concatMatches) {
// Extract all column names between quotes after CONCAT
const columnNames = selection
.match(/"([^"]+)"/g)
?.map((match) => match.slice(1, -1));
return columnNames || null;
}
// For non-CONCAT expressions, match content between double quotes
// Using positive lookbehind and lookahead to match content between quotes without including quotes
const columnMatch = selection.match(/(?<=")([^"]+)(?=")/);
if (columnMatch) {
return [columnMatch[0]];
}
return null;
};
}

View File

@ -25,9 +25,7 @@ import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-
@Injectable()
export class ProcessNestedRelationsV2Helper {
constructor(
private readonly processAggregateHelper: ProcessAggregateHelper,
) {}
constructor() {}
public async processNestedRelations<T extends ObjectRecord = ObjectRecord>({
objectMetadataMaps,
@ -324,12 +322,10 @@ export class ProcessNestedRelationsV2Helper {
if (aggregateForRelation) {
const aggregateQueryBuilder = referenceQueryBuilder.clone();
this.processAggregateHelper.addSelectedAggregatedFieldsQueriesToQueryBuilder(
{
selectedAggregatedFields: aggregateForRelation,
queryBuilder: aggregateQueryBuilder,
},
);
ProcessAggregateHelper.addSelectedAggregatedFieldsQueriesToQueryBuilder({
selectedAggregatedFields: aggregateForRelation,
queryBuilder: aggregateQueryBuilder,
});
const aggregatedFieldsValues = await aggregateQueryBuilder
.addSelect(column)

View File

@ -17,6 +17,7 @@ import {
GraphqlQueryRunnerExceptionCode,
} from 'src/engine/api/graphql/graphql-query-runner/errors/graphql-query-runner.exception';
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { buildColumnsToReturn } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-return';
import { buildColumnsToSelect } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-select';
import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util';
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
@ -68,7 +69,19 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol
executionArgs: GraphqlQueryResolverExecutionArgs<CreateManyResolverArgs>,
): Promise<InsertResult> {
if (!executionArgs.args.upsert) {
return await executionArgs.repository.insert(executionArgs.args.data);
const { objectMetadataItemWithFieldMaps } = executionArgs.options;
const selectedColumns = buildColumnsToReturn({
select: executionArgs.graphqlQuerySelectedFieldsResult.select,
relations: executionArgs.graphqlQuerySelectedFieldsResult.relations,
objectMetadataItemWithFieldMaps,
});
return await executionArgs.repository.insert(
executionArgs.args.data,
undefined,
selectedColumns,
);
}
return this.performUpsertOperation(executionArgs);
@ -78,12 +91,20 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol
executionArgs: GraphqlQueryResolverExecutionArgs<CreateManyResolverArgs>,
): Promise<InsertResult> {
const { objectMetadataItemWithFieldMaps } = executionArgs.options;
const selectedColumns = buildColumnsToSelect({
select: executionArgs.graphqlQuerySelectedFieldsResult.select,
relations: executionArgs.graphqlQuerySelectedFieldsResult.relations,
objectMetadataItemWithFieldMaps,
});
const conflictingFields = this.getConflictingFields(
objectMetadataItemWithFieldMaps,
);
const existingRecords = await this.findExistingRecords(
executionArgs,
conflictingFields,
selectedColumns,
);
const { recordsToUpdate, recordsToInsert } = this.categorizeRecords(
@ -98,17 +119,25 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol
raw: [],
};
const columnsToReturn = buildColumnsToReturn({
select: executionArgs.graphqlQuerySelectedFieldsResult.select,
relations: executionArgs.graphqlQuerySelectedFieldsResult.relations,
objectMetadataItemWithFieldMaps,
});
await this.processRecordsToUpdate({
partialRecordsToUpdate: recordsToUpdate,
repository: executionArgs.repository,
objectMetadataItemWithFieldMaps,
result,
columnsToReturn,
});
await this.processRecordsToInsert({
recordsToInsert,
repository: executionArgs.repository,
result,
columnsToReturn,
});
return result;
@ -161,6 +190,7 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol
fullPath: string;
column: string;
}[],
selectedColumns: Record<string, boolean>,
): Promise<Partial<ObjectRecord>[]> {
const { objectMetadataItemWithFieldMaps } = executionArgs.options;
const queryBuilder = executionArgs.repository.createQueryBuilder(
@ -176,7 +206,11 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol
queryBuilder.orWhere(condition);
});
return await queryBuilder.getMany();
return await queryBuilder
.setFindOptions({
select: selectedColumns,
})
.getMany();
}
private getValueFromPath(
@ -265,11 +299,13 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol
repository,
objectMetadataItemWithFieldMaps,
result,
columnsToReturn,
}: {
partialRecordsToUpdate: Partial<ObjectRecord>[];
repository: WorkspaceRepository<ObjectLiteral>;
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps;
result: InsertResult;
columnsToReturn: string[];
}): Promise<void> {
for (const partialRecordToUpdate of partialRecordsToUpdate) {
const recordId = partialRecordToUpdate.id as string;
@ -284,6 +320,8 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol
await repository.update(
recordId,
partialRecordToUpdateWithoutCreatedByUpdate,
undefined,
columnsToReturn,
);
result.identifiers.push({ id: recordId });
@ -295,13 +333,19 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol
recordsToInsert,
repository,
result,
columnsToReturn,
}: {
recordsToInsert: Partial<ObjectRecord>[];
repository: WorkspaceRepository<ObjectLiteral>;
result: InsertResult;
columnsToReturn: string[];
}): Promise<void> {
if (recordsToInsert.length > 0) {
const insertResult = await repository.insert(recordsToInsert);
const insertResult = await repository.insert(
recordsToInsert,
undefined,
columnsToReturn,
);
result.identifiers.push(...insertResult.identifiers);
result.generatedMaps.push(...insertResult.generatedMaps);

View File

@ -12,6 +12,7 @@ import { WorkspaceQueryRunnerOptions } from 'src/engine/api/graphql/workspace-qu
import { CreateOneResolverArgs } from 'src/engine/api/graphql/workspace-resolver-builder/interfaces/workspace-resolvers-builder.interface';
import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper';
import { buildColumnsToReturn } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-return';
import { buildColumnsToSelect } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-select';
import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util';
import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util';
@ -29,12 +30,27 @@ export class GraphqlQueryCreateOneResolverService extends GraphqlQueryBaseResolv
const { roleId } = executionArgs;
const selectedColumns = buildColumnsToReturn({
select: executionArgs.graphqlQuerySelectedFieldsResult.select,
relations: executionArgs.graphqlQuerySelectedFieldsResult.relations,
objectMetadataItemWithFieldMaps,
});
const objectRecords: InsertResult = !executionArgs.args.upsert
? await executionArgs.repository.insert(executionArgs.args.data)
: await executionArgs.repository.upsert(executionArgs.args.data, {
conflictPaths: ['id'],
skipUpdateIfNoValuesChanged: true,
});
? await executionArgs.repository.insert(
executionArgs.args.data,
undefined,
selectedColumns,
)
: await executionArgs.repository.upsert(
executionArgs.args.data,
{
conflictPaths: ['id'],
skipUpdateIfNoValuesChanged: true,
},
undefined,
selectedColumns,
);
const queryBuilder = executionArgs.repository.createQueryBuilder(
objectMetadataItemWithFieldMaps.nameSingular,

View File

@ -35,7 +35,7 @@ export class GraphqlQueryFindManyResolverService extends GraphqlQueryBaseResolve
FindManyResolverArgs,
IConnection<ObjectRecord>
> {
constructor(private readonly processAggregateHelper: ProcessAggregateHelper) {
constructor() {
super();
}
@ -109,13 +109,11 @@ export class GraphqlQueryFindManyResolverService extends GraphqlQueryBaseResolve
appliedFilters,
);
this.processAggregateHelper.addSelectedAggregatedFieldsQueriesToQueryBuilder(
{
selectedAggregatedFields:
executionArgs.graphqlQuerySelectedFieldsResult.aggregate,
queryBuilder: aggregateQueryBuilder,
},
);
ProcessAggregateHelper.addSelectedAggregatedFieldsQueriesToQueryBuilder({
selectedAggregatedFields:
executionArgs.graphqlQuerySelectedFieldsResult.aggregate,
queryBuilder: aggregateQueryBuilder,
});
const limit =
executionArgs.args.first ?? executionArgs.args.last ?? QUERY_MAX_RECORDS;