diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/input-type-definition.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/input-type-definition.factory.ts index 1ca8c39915..4b83adaf91 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/input-type-definition.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/input-type-definition.factory.ts @@ -5,7 +5,7 @@ import { GraphQLInputObjectType } from 'graphql'; import { WorkspaceBuildSchemaOptions } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-build-schema-options.interface'; import { TypeMapperService } from 'src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service'; -import { generateFields } from 'src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.utils'; +import { generateFields } from 'src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.util'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { pascalCase } from 'src/utils/pascal-case'; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/object-type-definition.factory.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/object-type-definition.factory.ts index 6251c8fc65..8c81b33e54 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/object-type-definition.factory.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/factories/object-type-definition.factory.ts @@ -4,7 +4,7 @@ import { GraphQLObjectType } from 'graphql'; import { WorkspaceBuildSchemaOptions } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-build-schema-options.interface'; -import { generateFields } from 'src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.utils'; +import { generateFields } from 'src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.util'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { pascalCase } from 'src/utils/pascal-case'; diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.utils.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.util.ts similarity index 94% rename from packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.utils.ts rename to packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.util.ts index e194e2ddea..b098a64644 100644 --- a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.utils.ts +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.util.ts @@ -13,10 +13,10 @@ import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfa import { InputTypeDefinitionKind } from 'src/engine/api/graphql/workspace-schema-builder/factories/input-type-definition.factory'; import { ObjectTypeDefinitionKind } from 'src/engine/api/graphql/workspace-schema-builder/factories/object-type-definition.factory'; import { formatRelationConnectInputTarget } from 'src/engine/api/graphql/workspace-schema-builder/factories/relation-connect-input-type-definition.factory'; +import { isFieldMetadataRelationOrMorphRelation } from 'src/engine/api/graphql/workspace-schema-builder/utils/is-field-metadata-relation-or-morph-relation.utils'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; -import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util'; type TypeFactory = { @@ -55,23 +55,19 @@ export const generateFields = < }): T extends InputTypeDefinitionKind ? GraphQLInputFieldConfigMap : // eslint-disable-next-line @typescript-eslint/no-explicit-any - // eslint-disable-next-line @typescript-eslint/no-explicit-any GraphQLFieldConfigMap => { const allGeneratedFields = {}; for (const fieldMetadata of objectMetadata.fields) { let generatedField; - const isRelation = - isFieldMetadataEntityOfType(fieldMetadata, FieldMetadataType.RELATION) || - isFieldMetadataEntityOfType( - fieldMetadata, - FieldMetadataType.MORPH_RELATION, - ); + const isRelation = isFieldMetadataRelationOrMorphRelation(fieldMetadata); if (isRelation) { generatedField = generateRelationField({ - fieldMetadata, + fieldMetadata: fieldMetadata as FieldMetadataEntity< + FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION + >, kind, options, typeFactory, diff --git a/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/is-field-metadata-relation-or-morph-relation.utils.ts b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/is-field-metadata-relation-or-morph-relation.utils.ts new file mode 100644 index 0000000000..3ff49ee0ef --- /dev/null +++ b/packages/twenty-server/src/engine/api/graphql/workspace-schema-builder/utils/is-field-metadata-relation-or-morph-relation.utils.ts @@ -0,0 +1,13 @@ +import { FieldMetadataType } from 'twenty-shared/types'; + +import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util'; + +export const isFieldMetadataRelationOrMorphRelation = ( + fieldMetadata: FieldMetadataEntity, +) => { + return ( + isFieldMetadataEntityOfType(fieldMetadata, FieldMetadataType.RELATION) || + isFieldMetadataEntityOfType(fieldMetadata, FieldMetadataType.MORPH_RELATION) + ); +}; diff --git a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-delete-one.handler.ts b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-delete-one.handler.ts index e0f410d85e..094c79e308 100644 --- a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-delete-one.handler.ts +++ b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-delete-one.handler.ts @@ -18,7 +18,7 @@ export class RestApiDeleteOneHandler extends RestApiBaseHandler { const { objectMetadata, repository, restrictedFields } = await this.getRepositoryAndMetadataOrFail(request); - const selectOptions = this.getSelectOptionsFromRestrictedFields({ + const selectOptions = this.getAllSelectableFields({ restrictedFields, objectMetadata, }); diff --git a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-update-one.handler.ts b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-update-one.handler.ts index b98b667597..e30d4e0bcd 100644 --- a/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-update-one.handler.ts +++ b/packages/twenty-server/src/engine/api/rest/core/handlers/rest-api-update-one.handler.ts @@ -23,7 +23,9 @@ export class RestApiUpdateOneHandler extends RestApiBaseHandler { const { objectMetadata, repository, restrictedFields } = await this.getRepositoryAndMetadataOrFail(request); - const recordToUpdate = await repository.findOneOrFail({ + // assert the record exists + await repository.findOneOrFail({ + select: { id: true }, where: { id: recordId }, }); @@ -33,7 +35,7 @@ export class RestApiUpdateOneHandler extends RestApiBaseHandler { }); const updatedRecord = await repository.save({ - ...recordToUpdate, + id: recordId, ...overriddenBody, }); diff --git a/packages/twenty-server/src/engine/api/rest/core/interfaces/rest-api-base.handler.ts b/packages/twenty-server/src/engine/api/rest/core/interfaces/rest-api-base.handler.ts index 1c6b82686c..ab44982901 100644 --- a/packages/twenty-server/src/engine/api/rest/core/interfaces/rest-api-base.handler.ts +++ b/packages/twenty-server/src/engine/api/rest/core/interfaces/rest-api-base.handler.ts @@ -266,7 +266,7 @@ export abstract class RestApiBaseHandler { let selectOptions = undefined; if (!isEmpty(restrictedFields)) { - selectOptions = this.getSelectOptionsFromRestrictedFields({ + selectOptions = this.getAllSelectableFields({ restrictedFields, objectMetadata, }); @@ -311,7 +311,7 @@ export abstract class RestApiBaseHandler { }; } - public getSelectOptionsFromRestrictedFields({ + public getAllSelectableFields({ restrictedFields, objectMetadata, }: { @@ -406,7 +406,9 @@ export abstract class RestApiBaseHandler { const objectMetadataNameSingular = objectMetadata.objectMetadataMapItem.nameSingular; - const qb = repository.createQueryBuilder(objectMetadataNameSingular); + const qb = repository + .createQueryBuilder(objectMetadataNameSingular) + .select('id'); const inputs = this.getVariablesFactory.create( recordId, diff --git a/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.exception.ts b/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.exception.ts index dd73c08aeb..e877fd359e 100644 --- a/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.exception.ts +++ b/packages/twenty-server/src/engine/metadata-modules/permissions/permissions.exception.ts @@ -48,6 +48,8 @@ export enum PermissionsExceptionCode { PERMISSION_NOT_FOUND = 'PERMISSION_NOT_FOUND', OBJECT_PERMISSION_NOT_FOUND = 'OBJECT_PERMISSION_NOT_FOUND', EMPTY_FIELD_PERMISSION_NOT_ALLOWED = 'EMPTY_FIELD_PERMISSION_NOT_ALLOWED', + JOIN_COLUMN_NAME_REQUIRED = 'JOIN_COLUMN_NAME_REQUIRED', + COMPOSITE_TYPE_NOT_FOUND = 'COMPOSITE_TYPE_NOT_FOUND', } export enum PermissionsExceptionMessage { diff --git a/packages/twenty-server/src/engine/metadata-modules/permissions/utils/permission-graphql-api-exception-handler.util.ts b/packages/twenty-server/src/engine/metadata-modules/permissions/utils/permission-graphql-api-exception-handler.util.ts index 07d16550ca..7a171e0df6 100644 --- a/packages/twenty-server/src/engine/metadata-modules/permissions/utils/permission-graphql-api-exception-handler.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/permissions/utils/permission-graphql-api-exception-handler.util.ts @@ -56,6 +56,8 @@ export const permissionGraphqlApiExceptionHandler = ( case PermissionsExceptionCode.METHOD_NOT_ALLOWED: case PermissionsExceptionCode.RAW_SQL_NOT_ALLOWED: case PermissionsExceptionCode.OBJECT_PERMISSION_NOT_FOUND: + case PermissionsExceptionCode.JOIN_COLUMN_NAME_REQUIRED: + case PermissionsExceptionCode.COMPOSITE_TYPE_NOT_FOUND: throw error; default: { const _exhaustiveCheck: never = error.code; diff --git a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts index 6a72c047e9..f343b7be5f 100644 --- a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.spec.ts @@ -15,6 +15,30 @@ jest.mock('src/engine/twenty-orm/repository/permissions.utils', () => ({ validateOperationIsPermittedOrThrow: jest.fn(), })); +jest.mock( + 'src/engine/twenty-orm/utils/get-object-metadata-from-entity-target.util', + () => ({ + getObjectMetadataFromEntityTarget: jest.fn().mockReturnValue({}), + }), +); + +jest.mock('src/engine/twenty-orm/utils/format-data.util', () => ({ + formatData: jest.fn().mockReturnValue([]), +})); + +jest.mock('src/engine/twenty-orm/utils/format-result.util', () => ({ + formatResult: jest.fn().mockReturnValue([]), +})); + +jest.mock( + 'src/engine/twenty-orm/entity-manager/workspace-entity-manager', + () => ({ + ...jest.requireActual( + 'src/engine/twenty-orm/entity-manager/workspace-entity-manager', + ), + }), +); + const mockedWorkspaceUpdateQueryBuilder = { set: jest.fn().mockImplementation(() => ({ where: jest.fn().mockReturnThis(), @@ -122,7 +146,22 @@ describe('WorkspaceEntityManager', () => { } as WorkspaceInternalContext; mockDataSource = { - featureFlagMap: {}, + featureFlagMap: { + IS_AIRTABLE_INTEGRATION_ENABLED: false, + IS_POSTGRESQL_INTEGRATION_ENABLED: false, + IS_STRIPE_INTEGRATION_ENABLED: false, + IS_UNIQUE_INDEXES_ENABLED: false, + IS_JSON_FILTER_ENABLED: false, + IS_AI_ENABLED: false, + IS_IMAP_SMTP_CALDAV_ENABLED: false, + IS_MORPH_RELATION_ENABLED: false, + IS_WORKFLOW_FILTERING_ENABLED: false, + IS_RELATION_CONNECT_ENABLED: false, + IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED: false, + IS_FIELDS_PERMISSIONS_ENABLED: true, + IS_CORE_VIEW_SYNCING_ENABLED: false, + IS_TWO_FACTOR_AUTHENTICATION_ENABLED: false, + }, permissionsPerRoleId: {}, } as WorkspaceDataSource; @@ -191,6 +230,15 @@ describe('WorkspaceEntityManager', () => { jest.spyOn(entityManager as any, 'validatePermissions'); jest.spyOn(entityManager as any, 'createQueryBuilder'); + jest + .spyOn(entityManager as any, 'getFormattedResultWithoutNonReadableFields') + .mockImplementation( + ({ formattedResult }: { formattedResult: string[] }) => formattedResult, + ); + + jest.spyOn(entityManager as any, 'getFeatureFlagMap').mockReturnValue({ + IS_FIELDS_PERMISSIONS_ENABLED: true, + }); jest .spyOn(entityManager as any, 'extractTargetNameSingularFromEntityTarget') @@ -276,15 +324,18 @@ describe('WorkspaceEntityManager', () => { operationType: 'update', permissionOptions: mockPermissionOptions, selectedColumns: [], + updatedColumns: [], }); expect(validateOperationIsPermittedOrThrow).toHaveBeenCalledWith({ entityName: 'test-entity', + isFieldPermissionsEnabled: true, operationType: 'update', objectMetadataMaps: mockInternalContext.objectMetadataMaps, objectRecordsPermissions: mockPermissionOptions.objectRecordsPermissions, selectedColumns: [], allFieldsSelected: false, + updatedColumns: [], }); }); }); @@ -313,11 +364,13 @@ describe('WorkspaceEntityManager', () => { expect(validateOperationIsPermittedOrThrow).toHaveBeenCalledWith({ entityName: 'test-entity', operationType: 'delete', + isFieldPermissionsEnabled: true, objectMetadataMaps: mockInternalContext.objectMetadataMaps, objectRecordsPermissions: mockPermissionOptions.objectRecordsPermissions, selectedColumns: [], allFieldsSelected: false, + updatedColumns: [], }); }); }); diff --git a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.ts b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.ts index 178e8c3cbe..b62301fb41 100644 --- a/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.ts +++ b/packages/twenty-server/src/engine/twenty-orm/entity-manager/workspace-entity-manager.ts @@ -1,4 +1,4 @@ -import { Entity } from '@microsoft/microsoft-graph-types'; +import isEmpty from 'lodash.isempty'; import { ObjectRecordsPermissions } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; import { @@ -35,10 +35,12 @@ import { WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/works import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action'; import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type'; +import { InternalServerError } from 'src/engine/core-modules/graphql/utils/graphql-errors.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'; import { WorkspaceDataSource } from 'src/engine/twenty-orm/datasource/workspace.datasource'; import { DeepPartialWithNestedRelationFields } from 'src/engine/twenty-orm/entity-manager/types/deep-partial-entity-with-nested-relation-fields.type'; import { QueryDeepPartialEntityWithNestedRelationFields } from 'src/engine/twenty-orm/entity-manager/types/query-deep-partial-entity-with-nested-relation-fields.type'; @@ -352,6 +354,7 @@ export class WorkspaceEntityManager extends EntityManager { operationType, permissionOptions, selectedColumns, + updatedColumns = [], }: { target: EntityTarget | Entity; operationType: OperationType; @@ -360,6 +363,7 @@ export class WorkspaceEntityManager extends EntityManager { objectRecordsPermissions?: ObjectRecordsPermissions; }; selectedColumns: string[]; + updatedColumns?: string[]; }): void { if (permissionOptions?.shouldBypassPermissionChecks === true) { return; @@ -378,6 +382,9 @@ export class WorkspaceEntityManager extends EntityManager { objectMetadataMaps: this.internalContext.objectMetadataMaps, selectedColumns, allFieldsSelected: false, + updatedColumns, + isFieldPermissionsEnabled: + this.getFeatureFlagMap().IS_FIELDS_PERMISSIONS_ENABLED, }); } @@ -702,7 +709,7 @@ export class WorkspaceEntityManager extends EntityManager { permissionOptions, ) .setFindOptions(options || {}) - .select('1') + .select('id') .limit(1) .getRawOne() .then((result) => isDefined(result)); @@ -722,7 +729,7 @@ export class WorkspaceEntityManager extends EntityManager { permissionOptions, ) .setFindOptions({ where }) - .select('1') + .select('id') .limit(1) .getRawOne() .then((result) => isDefined(result)); @@ -762,7 +769,7 @@ export class WorkspaceEntityManager extends EntityManager { .getCount(); } - async callAggregateFunCustom( + async callAggregateFunCustom( entityClass: EntityTarget, fnName: string, columnName: string, @@ -1033,13 +1040,6 @@ export class WorkspaceEntityManager extends EntityManager { ? maybeOptionsOrMaybePermissionOptions : permissionOptions; - this.validatePermissions({ - target: targetOrEntity, - operationType: 'update', - permissionOptions: permissionOptionsFromArgs, - selectedColumns: [], // TODO - }); - let target = arguments.length > 1 && (typeof targetOrEntity === 'function' || @@ -1047,7 +1047,9 @@ export class WorkspaceEntityManager extends EntityManager { typeof targetOrEntity === 'string') ? targetOrEntity : undefined; + const entity = target ? entityOrMaybeOptions : targetOrEntity; + const options = target ? maybeOptionsOrMaybePermissionOptions : entityOrMaybeOptions; @@ -1094,7 +1096,7 @@ export class WorkspaceEntityManager extends EntityManager { { where: { id: In(entityIds) }, }, - permissionOptions, + { shouldBypassPermissionChecks: true }, // Bypass as this is for event emission ); const beforeUpdateMapById = beforeUpdate.reduce( @@ -1116,6 +1118,18 @@ export class WorkspaceEntityManager extends EntityManager { objectMetadataItem, ); + const updatedColumns = formattedEntityOrEntities + .map((e) => Object.keys(e)) + .flat(); + + this.validatePermissions({ + target: targetOrEntity, + operationType: 'update', + permissionOptions: permissionOptionsFromArgs, + selectedColumns: [], + updatedColumns, + }); + const result = await new EntityPersistExecutor( this.connection, queryRunnerForEntityPersistExecutor, @@ -1130,7 +1144,7 @@ export class WorkspaceEntityManager extends EntityManager { const resultArray = Array.isArray(result) ? result : [result]; - const formattedResult = formatResult( + let formattedResult = formatResult( resultArray, objectMetadataItem, this.internalContext.objectMetadataMaps, @@ -1157,9 +1171,79 @@ export class WorkspaceEntityManager extends EntityManager { } } + const isFieldPermissionsEnabled = + this.getFeatureFlagMap().IS_FIELDS_PERMISSIONS_ENABLED; + + const permissionCheckApplies = + permissionOptionsFromArgs?.shouldBypassPermissionChecks !== true && + objectMetadataItem.isSystem !== true; + + if (isFieldPermissionsEnabled && permissionCheckApplies) { + formattedResult = this.getFormattedResultWithoutNonReadableFields({ + formattedResult, + objectMetadataItem, + permissionOptionsFromArgs, + }); + } + return isEntityArray ? formattedResult : formattedResult[0]; } + private getFormattedResultWithoutNonReadableFields< + Entity extends ObjectLiteral, + >({ + formattedResult, + objectMetadataItem, + permissionOptionsFromArgs, + }: { + formattedResult: Entity[]; + objectMetadataItem: ObjectMetadataItemWithFieldMaps; + permissionOptionsFromArgs: PermissionOptions | undefined; + }): Entity[] { + const restrictedFields = + permissionOptionsFromArgs?.objectRecordsPermissions?.[ + objectMetadataItem.id + ].restrictedFields; + + if (!restrictedFields) { + throw new InternalServerError('Restricted fields not found'); + } + + if (isEmpty(restrictedFields)) { + return formattedResult; + } + + const objectMetadataItemWithFieldMaps = + this.internalContext.objectMetadataMaps.byId[objectMetadataItem.id]; + + const restrictedFieldNames = new Set( + Object.entries(restrictedFields) + .filter(([_, fieldPermissions]) => fieldPermissions.canRead === false) + .map(([fieldMetadataId]) => { + const fieldMetadata = + objectMetadataItemWithFieldMaps?.fieldsById[fieldMetadataId]; + + if (!isDefined(fieldMetadata)) { + throw new InternalServerError( + `Field metadata not found for field ${fieldMetadataId}`, + ); + } + + return fieldMetadata.name; + }), + ); + + const filteredResult = formattedResult.map((individualFormattedResult) => { + return Object.fromEntries( + Object.entries(individualFormattedResult).filter( + ([key]) => !restrictedFieldNames.has(key), + ), + ); + }); + + return filteredResult as Entity[]; + } + override remove( entity: Entity, options?: RemoveOptions, diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/permissions.utils.ts b/packages/twenty-server/src/engine/twenty-orm/repository/permissions.utils.ts index 7e0bb7b76a..9b63056ad8 100644 --- a/packages/twenty-server/src/engine/twenty-orm/repository/permissions.utils.ts +++ b/packages/twenty-server/src/engine/twenty-orm/repository/permissions.utils.ts @@ -15,7 +15,7 @@ import { PermissionsExceptionMessage, } from 'src/engine/metadata-modules/permissions/permissions.exception'; import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps'; -import { getFieldMetadataIdForColumnNameMap } from 'src/engine/twenty-orm/utils/get-field-metadata-id-for-column-name.util'; +import { getColumnNameToFieldMetadataIdMap } from 'src/engine/twenty-orm/utils/get-column-name-to-field-metadata-id.util'; const getTargetEntityAndOperationType = (expressionMap: QueryExpressionMap) => { const mainEntity = expressionMap.aliases[0].metadata.name; @@ -43,6 +43,7 @@ export const validateOperationIsPermittedOrThrow = ({ selectedColumns, isFieldPermissionsEnabled, allFieldsSelected, + updatedColumns, }: { entityName: string; operationType: OperationType; @@ -51,6 +52,7 @@ export const validateOperationIsPermittedOrThrow = ({ selectedColumns: string[]; isFieldPermissionsEnabled?: boolean; allFieldsSelected: boolean; + updatedColumns: string[]; }) => { const objectMetadataIdForEntity = objectMetadataMaps.idByNameSingular[entityName]; @@ -77,8 +79,8 @@ export const validateOperationIsPermittedOrThrow = ({ return; } - const fieldMetadataIdForColumnNameMap = isFieldPermissionsEnabled - ? getFieldMetadataIdForColumnNameMap(objectMetadata) + const columnNameToFieldMetadataIdMap = isFieldPermissionsEnabled + ? getColumnNameToFieldMetadataIdMap(objectMetadata) : {}; const permissionsForEntity = @@ -97,7 +99,7 @@ export const validateOperationIsPermittedOrThrow = ({ validateReadFieldPermissionOrThrow({ restrictedFields: permissionsForEntity.restrictedFields, selectedColumns, - fieldMetadataIdForColumnNameMap, + columnNameToFieldMetadataIdMap, allFieldsSelected, }); } @@ -115,7 +117,15 @@ export const validateOperationIsPermittedOrThrow = ({ validateReadFieldPermissionOrThrow({ restrictedFields: permissionsForEntity.restrictedFields, selectedColumns, - fieldMetadataIdForColumnNameMap, + columnNameToFieldMetadataIdMap, + }); + } + + if (isFieldPermissionsEnabled && updatedColumns.length > 0) { + validateUpdateFieldPermissionOrThrow({ + restrictedFields: permissionsForEntity.restrictedFields, + updatedColumns, + columnNameToFieldMetadataIdMap, }); } break; @@ -131,7 +141,7 @@ export const validateOperationIsPermittedOrThrow = ({ validateReadFieldPermissionOrThrow({ restrictedFields: permissionsForEntity.restrictedFields, selectedColumns, - fieldMetadataIdForColumnNameMap, + columnNameToFieldMetadataIdMap, }); } break; @@ -148,7 +158,7 @@ export const validateOperationIsPermittedOrThrow = ({ validateReadFieldPermissionOrThrow({ restrictedFields: permissionsForEntity.restrictedFields, selectedColumns, - fieldMetadataIdForColumnNameMap, + columnNameToFieldMetadataIdMap, }); } break; @@ -189,6 +199,7 @@ export const validateQueryIsPermittedOrThrow = ({ ); let selectedColumns: string[] = []; + let updatedColumns: string[] = []; if (isFieldPermissionsEnabled) { selectedColumns = getSelectedColumnsFromExpressionMap({ @@ -196,6 +207,26 @@ export const validateQueryIsPermittedOrThrow = ({ expressionMap, allFieldsSelected, }); + + if (operationType !== 'select') { + const valuesSet = expressionMap.valuesSet; + + if (Array.isArray(valuesSet)) { + updatedColumns = valuesSet.reduce((acc, value) => { + const keys = Object.keys(value); + + keys.forEach((key) => { + if (!acc.includes(key)) { + acc.push(key); + } + }); + + return acc; + }, []); + } else { + updatedColumns = Object.keys(valuesSet ?? {}); + } + } } validateOperationIsPermittedOrThrow({ @@ -203,21 +234,22 @@ export const validateQueryIsPermittedOrThrow = ({ operationType: operationType as OperationType, objectRecordsPermissions, objectMetadataMaps, - selectedColumns: selectedColumns, + selectedColumns, isFieldPermissionsEnabled, allFieldsSelected, + updatedColumns, }); }; const validateReadFieldPermissionOrThrow = ({ restrictedFields, selectedColumns, - fieldMetadataIdForColumnNameMap, + columnNameToFieldMetadataIdMap, allFieldsSelected, }: { restrictedFields: RestrictedFields; selectedColumns: string[]; - fieldMetadataIdForColumnNameMap: Record; + columnNameToFieldMetadataIdMap: Record; allFieldsSelected?: boolean; }) => { if (isEmpty(restrictedFields)) { @@ -232,7 +264,7 @@ const validateReadFieldPermissionOrThrow = ({ } for (const column of selectedColumns) { - const fieldMetadataId = fieldMetadataIdForColumnNameMap[column]; + const fieldMetadataId = columnNameToFieldMetadataIdMap[column]; if (!fieldMetadataId) { throw new InternalServerError( @@ -249,6 +281,37 @@ const validateReadFieldPermissionOrThrow = ({ } }; +const validateUpdateFieldPermissionOrThrow = ({ + restrictedFields, + updatedColumns, + columnNameToFieldMetadataIdMap, +}: { + restrictedFields: RestrictedFields; + updatedColumns: string[]; + columnNameToFieldMetadataIdMap: Record; +}) => { + if (isEmpty(restrictedFields)) { + return; + } + + for (const column of updatedColumns) { + const fieldMetadataId = columnNameToFieldMetadataIdMap[column]; + + if (!fieldMetadataId) { + throw new InternalServerError( + `Field metadata id not found for column name ${column}`, + ); + } + + if (restrictedFields[fieldMetadataId]?.canUpdate === false) { + throw new PermissionsException( + PermissionsExceptionMessage.PERMISSION_DENIED, + PermissionsExceptionCode.PERMISSION_DENIED, + ); + } + } +}; + const getSelectedColumnsFromExpressionMap = ({ operationType, expressionMap, diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/get-column-name-to-field-metadata-id.util.spec.ts b/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/get-column-name-to-field-metadata-id.util.spec.ts new file mode 100644 index 0000000000..11ccf3cb79 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/get-column-name-to-field-metadata-id.util.spec.ts @@ -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, + ): 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); + }); + }); +}); diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/get-column-name-to-field-metadata-id.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/get-column-name-to-field-metadata-id.util.ts new file mode 100644 index 0000000000..de623cf9a2 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/utils/get-column-name-to-field-metadata-id.util.ts @@ -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 = {}; + + 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; +} diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/get-field-metadata-id-for-column-name.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/get-field-metadata-id-for-column-name.util.ts deleted file mode 100644 index 65bb5e4316..0000000000 --- a/packages/twenty-server/src/engine/twenty-orm/utils/get-field-metadata-id-for-column-name.util.ts +++ /dev/null @@ -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 = {}; - - 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; -} diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/get-field-metadata-id-to-column-names-map.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/get-field-metadata-id-to-column-names-map.util.ts index db52e399b8..2e34c44a94 100644 --- a/packages/twenty-server/src/engine/twenty-orm/utils/get-field-metadata-id-to-column-names-map.util.ts +++ b/packages/twenty-server/src/engine/twenty-orm/utils/get-field-metadata-id-to-column-names-map.util.ts @@ -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(); - 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; } diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/process-field-metadata-for-column-name-mapping.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/process-field-metadata-for-column-name-mapping.util.ts new file mode 100644 index 0000000000..ecddb75ea4 --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/utils/process-field-metadata-for-column-name-mapping.util.ts @@ -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, + }); + } + } + } +} diff --git a/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/fields-permissions/update-permissions.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/fields-permissions/update-permissions.integration-spec.ts new file mode 100644 index 0000000000..c24f77d8f3 --- /dev/null +++ b/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/fields-permissions/update-permissions.integration-spec.ts @@ -0,0 +1,435 @@ +import { randomUUID } from 'crypto'; + +import gql from 'graphql-tag'; +import request from 'supertest'; +import { createCustomRoleWithObjectPermissions } from 'test/integration/graphql/utils/create-custom-role-with-object-permissions.util'; +import { createManyOperationFactory } from 'test/integration/graphql/utils/create-many-operation-factory.util'; +import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util'; +import { deleteRole } from 'test/integration/graphql/utils/delete-one-role.util'; +import { makeGraphqlAPIRequestWithMemberRole } from 'test/integration/graphql/utils/make-graphql-api-request-with-member-role.util'; +import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; +import { updateFeatureFlagFactory } from 'test/integration/graphql/utils/update-feature-flag-factory.util'; +import { updateManyOperationFactory } from 'test/integration/graphql/utils/update-many-operation-factory.util'; +import { updateOneOperationFactory } from 'test/integration/graphql/utils/update-one-operation-factory.util'; +import { updateWorkspaceMemberRole } from 'test/integration/graphql/utils/update-workspace-member-role.util'; +import { upsertFieldPermissions } from 'test/integration/graphql/utils/upsert-field-permissions.util'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; + +import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; +import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception'; +import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-workspaces.util'; +import { WORKSPACE_MEMBER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/workspace-member-data-seeds.constant'; + +const client = request(`http://localhost:${APP_PORT}`); + +const COMPANY_GQL_FIELDS_WITH_EMPLOYEES = ` + id + name + employees +`; + +const COMPANY_GQL_FIELDS_WITHOUT_EMPLOYEES = ` + id + name +`; +const expectPermissionDeniedError = (response: any) => { + expect(response.body.errors[0].message).toBe( + PermissionsExceptionMessage.PERMISSION_DENIED, + ); + expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN); +}; + +describe('Field update permissions restrictions', () => { + let companyId: string; + let personId: string; + let customRoleId: string; + let companyObjectId: string; + let restrictedCompanyFieldId: string; + let originalMemberRoleId: string; + + const restrictUpdateAccessToCompanyEmployee = async ( + roleId: string, + companyObjectId: string, + restrictedCompanyFieldId: string, + ) => { + await upsertFieldPermissions({ + roleId, + fieldPermissions: [ + { + objectMetadataId: companyObjectId, + fieldMetadataId: restrictedCompanyFieldId, + canUpdateFieldValue: false, + }, + ], + }); + }; + + const restrictReadAccessToCompanyEmployee = async ( + roleId: string, + companyObjectId: string, + restrictedCompanyFieldId: string, + ) => { + await upsertFieldPermissions({ + roleId, + fieldPermissions: [ + { + objectMetadataId: companyObjectId, + fieldMetadataId: restrictedCompanyFieldId, + canReadFieldValue: false, + }, + ], + }); + }; + + beforeAll(async () => { + // Enable the feature flag + const enablePermissionsQuery = updateFeatureFlagFactory( + SEED_APPLE_WORKSPACE_ID, + 'IS_FIELDS_PERMISSIONS_ENABLED', + true, + ); + + await makeGraphqlAPIRequest(enablePermissionsQuery); + + // Get the original Member role ID for restoration later + const getRolesQuery = { + query: ` + query GetRoles { + getRoles { + id + label + } + } + `, + }; + const rolesResponse = await client + .post('/graphql') + .set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`) + .send(getRolesQuery); + + originalMemberRoleId = rolesResponse.body.data.getRoles.find( + (role: any) => role.label === 'Member', + ).id; + + // Create a company and a person + companyId = randomUUID(); + personId = randomUUID(); + const createCompanyOp = createOneOperationFactory({ + objectMetadataSingularName: 'company', + gqlFields: 'id name employees', + data: { id: companyId, name: 'TestCompany', employees: 10 }, + }); + + await makeGraphqlAPIRequest(createCompanyOp); + const createPersonOperation = createOneOperationFactory({ + objectMetadataSingularName: 'person', + gqlFields: 'id city', + data: { id: personId, city: 'Paris', companyId }, + }); + + await makeGraphqlAPIRequest(createPersonOperation); + + // Get object and field metadata IDs + const getObjectMetadataOp = { + query: gql` + query { + objects(paging: { first: 1000 }) { + edges { + node { + id + nameSingular + } + } + } + } + `, + }; + const objectMetadataResponse = + await makeMetadataAPIRequest(getObjectMetadataOp); + const objects = objectMetadataResponse.body.data.objects.edges; + + companyObjectId = objects.find( + (obj: any) => obj.node.nameSingular === 'company', + ).node.id; + + const getFieldMetadataOp = { + query: gql` + query { + fields(paging: { first: 1000 }) { + edges { + node { + id + name + object { + nameSingular + } + } + } + } + } + `, + }; + const fieldMetadataResponse = + await makeMetadataAPIRequest(getFieldMetadataOp); + const fields = fieldMetadataResponse.body.data.fields.edges; + + restrictedCompanyFieldId = fields.find( + (field: any) => + field.node.name === 'employees' && + field.node.object.nameSingular === 'company', + ).node.id; + }); + + afterAll(async () => { + // Restore the feature flag + const disablePermissionsQuery = updateFeatureFlagFactory( + SEED_APPLE_WORKSPACE_ID, + 'IS_FIELDS_PERMISSIONS_ENABLED', + false, + ); + + await makeGraphqlAPIRequest(disablePermissionsQuery); + + // Restore original role + const restoreMemberRoleQuery = { + query: ` + mutation UpdateWorkspaceMemberRole { + updateWorkspaceMemberRole( + workspaceMemberId: "${WORKSPACE_MEMBER_DATA_SEED_IDS.JONY}" + roleId: "${originalMemberRoleId}" + ) { id } + } + `, + }; + + await client + .post('/graphql') + .set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`) + .send(restoreMemberRoleQuery); + }); + + beforeEach(async () => { + const { roleId } = await createCustomRoleWithObjectPermissions({ + label: 'CompanyPeopleRole', + canReadCompany: true, + canReadPerson: true, + hasAllObjectRecordsReadPermission: true, + canUpdateCompany: true, + canUpdatePerson: true, + canUpdateOpportunities: true, + }); + + customRoleId = roleId; + await updateWorkspaceMemberRole({ + client, + roleId: customRoleId, + workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.JONY, + }); + }); + + afterEach(async () => { + if (customRoleId) { + await deleteRole(client, customRoleId); + customRoleId = ''; + } + }); + + // describe('should throw an error if updating a restricted field', () => { + // beforeEach(async () => { + // await restrictUpdateAccessToCompanyEmployee( + // customRoleId, + // companyObjectId, + // restrictedCompanyFieldId, + // ); + // }); + + // it('1. updateMany with restricted field', async () => { + // const graphqlOperation = updateManyOperationFactory({ + // objectMetadataSingularName: 'company', + // objectMetadataPluralName: 'companies', + // gqlFields: COMPANY_GQL_FIELDS_WITH_EMPLOYEES, + // data: { employees: 20 }, + // }); + + // const response = + // await makeGraphqlAPIRequestWithMemberRole(graphqlOperation); + + // expectPermissionDeniedError(response); + // }); + + // it('2. updateOne with restricted field', async () => { + // const graphqlOperation = updateOneOperationFactory({ + // objectMetadataSingularName: 'company', + // gqlFields: COMPANY_GQL_FIELDS_WITH_EMPLOYEES, + // recordId: companyId, + // data: { employees: 20 }, + // }); + + // const response = + // await makeGraphqlAPIRequestWithMemberRole(graphqlOperation); + + // expectPermissionDeniedError(response); + // }); + // }); + + // describe('should succeed if updating non-restricted fields', () => { + // beforeEach(async () => { + // await restrictUpdateAccessToCompanyEmployee( + // customRoleId, + // companyObjectId, + // restrictedCompanyFieldId, + // ); + // }); + + // it('1. updateMany with non-restricted field', async () => { + // const graphqlOperation = updateManyOperationFactory({ + // objectMetadataSingularName: 'company', + // objectMetadataPluralName: 'companies', + // gqlFields: COMPANY_GQL_FIELDS_WITHOUT_EMPLOYEES, + // data: { name: 'UpdatedCompany' }, + // }); + + // const response = + // await makeGraphqlAPIRequestWithMemberRole(graphqlOperation); + + // expect(response.body.errors).toBeUndefined(); + // expect(response.body.data).toBeDefined(); + // expect(response.body.data.updateCompanies[0].name).toBe('UpdatedCompany'); + // }); + + // it('2. updateOne with non-restricted field', async () => { + // const graphqlOperation = updateOneOperationFactory({ + // objectMetadataSingularName: 'company', + // gqlFields: COMPANY_GQL_FIELDS_WITHOUT_EMPLOYEES, + // recordId: companyId, + // data: { name: 'UpdatedCompany2' }, + // }); + + // const response = + // await makeGraphqlAPIRequestWithMemberRole(graphqlOperation); + + // expect(response.body.errors).toBeUndefined(); + // expect(response.body.data).toBeDefined(); + // expect(response.body.data.updateCompany.name).toBe('UpdatedCompany2'); + // }); + // }); + + describe('should throw an error if creating with restricted fields', () => { + beforeEach(async () => { + await restrictUpdateAccessToCompanyEmployee( + customRoleId, + companyObjectId, + restrictedCompanyFieldId, + ); + }); + + it('1. createMany with restricted field', async () => { + const graphqlOperation = createManyOperationFactory({ + objectMetadataSingularName: 'company', + objectMetadataPluralName: 'companies', + gqlFields: COMPANY_GQL_FIELDS_WITH_EMPLOYEES, + data: [ + { id: randomUUID(), name: 'NewCompany1', employees: 15 }, + { id: randomUUID(), name: 'NewCompany2', employees: 20 }, + ], + }); + + const response = + await makeGraphqlAPIRequestWithMemberRole(graphqlOperation); + + expectPermissionDeniedError(response); + }); + + it('2. createOne with restricted field', async () => { + const graphqlOperation = createOneOperationFactory({ + objectMetadataSingularName: 'company', + gqlFields: COMPANY_GQL_FIELDS_WITH_EMPLOYEES, + data: { id: randomUUID(), name: 'NewCompany3', employees: 25 }, + }); + + const response = + await makeGraphqlAPIRequestWithMemberRole(graphqlOperation); + + expectPermissionDeniedError(response); + }); + }); + describe('should throw an error if reading restricted fields in update operations', () => { + beforeEach(async () => { + await restrictReadAccessToCompanyEmployee( + customRoleId, + companyObjectId, + restrictedCompanyFieldId, + ); + }); + + it('1. updateMany requesting restricted field in response', async () => { + const graphqlOperation = updateManyOperationFactory({ + objectMetadataSingularName: 'company', + objectMetadataPluralName: 'companies', + gqlFields: COMPANY_GQL_FIELDS_WITH_EMPLOYEES, + data: { name: 'UpdatedCompany' }, + }); + + const response = + await makeGraphqlAPIRequestWithMemberRole(graphqlOperation); + + expectPermissionDeniedError(response); + }); + + it('2. updateOne requesting restricted field in response', async () => { + const graphqlOperation = updateOneOperationFactory({ + objectMetadataSingularName: 'company', + gqlFields: COMPANY_GQL_FIELDS_WITH_EMPLOYEES, + recordId: companyId, + data: { name: 'UpdatedCompany' }, + }); + + const response = + await makeGraphqlAPIRequestWithMemberRole(graphqlOperation); + + expectPermissionDeniedError(response); + }); + }); + + describe('should succeed if not requesting restricted fields in update operations', () => { + beforeEach(async () => { + await restrictUpdateAccessToCompanyEmployee( + customRoleId, + companyObjectId, + restrictedCompanyFieldId, + ); + }); + + it('1. updateMany not requesting restricted field in response', async () => { + const graphqlOperation = updateManyOperationFactory({ + objectMetadataSingularName: 'company', + objectMetadataPluralName: 'companies', + gqlFields: COMPANY_GQL_FIELDS_WITHOUT_EMPLOYEES, + data: { name: 'UpdatedCompany' }, + }); + + const response = + await makeGraphqlAPIRequestWithMemberRole(graphqlOperation); + + expect(response.body.errors).toBeUndefined(); + expect(response.body.data).toBeDefined(); + expect(response.body.data.updateCompanies[0].name).toBe('UpdatedCompany'); + }); + + it('2. updateOne not requesting restricted field in response', async () => { + const graphqlOperation = updateOneOperationFactory({ + objectMetadataSingularName: 'company', + gqlFields: COMPANY_GQL_FIELDS_WITHOUT_EMPLOYEES, + recordId: companyId, + data: { name: 'UpdatedCompany2' }, + }); + + const response = + await makeGraphqlAPIRequestWithMemberRole(graphqlOperation); + + expect(response.body.errors).toBeUndefined(); + expect(response.body.data).toBeDefined(); + expect(response.body.data.updateCompany.name).toBe('UpdatedCompany2'); + }); + }); +}); diff --git a/packages/twenty-server/test/integration/graphql/utils/create-custom-role-with-object-permissions.util.ts b/packages/twenty-server/test/integration/graphql/utils/create-custom-role-with-object-permissions.util.ts index f49d4bbfb2..6c90da1a63 100644 --- a/packages/twenty-server/test/integration/graphql/utils/create-custom-role-with-object-permissions.util.ts +++ b/packages/twenty-server/test/integration/graphql/utils/create-custom-role-with-object-permissions.util.ts @@ -7,6 +7,9 @@ export const createCustomRoleWithObjectPermissions = async (options: { canReadPerson?: boolean; canReadCompany?: boolean; canReadOpportunities?: boolean; + canUpdatePerson?: boolean; + canUpdateCompany?: boolean; + canUpdateOpportunities?: boolean; hasAllObjectRecordsReadPermission?: boolean; }) => { const createRoleOperation = { @@ -68,31 +71,47 @@ export const createCustomRoleWithObjectPermissions = async (options: { // Create object permissions based on the options const objectPermissions = []; - if (options.canReadPerson !== undefined) { + if ( + options.canReadPerson !== undefined || + options.canUpdatePerson !== undefined + ) { objectPermissions.push({ objectMetadataId: personObjectId, canReadObjectRecords: options.canReadPerson, - canUpdateObjectRecords: false, + canUpdateObjectRecords: + options.canUpdatePerson === undefined ? false : options.canUpdatePerson, canSoftDeleteObjectRecords: false, canDestroyObjectRecords: false, }); } - if (options.canReadCompany !== undefined) { + if ( + options.canReadCompany !== undefined || + options.canUpdateCompany !== undefined + ) { objectPermissions.push({ objectMetadataId: companyObjectId, canReadObjectRecords: options.canReadCompany, - canUpdateObjectRecords: false, + canUpdateObjectRecords: + options.canUpdateCompany === undefined + ? false + : options.canUpdateCompany, canSoftDeleteObjectRecords: false, canDestroyObjectRecords: false, }); } - if (options.canReadOpportunities !== undefined) { + if ( + options.canReadOpportunities !== undefined || + options.canUpdateOpportunities !== undefined + ) { objectPermissions.push({ objectMetadataId: opportunityObjectId, canReadObjectRecords: options.canReadOpportunities, - canUpdateObjectRecords: false, + canUpdateObjectRecords: + options.canUpdateOpportunities === undefined + ? false + : options.canUpdateOpportunities, canSoftDeleteObjectRecords: false, canDestroyObjectRecords: false, }); diff --git a/packages/twenty-server/test/integration/rest/suites/field-permissions.integration-spec.ts b/packages/twenty-server/test/integration/rest/suites/field-permissions.integration-spec.ts index afff3bcae5..00d32c2bb1 100644 --- a/packages/twenty-server/test/integration/rest/suites/field-permissions.integration-spec.ts +++ b/packages/twenty-server/test/integration/rest/suites/field-permissions.integration-spec.ts @@ -1,5 +1,7 @@ import gql from 'graphql-tag'; +import { TEST_COMPANY_1_ID } from 'test/integration/constants/test-company-ids.constants'; import { TEST_PERSON_1_ID } from 'test/integration/constants/test-person-ids.constants'; +import { TEST_PRIMARY_LINK_URL } from 'test/integration/constants/test-primary-link-url.constant'; import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util'; import { updateFeatureFlagFactory } from 'test/integration/graphql/utils/update-feature-flag-factory.util'; import { upsertFieldPermissions } from 'test/integration/graphql/utils/upsert-field-permissions.util'; @@ -11,13 +13,25 @@ import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder describe('Restricted fields', () => { let personCity: string; - let adminRoleId: string; + let memberRoleId: string; let personObjectId: string; let emailsFieldId: string; + let phonesFieldId: string; beforeAll(async () => { personCity = generateRecordName(TEST_PERSON_1_ID); + await makeRestAPIRequest({ + method: 'post', + path: '/companies', + body: { + id: TEST_COMPANY_1_ID, + domainName: { + primaryLinkUrl: TEST_PRIMARY_LINK_URL, + }, + }, + }); + await makeRestAPIRequest({ method: 'post', path: '/people', @@ -27,6 +41,11 @@ describe('Restricted fields', () => { emails: { primaryEmail: 'test@test.com', }, + phones: { + primaryPhoneNumber: '123456789', + primaryPhoneCountryCode: 'US', + primaryPhoneCallingCode: '+1', + }, }, }); @@ -85,7 +104,13 @@ describe('Restricted fields', () => { field.node.object.nameSingular === 'person', ).node.id; - // Get admin role ID + phonesFieldId = fields.find( + (field: any) => + field.node.name === 'phones' && + field.node.object.nameSingular === 'person', + ).node.id; + + // Get member role ID const getRolesOperation = { query: gql` query { @@ -99,13 +124,13 @@ describe('Restricted fields', () => { const rolesResponse = await makeMetadataAPIRequest(getRolesOperation); - adminRoleId = rolesResponse.body.data.getRoles.find( + memberRoleId = rolesResponse.body.data.getRoles.find( (role: any) => role.label === 'Member', )?.id; // Create field permission restricting read access to email field await upsertFieldPermissions({ - roleId: adminRoleId, + roleId: memberRoleId, fieldPermissions: [ { objectMetadataId: personObjectId, @@ -137,7 +162,8 @@ describe('Restricted fields', () => { await makeGraphqlAPIRequest(disablePermissionsQuery); }); - it('should hide fields when user has restricted read permissions', async () => { + + it('should hide fields when user has restricted read permissions - findOne', async () => { await makeRestAPIRequest({ method: 'get', path: `/people/${TEST_PERSON_1_ID}`, @@ -152,6 +178,248 @@ describe('Restricted fields', () => { expect(person.emails).toBeUndefined(); }); }); + + describe('updateOne', () => { + it('should hide fields in the response when user has restricted read permissions', async () => { + // Create field permission restricting update access to phones field + await upsertFieldPermissions({ + roleId: memberRoleId, + fieldPermissions: [ + { + objectMetadataId: personObjectId, + fieldMetadataId: phonesFieldId, + canReadFieldValue: false, + canUpdateFieldValue: null, + }, + ], + }); + + await makeRestAPIRequest({ + method: 'patch', + path: `/people/${TEST_PERSON_1_ID}`, + bearer: APPLE_JONY_MEMBER_ACCESS_TOKEN, + body: { + name: { + firstName: 'John', + }, + }, + }) + .expect(200) + .expect((res) => { + const updatedPerson = res.body.data.updatePerson; + + expect(updatedPerson.name.firstName).toBe('John'); + expect(updatedPerson.phones).toBeUndefined(); + }); + }); + it('should block update when user tries to update non-updatable field', async () => { + // Create field permission restricting update access to phones field + await upsertFieldPermissions({ + roleId: memberRoleId, + fieldPermissions: [ + { + objectMetadataId: personObjectId, + fieldMetadataId: phonesFieldId, + canReadFieldValue: null, + canUpdateFieldValue: false, + }, + ], + }); + + await makeRestAPIRequest({ + method: 'patch', + path: `/people/${TEST_PERSON_1_ID}`, + bearer: APPLE_JONY_MEMBER_ACCESS_TOKEN, + body: { + phones: { + primaryPhoneNumber: '987654321', + primaryPhoneCountryCode: 'FR', + primaryPhoneCallingCode: '+33', + }, + }, + }) + .expect(400) + .expect((res) => { + expect(res.body.messages[0]).toContain( + 'User does not have permission', + ); + }); + }); + + it('should allow update when user has no restricted update permissions', async () => { + // Remove field permission restrictions + await upsertFieldPermissions({ + roleId: memberRoleId, + fieldPermissions: [ + { + objectMetadataId: personObjectId, + fieldMetadataId: phonesFieldId, + canReadFieldValue: null, + canUpdateFieldValue: null, + }, + ], + }); + + await makeRestAPIRequest({ + method: 'patch', + path: `/people/${TEST_PERSON_1_ID}`, + bearer: APPLE_JONY_MEMBER_ACCESS_TOKEN, + body: { + city: 'Updated City', + }, + }) + .expect(200) + .expect((res) => { + const updatedPerson = res.body.data.updatePerson; + + expect(updatedPerson.city).toBe('Updated City'); + }); + }); + }); + + describe('createOne', () => { + it('should block create when user has restricted update permissions on phones field', async () => { + // Create field permission restricting update access to phones field + await upsertFieldPermissions({ + roleId: memberRoleId, + fieldPermissions: [ + { + objectMetadataId: personObjectId, + fieldMetadataId: phonesFieldId, + canReadFieldValue: null, + canUpdateFieldValue: false, + }, + ], + }); + + await makeRestAPIRequest({ + method: 'post', + path: `/people`, + bearer: APPLE_JONY_MEMBER_ACCESS_TOKEN, + body: { + phones: { + primaryPhoneNumber: '555123456', + primaryPhoneCountryCode: 'US', + primaryPhoneCallingCode: '+1', + }, + }, + }) + .expect(400) + .expect((res) => { + expect(res.body.messages[0]).toContain( + 'User does not have permission', + ); + }); + }); + + it('should allow create when user has no restricted update permissions', async () => { + // Remove field permission restrictions on phones + await upsertFieldPermissions({ + roleId: memberRoleId, + fieldPermissions: [ + { + objectMetadataId: personObjectId, + fieldMetadataId: phonesFieldId, + canReadFieldValue: null, + canUpdateFieldValue: null, + }, + ], + }); + + await makeRestAPIRequest({ + method: 'post', + path: `/people`, + bearer: APPLE_JONY_MEMBER_ACCESS_TOKEN, + body: { + city: 'New City', + }, + }) + .expect(201) + .expect((res) => { + const createdPerson = res.body.data.createPerson; + + expect(createdPerson.city).toBe('New City'); + expect(createdPerson.emails).toBeUndefined(); // No reading rights on emails + }); + }); + }); + + describe('createMany', () => { + it('should block createMany when user has restricted update permissions on phones field', async () => { + // Create field permission restricting update access to phones field + await upsertFieldPermissions({ + roleId: memberRoleId, + fieldPermissions: [ + { + objectMetadataId: personObjectId, + fieldMetadataId: phonesFieldId, + canReadFieldValue: null, + canUpdateFieldValue: false, + }, + ], + }); + + await makeRestAPIRequest({ + method: 'post', + path: `/batch/people`, + bearer: APPLE_JONY_MEMBER_ACCESS_TOKEN, + body: [ + { + phones: { + primaryPhoneNumber: '555123456', + primaryPhoneCountryCode: 'US', + primaryPhoneCallingCode: '+1', + }, + }, + ], + }) + .expect(400) + .expect((res) => { + expect(res.body.messages[0]).toContain( + 'User does not have permission', + ); + }); + }); + + it('should allow createMany when user has no restricted update permissions', async () => { + // Remove field permission restrictions + await upsertFieldPermissions({ + roleId: memberRoleId, + fieldPermissions: [ + { + objectMetadataId: personObjectId, + fieldMetadataId: phonesFieldId, + canReadFieldValue: null, + canUpdateFieldValue: null, + }, + ], + }); + + await makeRestAPIRequest({ + method: 'post', + path: `/batch/people`, + bearer: APPLE_JONY_MEMBER_ACCESS_TOKEN, + body: [ + { + city: 'Batch City 1', + }, + { + city: 'Batch City 2', + }, + ], + }) + .expect(201) + .expect((res) => { + const createdPeople = res.body.data.createPeople; + + expect(createdPeople).toHaveLength(2); + expect(createdPeople[0].city).toBe('Batch City 1'); + expect(createdPeople[0].emails).toBeUndefined(); // No reading rights on emails + expect(createdPeople[1].city).toBe('Batch City 2'); + expect(createdPeople[1].emails).toBeUndefined(); // No reading rights on emails + }); + }); + }); }); describe('With feature flag disabled', () => { @@ -170,5 +438,26 @@ describe('Restricted fields', () => { expect(person.emails).toBeDefined(); }); }); + + it('should allow updates despite field permission restriction', async () => { + await makeRestAPIRequest({ + method: 'patch', + path: `/people/${TEST_PERSON_1_ID}`, + bearer: APPLE_JONY_MEMBER_ACCESS_TOKEN, + body: { + phones: { + primaryPhoneNumber: '111222333', + primaryPhoneCountryCode: 'US', + primaryPhoneCallingCode: '+1', + }, + }, + }) + .expect(200) + .expect((res) => { + const updatedPerson = res.body.data.updatePerson; + + expect(updatedPerson.phones.primaryPhoneNumber).toBe('111222333'); + }); + }); }); });