[permissions] Update permission check layer (#13485)

Fixes https://github.com/twentyhq/core-team-issues/issues/1262

In this PR we add the update permission check layer by 
- for the graphql api: extracting columns to update from the
expressionMap
- for rest api: .save() is used so we need to add the permission layer
to .save directly. We also take advantage of this PR to filter out
non-readable fields from save response (other save returns the whole
entity) - this was planned in
https://github.com/twentyhq/core-team-issues/issues/1216

The current solution does not work with rest api depth 2 queries, but
this seem to already not work on main (for timeout reasons though, so
different). I offer to create a ticket to fix it altogether later.
This commit is contained in:
Marie
2025-07-31 18:37:01 +02:00
committed by GitHub
parent 3e9b642f7f
commit b41502a4b8
20 changed files with 1483 additions and 124 deletions
@@ -0,0 +1,265 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { getColumnNameToFieldMetadataIdMap } from 'src/engine/twenty-orm/utils/get-column-name-to-field-metadata-id.util';
describe('getColumnNameToFieldMetadataIdMap', () => {
const createMockObjectMetadataItemWithFieldMaps = (
fieldsById: Record<string, any>,
): ObjectMetadataItemWithFieldMaps =>
({
id: 'test-object-id',
nameSingular: 'test',
namePlural: 'tests',
labelSingular: 'Test',
labelPlural: 'Tests',
description: 'Test object',
icon: 'IconTest',
targetTableName: 'test',
isCustom: false,
isRemote: false,
isActive: true,
isSystem: false,
isAuditLogged: false,
isSearchable: false,
labelIdentifierFieldMetadataId: '',
imageIdentifierFieldMetadataId: '',
workspaceId: 'test-workspace-id',
indexMetadatas: [],
fieldsById,
fieldIdByName: {},
fieldIdByJoinColumnName: {},
}) as unknown as ObjectMetadataItemWithFieldMaps;
const createMockFieldMetadata = (
id: string,
name: string,
type: FieldMetadataType,
) => ({
id,
name,
type,
label: name,
objectMetadataId: 'test-object-id',
isLabelSyncedWithName: true,
isNullable: true,
isUnique: false,
workspaceId: 'test-workspace-id',
createdAt: new Date(),
updatedAt: new Date(),
});
describe('with simple field types', () => {
it('should return a map with column name to field metadata id for simple field types', () => {
const fieldsById = {
'field-1': createMockFieldMetadata(
'field-1',
'name',
FieldMetadataType.TEXT,
),
'field-2': createMockFieldMetadata(
'field-2',
'age',
FieldMetadataType.NUMBER,
),
};
const objectMetadataItemWithFieldMaps =
createMockObjectMetadataItemWithFieldMaps(fieldsById);
const result = getColumnNameToFieldMetadataIdMap(
objectMetadataItemWithFieldMaps,
);
expect(result['name']).toBe('field-1');
expect(result['age']).toBe('field-2');
expect(Object.keys(result)).toHaveLength(2);
});
});
describe('with composite field types', () => {
it('should return column names to field metadata id for FULL_NAME composite type', () => {
const fieldsById = {
'field-1': createMockFieldMetadata(
'field-1',
'fullName',
FieldMetadataType.FULL_NAME,
),
};
const objectMetadataItemWithFieldMaps =
createMockObjectMetadataItemWithFieldMaps(fieldsById);
const result = getColumnNameToFieldMetadataIdMap(
objectMetadataItemWithFieldMaps,
);
expect(result['fullNameFirstName']).toBe('field-1');
expect(result['fullNameLastName']).toBe('field-1');
expect(Object.keys(result)).toHaveLength(2);
});
it('should return column names to field metadata id for CURRENCY composite type', () => {
const fieldsById = {
'field-1': createMockFieldMetadata(
'field-1',
'price',
FieldMetadataType.CURRENCY,
),
};
const objectMetadataItemWithFieldMaps =
createMockObjectMetadataItemWithFieldMaps(fieldsById);
const result = getColumnNameToFieldMetadataIdMap(
objectMetadataItemWithFieldMaps,
);
expect(result['priceAmountMicros']).toBe('field-1');
expect(result['priceCurrencyCode']).toBe('field-1');
expect(Object.keys(result)).toHaveLength(2);
});
it('should handle multiple composite fields', () => {
const fieldsById = {
'field-1': createMockFieldMetadata(
'field-1',
'fullName',
FieldMetadataType.FULL_NAME,
),
'field-2': createMockFieldMetadata(
'field-2',
'price',
FieldMetadataType.CURRENCY,
),
'field-3': createMockFieldMetadata(
'field-3',
'name',
FieldMetadataType.TEXT,
),
};
const objectMetadataItemWithFieldMaps =
createMockObjectMetadataItemWithFieldMaps(fieldsById);
const result = getColumnNameToFieldMetadataIdMap(
objectMetadataItemWithFieldMaps,
);
expect(result['fullNameFirstName']).toBe('field-1');
expect(result['fullNameLastName']).toBe('field-1');
expect(result['priceAmountMicros']).toBe('field-2');
expect(result['priceCurrencyCode']).toBe('field-2');
expect(result['name']).toBe('field-3');
expect(Object.keys(result)).toHaveLength(5);
});
});
describe('with mixed field types', () => {
it('should handle both simple and composite field types', () => {
const fieldsById = {
'field-1': createMockFieldMetadata(
'field-1',
'name',
FieldMetadataType.TEXT,
),
'field-2': createMockFieldMetadata(
'field-2',
'fullName',
FieldMetadataType.FULL_NAME,
),
'field-3': createMockFieldMetadata(
'field-3',
'age',
FieldMetadataType.NUMBER,
),
'field-4': createMockFieldMetadata(
'field-4',
'price',
FieldMetadataType.CURRENCY,
),
};
const objectMetadataItemWithFieldMaps =
createMockObjectMetadataItemWithFieldMaps(fieldsById);
const result = getColumnNameToFieldMetadataIdMap(
objectMetadataItemWithFieldMaps,
);
expect(result['name']).toBe('field-1');
expect(result['fullNameFirstName']).toBe('field-2');
expect(result['fullNameLastName']).toBe('field-2');
expect(result['age']).toBe('field-3');
expect(result['priceAmountMicros']).toBe('field-4');
expect(result['priceCurrencyCode']).toBe('field-4');
expect(Object.keys(result)).toHaveLength(6);
});
});
describe('with relation field types', () => {
it('should handle relation field types with join column name', () => {
const fieldsById = {
'field-1': {
...createMockFieldMetadata(
'field-1',
'company',
FieldMetadataType.RELATION,
),
settings: {
relationType: 'ONE_TO_ONE',
joinColumnName: 'companyId',
},
},
'field-2': createMockFieldMetadata(
'field-2',
'name',
FieldMetadataType.TEXT,
),
};
const objectMetadataItemWithFieldMaps =
createMockObjectMetadataItemWithFieldMaps(fieldsById);
const result = getColumnNameToFieldMetadataIdMap(
objectMetadataItemWithFieldMaps,
);
expect(result['companyId']).toBe('field-1');
expect(result['name']).toBe('field-2');
expect(Object.keys(result)).toHaveLength(2);
});
it('should skip ONE_TO_MANY relation field types', () => {
const fieldsById = {
'field-1': {
...createMockFieldMetadata(
'field-1',
'employees',
FieldMetadataType.RELATION,
),
settings: {
relationType: 'ONE_TO_MANY',
joinColumnName: 'companyId',
},
},
'field-2': createMockFieldMetadata(
'field-2',
'name',
FieldMetadataType.TEXT,
),
};
const objectMetadataItemWithFieldMaps =
createMockObjectMetadataItemWithFieldMaps(fieldsById);
const result = getColumnNameToFieldMetadataIdMap(
objectMetadataItemWithFieldMaps,
);
expect(result['name']).toBe('field-2');
expect(result['companyId']).toBeUndefined();
expect(Object.keys(result)).toHaveLength(1);
});
});
});
@@ -0,0 +1,61 @@
import { CompositeType } from 'src/engine/metadata-modules/field-metadata/interfaces/composite-type.interface';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import {
ColumnNameProcessor,
processFieldMetadataForColumnNameMapping,
} from 'src/engine/twenty-orm/utils/process-field-metadata-for-column-name-mapping.util';
export function getColumnNameToFieldMetadataIdMap(
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
) {
const columnNameToFieldMetadataIdMap: Record<string, string> = {};
const processor: ColumnNameProcessor = {
processCompositeField: ({
fieldMetadataId,
fieldMetadata,
compositeType,
}: {
fieldMetadataId: string;
fieldMetadata: FieldMetadataEntity;
compositeType: CompositeType;
}) => {
compositeType.properties.forEach((compositeProperty) => {
const columnName = computeCompositeColumnName(
fieldMetadata.name,
compositeProperty,
);
columnNameToFieldMetadataIdMap[columnName] = fieldMetadataId;
});
},
processRelationField: ({
fieldMetadataId,
columnName,
}: {
fieldMetadataId: string;
columnName: string;
}) => {
columnNameToFieldMetadataIdMap[columnName] = fieldMetadataId;
},
processSimpleField: ({
fieldMetadataId,
columnName,
}: {
fieldMetadataId: string;
columnName: string;
}) => {
columnNameToFieldMetadataIdMap[columnName] = fieldMetadataId;
},
};
processFieldMetadataForColumnNameMapping(
objectMetadataItemWithFieldMaps,
processor,
);
return columnNameToFieldMetadataIdMap;
}
@@ -1,47 +0,0 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { InternalServerError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
import {
computeColumnName,
computeCompositeColumnName,
} from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
export function getFieldMetadataIdForColumnNameMap(
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
) {
const columnNameToFieldMetadataIdMap: Record<string, string> = {};
for (const [fieldMetadataId, fieldMetadata] of Object.entries(
objectMetadataItemWithFieldMaps.fieldsById,
)) {
if (isCompositeFieldMetadataType(fieldMetadata.type)) {
const compositeType = compositeTypeDefinitions.get(fieldMetadata.type);
if (!compositeType) {
throw new InternalServerError(
`Composite type not found for field metadata type ${fieldMetadata.type}`,
);
}
compositeType.properties.forEach((compositeProperty) => {
const columnName = computeCompositeColumnName(
fieldMetadata.name,
compositeProperty,
);
columnNameToFieldMetadataIdMap[columnName] = fieldMetadataId;
});
} else {
const columnName = computeColumnName(fieldMetadata, {
isForeignKey: fieldMetadata.type === FieldMetadataType.RELATION,
});
columnNameToFieldMetadataIdMap[columnName] = fieldMetadataId;
}
}
return columnNameToFieldMetadataIdMap;
}
@@ -1,29 +1,28 @@
import { InternalServerError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
import {
computeColumnName,
computeCompositeColumnName,
} from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { CompositeType } from 'src/engine/metadata-modules/field-metadata/interfaces/composite-type.interface';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import {
ColumnNameProcessor,
processFieldMetadataForColumnNameMapping,
} from 'src/engine/twenty-orm/utils/process-field-metadata-for-column-name-mapping.util';
export function getFieldMetadataIdToColumnNamesMap(
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
) {
const fieldMetadataToColumnNamesMap = new Map<string, string[]>();
for (const [fieldMetadataId, fieldMetadata] of Object.entries(
objectMetadataItemWithFieldMaps.fieldsById,
)) {
if (isCompositeFieldMetadataType(fieldMetadata.type)) {
const compositeType = compositeTypeDefinitions.get(fieldMetadata.type);
if (!compositeType) {
throw new InternalServerError(
`Composite type not found for field metadata type ${fieldMetadata.type}`,
);
}
const processor: ColumnNameProcessor = {
processCompositeField: ({
fieldMetadataId,
fieldMetadata,
compositeType,
}: {
fieldMetadataId: string;
fieldMetadata: FieldMetadataEntity;
compositeType: CompositeType;
}) => {
compositeType.properties.forEach((compositeProperty) => {
const columnName = computeCompositeColumnName(
fieldMetadata.name,
@@ -38,12 +37,33 @@ export function getFieldMetadataIdToColumnNamesMap(
columnName,
]);
});
} else {
const columnName = computeColumnName(fieldMetadata);
},
processRelationField: ({
fieldMetadataId,
columnName,
}: {
fieldMetadataId: string;
fieldMetadata: FieldMetadataEntity;
columnName: string;
}) => {
fieldMetadataToColumnNamesMap.set(fieldMetadataId, [columnName]); // TODO test
},
processSimpleField: ({
fieldMetadataId,
columnName,
}: {
fieldMetadataId: string;
fieldMetadata: FieldMetadataEntity;
columnName: string;
}) => {
fieldMetadataToColumnNamesMap.set(fieldMetadataId, [columnName]);
}
}
},
};
processFieldMetadataForColumnNameMapping(
objectMetadataItemWithFieldMaps,
processor,
);
return fieldMetadataToColumnNamesMap;
}
@@ -0,0 +1,100 @@
import { CompositeType } from 'src/engine/metadata-modules/field-metadata/interfaces/composite-type.interface';
import { FieldMetadataRelationSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { isFieldMetadataRelationOrMorphRelation } from 'src/engine/api/graphql/workspace-schema-builder/utils/is-field-metadata-relation-or-morph-relation.utils';
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { computeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import {
PermissionsException,
PermissionsExceptionCode,
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
export type ColumnNameProcessor = {
processCompositeField: ({
fieldMetadataId,
fieldMetadata,
compositeType,
}: {
fieldMetadataId: string;
fieldMetadata: FieldMetadataEntity;
compositeType: CompositeType;
}) => void;
processRelationField: ({
fieldMetadataId,
fieldMetadata,
columnName,
}: {
fieldMetadataId: string;
fieldMetadata: FieldMetadataEntity;
columnName: string;
}) => void;
processSimpleField: ({
fieldMetadataId,
fieldMetadata,
columnName,
}: {
fieldMetadataId: string;
fieldMetadata: FieldMetadataEntity;
columnName: string;
}) => void;
};
export function processFieldMetadataForColumnNameMapping(
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
processor: ColumnNameProcessor,
) {
for (const [fieldMetadataId, fieldMetadata] of Object.entries(
objectMetadataItemWithFieldMaps.fieldsById,
)) {
if (isCompositeFieldMetadataType(fieldMetadata.type)) {
const compositeType = compositeTypeDefinitions.get(fieldMetadata.type);
if (!compositeType) {
throw new PermissionsException(
`Composite type not found for field metadata type ${fieldMetadata.type}`,
PermissionsExceptionCode.COMPOSITE_TYPE_NOT_FOUND,
);
}
processor.processCompositeField({
fieldMetadataId,
fieldMetadata,
compositeType,
});
} else {
if (isFieldMetadataRelationOrMorphRelation(fieldMetadata)) {
const fieldMetadataSettings =
fieldMetadata.settings as FieldMetadataRelationSettings;
if (fieldMetadataSettings?.relationType === RelationType.ONE_TO_MANY) {
continue;
}
const columnName = fieldMetadataSettings?.joinColumnName;
if (!columnName) {
throw new PermissionsException(
`Join column name is required for relation field metadata ${fieldMetadata.name}`,
PermissionsExceptionCode.JOIN_COLUMN_NAME_REQUIRED,
);
}
processor.processRelationField({
fieldMetadataId,
fieldMetadata,
columnName,
});
} else {
const columnName = computeColumnName(fieldMetadata);
processor.processSimpleField({
fieldMetadataId,
fieldMetadata,
columnName,
});
}
}
}
}