From 5efe69f8d34bd915fb5954f1ccd09f96009d382a Mon Sep 17 00:00:00 2001 From: Abdul Rahman <81605929+abdulrahmancodes@users.noreply.github.com> Date: Fri, 27 Mar 2026 19:17:27 +0530 Subject: [PATCH] Migrate field permission to syncable entity (#18751) Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com> Co-authored-by: Charles Bochet --- .../src/metadata/generated/schema.graphql | 6 +- .../src/metadata/generated/schema.ts | 11 +- .../src/metadata/generated/types.ts | 15 +- .../src/generated-metadata/graphql.ts | 4 + .../hooks/useMetadataErrorHandler.ts | 1 + .../types/FieldMetadataItem.ts | 2 + ...ntify-field-permission-metadata.command.ts | 111 ++++ ...ation-id-not-nullable-migration.command.ts | 72 +++ .../1-20-upgrade-version-command.module.ts | 6 + .../upgrade.command.ts | 7 + ...-and-application-id-to-field-permission.ts | 25 + ...-identifier-and-application-id-not-null.ts | 58 ++ ...tifier-and-application-id-not-null.util.ts | 20 + .../__mocks__/mockPersonObjectMetadata.ts | 2 + ...t-to-universal-flat-field-metadata.util.ts | 1 + ...-to-universal-flat-object-metadata.util.ts | 1 + ...le-manifest-to-universal-flat-role.util.ts | 1 + .../object-record-changed-values.spec.ts | 2 + .../field-metadata/dtos/field-metadata.dto.ts | 1 + ...ompare-and-stringify.constant.spec.ts.snap | 10 + ...configuration-by-metadata-name.constant.ts | 37 ++ ...ny-to-one-metadata-foreign-key.constant.ts | 13 + ...many-to-one-metadata-relations.constant.ts | 25 + ...tadata-entity-by-metadata-name.constant.ts | 2 + ...quired-metadata-for-validation.constant.ts | 5 + ...l-metadata-serialized-relation.constant.ts | 1 + ...one-to-many-metadata-relations.constant.ts | 22 +- ...ny-or-all-flat-entity-maps-cache.module.ts | 5 + ...ny-entity-relation-properties.type-test.ts | 1 + .../all-flat-entity-types-by-metadata-name.ts | 27 + ...a-related-metadata-names.util.spec.ts.snap | 11 + ...ata-names-children-first.util.spec.ts.snap | 1 + .../__mocks__/get-flat-field-metadata.mock.ts | 2 + ...elation-target-flat-field-metadata-mock.ts | 2 + ...lat-field-metadatas-to-create.spec.ts.snap | 4 + ...data-entity-to-flat-field-metadata.util.ts | 6 + ...d-metadata-from-create-field-input.util.ts | 1 + ...permission-editable-properties.constant.ts | 9 + ...flat-field-permission-map-cache.service.ts | 100 +++ .../types/flat-field-permission-maps.type.ts | 4 + .../types/flat-field-permission.type.ts | 4 + ...to-universal-flat-field-permission.util.ts | 56 ++ ...on-entity-to-flat-field-permission.util.ts | 81 +++ .../get-flat-object-metadata.mock.ts | 2 + ...and-flat-field-metadatas-to-create.util.ts | 1 + ...ata-entity-to-flat-object-metadata.util.ts | 6 + ...-role-input-to-flat-role-to-create.util.ts | 1 + .../from-role-entity-to-flat-role.util.ts | 3 + ...al-system-flat-field-metadatas.constant.ts | 8 + ...-field-metadatas-for-custom-object.util.ts | 1 + .../field-permissions.service.spec.ts | 362 ++++++++--- .../field-permission.entity.ts | 4 +- .../field-permission.service.ts | 596 +++++++++--------- ...permission-to-field-permission-dto.util.ts | 13 + .../permissions/permissions.exception.ts | 4 + ...sion-graphql-api-exception-handler.util.ts | 1 + .../metadata-modules/role/role.resolver.ts | 19 +- .../workspace-flat-role-map-cache.service.ts | 2 +- .../workspace-entity-manager.spec.ts | 4 + .../__tests__/workspace.repository.spec.ts | 2 + ...elation-connect-query-configs.util.spec.ts | 1 + ...event-to-database-batch-event.util.spec.ts | 1 + ...umn-name-to-field-metadata-id.util.spec.ts | 2 + ...tadata-id-to-column-names-map.util.spec.ts | 2 + ...ow-level-permission-predicate.util.spec.ts | 2 + .../types/workspace-cache-key.type.ts | 1 + ...reate-standard-field-flat-metadata.util.ts | 2 + ...ndard-relation-field-flat-metadata.util.ts | 2 + ...eate-standard-object-flat-metadata.util.ts | 2 + ...create-standard-role-flat-metadata.util.ts | 1 + ...orkspace-migration-action-with-ids.util.ts | 1 + ...ce-migration-build-orchestrator.service.ts | 37 ++ ...ggregator-properties.constant.spec.ts.snap | 6 +- ...ized-relation-by-metadata-name.constant.ts | 1 + .../universal-flat-field-permission.type.ts | 7 + ...pdated-matrix-dispatcher.util.spec.ts.snap | 8 + ...-migration-field-permission-action.type.ts | 24 + ...ield-permission-actions-builder.service.ts | 120 ++++ ...flat-field-permission-validator.service.ts | 290 +++++++++ ...ace-migration-builder-validators.module.ts | 3 + .../workspace-migration-builder.module.ts | 3 + ...field-permission-action-handler.service.ts | 65 ++ ...field-permission-action-handler.service.ts | 46 ++ ...field-permission-action-handler.service.ts | 67 ++ ...ld-metadata-to-flat-field-metadata.util.ts | 1 + ...t-metadata-to-flat-object-metadata.util.ts | 1 + ...migration-runner-action-handlers.module.ts | 7 + .../metadata-event-to-emit.constant.ts | 1 + ...metadata-events-from-create-action.util.ts | 1 + ...metadata-events-from-delete-action.util.ts | 1 + ...metadata-events-from-update-action.util.ts | 1 + ...ate-action-on-all-flat-entity-maps.util.ts | 1 + ...ete-action-on-all-flat-entity-maps.util.ts | 1 + ...ate-action-on-all-flat-entity-maps.util.ts | 1 + .../read-permissions.integration-spec.ts | 36 +- .../update-permissions.integration-spec.ts | 61 +- ...ield-permissions-operation-factory.util.ts | 39 -- .../utils/upsert-field-permissions.util.ts | 29 +- ...permission-upsert.integration-spec.ts.snap | 126 ++++ ...ield-permission-upsert.integration-spec.ts | 308 +++++++++ ...ield-permission-upsert.integration-spec.ts | 238 +++++++ ...rt-field-permissions-query-factory.util.ts | 35 + .../utils/upsert-field-permissions.util.ts | 43 ++ .../field-permissions.integration-spec.ts | 16 +- .../constants/all-metadata-name.constant.ts | 1 + 105 files changed, 2956 insertions(+), 490 deletions(-) create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/1-20/1-20-identify-field-permission-metadata.command.ts create mode 100644 packages/twenty-server/src/database/commands/upgrade-version-command/1-20/1-20-make-field-permission-universal-identifier-and-application-id-not-nullable-migration.command.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1773400000000-add-universal-identifier-and-application-id-to-field-permission.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1773400000001-make-field-permission-universal-identifier-and-application-id-not-null.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/utils/1773400000000-make-field-permission-universal-identifier-and-application-id-not-null.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-field-permission/constants/flat-field-permission-editable-properties.constant.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-field-permission/services/workspace-flat-field-permission-map-cache.service.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-field-permission/types/flat-field-permission-maps.type.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-field-permission/types/flat-field-permission.type.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-field-permission/utils/from-create-field-permission-input-to-universal-flat-field-permission.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/flat-field-permission/utils/from-field-permission-entity-to-flat-field-permission.util.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/object-permission/utils/from-flat-field-permission-to-field-permission-dto.util.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-permission.type.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field-permission/types/workspace-migration-field-permission-action.type.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field-permission/workspace-migration-field-permission-actions-builder.service.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-field-permission-validator.service.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/field-permission/services/create-field-permission-action-handler.service.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/field-permission/services/delete-field-permission-action-handler.service.ts create mode 100644 packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/field-permission/services/update-field-permission-action-handler.service.ts delete mode 100644 packages/twenty-server/test/integration/graphql/utils/upsert-field-permissions-operation-factory.util.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/field-permission/__snapshots__/failing-field-permission-upsert.integration-spec.ts.snap create mode 100644 packages/twenty-server/test/integration/metadata/suites/field-permission/failing-field-permission-upsert.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/field-permission/successful-field-permission-upsert.integration-spec.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/field-permission/utils/upsert-field-permissions-query-factory.util.ts create mode 100644 packages/twenty-server/test/integration/metadata/suites/field-permission/utils/upsert-field-permissions.util.ts diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql index 0f4a85bde40..2e80259f5fe 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.graphql +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.graphql @@ -343,6 +343,7 @@ type Field { defaultValue: JSON options: JSON settings: JSON + objectMetadataId: UUID! isLabelSyncedWithName: Boolean morphId: UUID createdAt: DateTime! @@ -540,6 +541,7 @@ input FieldFilter { isActive: BooleanFieldComparison isSystem: BooleanFieldComparison isUIReadOnly: BooleanFieldComparison + objectMetadataId: UUIDFilterComparison } input IndexFilter { @@ -3090,6 +3092,7 @@ enum AllMetadataName { navigationMenuItem permissionFlag objectPermission + fieldPermission frontComponent webhook } @@ -4273,8 +4276,8 @@ input CreateFieldInput { defaultValue: JSON options: JSON settings: JSON - isLabelSyncedWithName: Boolean objectMetadataId: UUID! + isLabelSyncedWithName: Boolean isRemoteCreation: Boolean relationCreationPayload: JSON morphRelationsCreationPayload: [JSON!] @@ -4302,6 +4305,7 @@ input UpdateFieldInput { defaultValue: JSON options: JSON settings: JSON + objectMetadataId: UUID isLabelSyncedWithName: Boolean morphRelationsUpdatePayload: [JSON!] } diff --git a/packages/twenty-client-sdk/src/metadata/generated/schema.ts b/packages/twenty-client-sdk/src/metadata/generated/schema.ts index 0cfe42ab324..9a0450012f4 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/schema.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/schema.ts @@ -296,6 +296,7 @@ export interface Field { defaultValue?: Scalars['JSON'] options?: Scalars['JSON'] settings?: Scalars['JSON'] + objectMetadataId: Scalars['UUID'] isLabelSyncedWithName?: Scalars['Boolean'] morphId?: Scalars['UUID'] createdAt: Scalars['DateTime'] @@ -2676,7 +2677,7 @@ export interface CollectionHash { __typename: 'CollectionHash' } -export type AllMetadataName = 'fieldMetadata' | 'objectMetadata' | 'view' | 'viewField' | 'viewFieldGroup' | 'viewGroup' | 'viewSort' | 'rowLevelPermissionPredicate' | 'rowLevelPermissionPredicateGroup' | 'viewFilterGroup' | 'index' | 'logicFunction' | 'viewFilter' | 'role' | 'roleTarget' | 'agent' | 'skill' | 'pageLayout' | 'pageLayoutWidget' | 'pageLayoutTab' | 'commandMenuItem' | 'navigationMenuItem' | 'permissionFlag' | 'objectPermission' | 'frontComponent' | 'webhook' +export type AllMetadataName = 'fieldMetadata' | 'objectMetadata' | 'view' | 'viewField' | 'viewFieldGroup' | 'viewGroup' | 'viewSort' | 'rowLevelPermissionPredicate' | 'rowLevelPermissionPredicateGroup' | 'viewFilterGroup' | 'index' | 'logicFunction' | 'viewFilter' | 'role' | 'roleTarget' | 'agent' | 'skill' | 'pageLayout' | 'pageLayoutWidget' | 'pageLayoutTab' | 'commandMenuItem' | 'navigationMenuItem' | 'permissionFlag' | 'objectPermission' | 'fieldPermission' | 'frontComponent' | 'webhook' export interface MinimalObjectMetadata { id: Scalars['UUID'] @@ -3361,6 +3362,7 @@ export interface FieldGenqlSelection{ defaultValue?: boolean | number options?: boolean | number settings?: boolean | number + objectMetadataId?: boolean | number isLabelSyncedWithName?: boolean | number morphId?: boolean | number createdAt?: boolean | number @@ -3477,7 +3479,7 @@ export interface ObjectGenqlSelection{ __scalar?: boolean | number } -export interface FieldFilter {and?: (FieldFilter[] | null),or?: (FieldFilter[] | null),id?: (UUIDFilterComparison | null),isCustom?: (BooleanFieldComparison | null),isActive?: (BooleanFieldComparison | null),isSystem?: (BooleanFieldComparison | null),isUIReadOnly?: (BooleanFieldComparison | null)} +export interface FieldFilter {and?: (FieldFilter[] | null),or?: (FieldFilter[] | null),id?: (UUIDFilterComparison | null),isCustom?: (BooleanFieldComparison | null),isActive?: (BooleanFieldComparison | null),isSystem?: (BooleanFieldComparison | null),isUIReadOnly?: (BooleanFieldComparison | null),objectMetadataId?: (UUIDFilterComparison | null)} export interface IndexFilter {and?: (IndexFilter[] | null),or?: (IndexFilter[] | null),id?: (UUIDFilterComparison | null),isCustom?: (BooleanFieldComparison | null)} @@ -6529,7 +6531,7 @@ export interface CreateOneFieldMetadataInput { /** The record to create */ field: CreateFieldInput} -export interface CreateFieldInput {type: FieldMetadataType,name: Scalars['String'],label: Scalars['String'],description?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),isCustom?: (Scalars['Boolean'] | null),isActive?: (Scalars['Boolean'] | null),isSystem?: (Scalars['Boolean'] | null),isUIReadOnly?: (Scalars['Boolean'] | null),isNullable?: (Scalars['Boolean'] | null),isUnique?: (Scalars['Boolean'] | null),defaultValue?: (Scalars['JSON'] | null),options?: (Scalars['JSON'] | null),settings?: (Scalars['JSON'] | null),isLabelSyncedWithName?: (Scalars['Boolean'] | null),objectMetadataId: Scalars['UUID'],isRemoteCreation?: (Scalars['Boolean'] | null),relationCreationPayload?: (Scalars['JSON'] | null),morphRelationsCreationPayload?: (Scalars['JSON'][] | null)} +export interface CreateFieldInput {type: FieldMetadataType,name: Scalars['String'],label: Scalars['String'],description?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),isCustom?: (Scalars['Boolean'] | null),isActive?: (Scalars['Boolean'] | null),isSystem?: (Scalars['Boolean'] | null),isUIReadOnly?: (Scalars['Boolean'] | null),isNullable?: (Scalars['Boolean'] | null),isUnique?: (Scalars['Boolean'] | null),defaultValue?: (Scalars['JSON'] | null),options?: (Scalars['JSON'] | null),settings?: (Scalars['JSON'] | null),objectMetadataId: Scalars['UUID'],isLabelSyncedWithName?: (Scalars['Boolean'] | null),isRemoteCreation?: (Scalars['Boolean'] | null),relationCreationPayload?: (Scalars['JSON'] | null),morphRelationsCreationPayload?: (Scalars['JSON'][] | null)} export interface UpdateOneFieldMetadataInput { /** The id of the record to update */ @@ -6537,7 +6539,7 @@ id: Scalars['UUID'], /** The record to update */ update: UpdateFieldInput} -export interface UpdateFieldInput {universalIdentifier?: (Scalars['String'] | null),name?: (Scalars['String'] | null),label?: (Scalars['String'] | null),description?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),isActive?: (Scalars['Boolean'] | null),isSystem?: (Scalars['Boolean'] | null),isUIReadOnly?: (Scalars['Boolean'] | null),isNullable?: (Scalars['Boolean'] | null),isUnique?: (Scalars['Boolean'] | null),defaultValue?: (Scalars['JSON'] | null),options?: (Scalars['JSON'] | null),settings?: (Scalars['JSON'] | null),isLabelSyncedWithName?: (Scalars['Boolean'] | null),morphRelationsUpdatePayload?: (Scalars['JSON'][] | null)} +export interface UpdateFieldInput {universalIdentifier?: (Scalars['String'] | null),name?: (Scalars['String'] | null),label?: (Scalars['String'] | null),description?: (Scalars['String'] | null),icon?: (Scalars['String'] | null),isActive?: (Scalars['Boolean'] | null),isSystem?: (Scalars['Boolean'] | null),isUIReadOnly?: (Scalars['Boolean'] | null),isNullable?: (Scalars['Boolean'] | null),isUnique?: (Scalars['Boolean'] | null),defaultValue?: (Scalars['JSON'] | null),options?: (Scalars['JSON'] | null),settings?: (Scalars['JSON'] | null),objectMetadataId?: (Scalars['UUID'] | null),isLabelSyncedWithName?: (Scalars['Boolean'] | null),morphRelationsUpdatePayload?: (Scalars['JSON'][] | null)} export interface DeleteOneFieldInput { /** The id of the field to delete. */ @@ -9427,6 +9429,7 @@ export const enumAllMetadataName = { navigationMenuItem: 'navigationMenuItem' as const, permissionFlag: 'permissionFlag' as const, objectPermission: 'objectPermission' as const, + fieldPermission: 'fieldPermission' as const, frontComponent: 'frontComponent' as const, webhook: 'webhook' as const } diff --git a/packages/twenty-client-sdk/src/metadata/generated/types.ts b/packages/twenty-client-sdk/src/metadata/generated/types.ts index af6d183c540..e628df7f041 100644 --- a/packages/twenty-client-sdk/src/metadata/generated/types.ts +++ b/packages/twenty-client-sdk/src/metadata/generated/types.ts @@ -777,6 +777,9 @@ export default { "settings": [ 15 ], + "objectMetadataId": [ + 3 + ], "isLabelSyncedWithName": [ 6 ], @@ -1164,6 +1167,9 @@ export default { "isUIReadOnly": [ 43 ], + "objectMetadataId": [ + 42 + ], "__typename": [ 1 ] @@ -10754,12 +10760,12 @@ export default { "settings": [ 15 ], - "isLabelSyncedWithName": [ - 6 - ], "objectMetadataId": [ 3 ], + "isLabelSyncedWithName": [ + 6 + ], "isRemoteCreation": [ 6 ], @@ -10824,6 +10830,9 @@ export default { "settings": [ 15 ], + "objectMetadataId": [ + 3 + ], "isLabelSyncedWithName": [ 6 ], diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index b8526b98faa..875e5968dfa 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -266,6 +266,7 @@ export enum AllMetadataName { agent = 'agent', commandMenuItem = 'commandMenuItem', fieldMetadata = 'fieldMetadata', + fieldPermission = 'fieldPermission', frontComponent = 'frontComponent', index = 'index', logicFunction = 'logicFunction', @@ -1748,6 +1749,7 @@ export type Field = { morphRelations?: Maybe>; name: Scalars['String']; object?: Maybe; + objectMetadataId: Scalars['UUID']; options?: Maybe; relation?: Maybe; settings?: Maybe; @@ -1794,6 +1796,7 @@ export type FieldFilter = { isCustom?: InputMaybe; isSystem?: InputMaybe; isUIReadOnly?: InputMaybe; + objectMetadataId?: InputMaybe; or?: InputMaybe>; }; @@ -5245,6 +5248,7 @@ export type UpdateFieldInput = { label?: InputMaybe; morphRelationsUpdatePayload?: InputMaybe>; name?: InputMaybe; + objectMetadataId?: InputMaybe; options?: InputMaybe; settings?: InputMaybe; universalIdentifier?: InputMaybe; diff --git a/packages/twenty-front/src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts b/packages/twenty-front/src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts index e468612f719..8cb091ce8ab 100644 --- a/packages/twenty-front/src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts +++ b/packages/twenty-front/src/modules/metadata-error-handler/hooks/useMetadataErrorHandler.ts @@ -32,6 +32,7 @@ export const useMetadataErrorHandler = () => { logicFunction: t`logic function`, permissionFlag: t`permission flag`, objectPermission: t`object permission`, + fieldPermission: t`field permission`, role: t`role`, roleTarget: t`role target`, agent: t`agent`, diff --git a/packages/twenty-front/src/modules/object-metadata/types/FieldMetadataItem.ts b/packages/twenty-front/src/modules/object-metadata/types/FieldMetadataItem.ts index 398e5c17e54..d0bc848d2ce 100644 --- a/packages/twenty-front/src/modules/object-metadata/types/FieldMetadataItem.ts +++ b/packages/twenty-front/src/modules/object-metadata/types/FieldMetadataItem.ts @@ -20,12 +20,14 @@ export type FieldMetadataItem = Omit< | '__typename' | 'applicationId' | 'defaultValue' + | 'objectMetadataId' | 'options' | 'relation' | 'morphRelations' > & { __typename?: string; applicationId?: string; + objectMetadataId?: string; defaultValue?: any; options?: FieldMetadataItemOption[] | null; relation?: FieldMetadataItemRelation | null; diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-20/1-20-identify-field-permission-metadata.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-20/1-20-identify-field-permission-metadata.command.ts new file mode 100644 index 00000000000..2c02554cd9c --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-20/1-20-identify-field-permission-metadata.command.ts @@ -0,0 +1,111 @@ +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; + +import { Command } from 'nest-commander'; +import { DataSource, IsNull, type Repository } from 'typeorm'; +import { v4 } from 'uuid'; + +import { isDefined } from 'twenty-shared/utils'; + +import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner'; +import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; +import { FieldPermissionEntity } from 'src/engine/metadata-modules/object-permission/field-permission/field-permission.entity'; +import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; + +@Command({ + name: 'upgrade:1-20:identify-field-permission-metadata', + description: + 'Identify field permission metadata (backfill universalIdentifier and applicationId)', +}) +export class IdentifyFieldPermissionMetadataCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner { + private hasRunOnce = false; + + constructor( + @InjectRepository(WorkspaceEntity) + protected readonly workspaceRepository: Repository, + protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager, + protected readonly dataSourceService: DataSourceService, + @InjectDataSource() + private readonly coreDataSource: DataSource, + ) { + super(workspaceRepository, twentyORMGlobalManager, dataSourceService); + } + + override async runOnWorkspace({ + options, + }: RunOnWorkspaceArgs): Promise { + if (this.hasRunOnce) { + this.logger.log( + 'Skipping has already been run once IdentifyFieldPermissionMetadataCommand', + ); + + return; + } + + if (options.dryRun) { + return; + } + + const queryRunner = this.coreDataSource.createQueryRunner(); + + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + const repository = queryRunner.manager.getRepository( + FieldPermissionEntity, + ); + const withNullApplicationId = await repository.find({ + where: { applicationId: IsNull() }, + relations: ['role'], + }); + + const toUpdate = withNullApplicationId.filter((fieldPermission) => + isDefined(fieldPermission.role?.applicationId), + ); + const toRemove = withNullApplicationId.filter( + (fieldPermission) => !isDefined(fieldPermission.role?.applicationId), + ); + + for (const fieldPermission of toUpdate) { + fieldPermission.applicationId = fieldPermission.role!.applicationId; + fieldPermission.universalIdentifier = + fieldPermission.universalIdentifier ?? v4(); + } + + if (toUpdate.length > 0) { + await repository.save(toUpdate); + } + if (toRemove.length > 0) { + await repository.remove(toRemove); + } + + const withNullUniversalIdentifier = await repository.find({ + where: { universalIdentifier: IsNull() }, + }); + + for (const fieldPermission of withNullUniversalIdentifier) { + fieldPermission.universalIdentifier = v4(); + } + + if (withNullUniversalIdentifier.length > 0) { + await repository.save(withNullUniversalIdentifier); + } + + await queryRunner.commitTransaction(); + this.logger.log( + 'Successfully run IdentifyFieldPermissionMetadataCommand', + ); + this.hasRunOnce = true; + } catch (error) { + await queryRunner.rollbackTransaction(); + this.logger.error( + `Rolling back IdentifyFieldPermissionMetadataCommand: ${error.message}`, + ); + throw error; + } finally { + await queryRunner.release(); + } + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-20/1-20-make-field-permission-universal-identifier-and-application-id-not-nullable-migration.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-20/1-20-make-field-permission-universal-identifier-and-application-id-not-nullable-migration.command.ts new file mode 100644 index 00000000000..9fe57b87102 --- /dev/null +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-20/1-20-make-field-permission-universal-identifier-and-application-id-not-nullable-migration.command.ts @@ -0,0 +1,72 @@ +import { InjectDataSource, InjectRepository } from '@nestjs/typeorm'; + +import { Command } from 'nest-commander'; +import { DataSource, type Repository } from 'typeorm'; + +import { ActiveOrSuspendedWorkspacesMigrationCommandRunner } from 'src/database/commands/command-runners/active-or-suspended-workspaces-migration.command-runner'; +import { RunOnWorkspaceArgs } from 'src/database/commands/command-runners/workspaces-migration.command-runner'; +import { makeFieldPermissionUniversalIdentifierAndApplicationIdNotNullQueries } from 'src/database/typeorm/core/migrations/utils/1773400000000-make-field-permission-universal-identifier-and-application-id-not-null.util'; +import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; +import { DataSourceService } from 'src/engine/metadata-modules/data-source/data-source.service'; +import { GlobalWorkspaceOrmManager } from 'src/engine/twenty-orm/global-workspace-datasource/global-workspace-orm.manager'; + +@Command({ + name: 'upgrade:1-20:make-field-permission-universal-identifier-and-application-id-not-nullable-migration', + description: + 'Set NOT NULL on fieldPermission universalIdentifier and applicationId, add unique index and FK (run identify-field-permission-metadata first)', +}) +export class MakeFieldPermissionUniversalIdentifierAndApplicationIdNotNullableMigrationCommand extends ActiveOrSuspendedWorkspacesMigrationCommandRunner { + private hasRunOnce = false; + + constructor( + @InjectRepository(WorkspaceEntity) + protected readonly workspaceRepository: Repository, + protected readonly twentyORMGlobalManager: GlobalWorkspaceOrmManager, + protected readonly dataSourceService: DataSourceService, + @InjectDataSource() + private readonly coreDataSource: DataSource, + ) { + super(workspaceRepository, twentyORMGlobalManager, dataSourceService); + } + + override async runOnWorkspace({ + options, + }: RunOnWorkspaceArgs): Promise { + if (this.hasRunOnce) { + this.logger.log( + 'Skipping has already been run once MakeFieldPermissionUniversalIdentifierAndApplicationIdNotNullableMigrationCommand', + ); + + return; + } + + if (options.dryRun) { + return; + } + + const queryRunner = this.coreDataSource.createQueryRunner(); + + await queryRunner.connect(); + await queryRunner.startTransaction(); + + try { + await makeFieldPermissionUniversalIdentifierAndApplicationIdNotNullQueries( + queryRunner, + ); + + await queryRunner.commitTransaction(); + this.logger.log( + 'Successfully run MakeFieldPermissionUniversalIdentifierAndApplicationIdNotNullableMigrationCommand', + ); + this.hasRunOnce = true; + } catch (error) { + await queryRunner.rollbackTransaction(); + this.logger.error( + `Rolling back MakeFieldPermissionUniversalIdentifierAndApplicationIdNotNullableMigrationCommand: ${error.message}`, + ); + throw error; + } finally { + await queryRunner.release(); + } + } +} diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/1-20/1-20-upgrade-version-command.module.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/1-20/1-20-upgrade-version-command.module.ts index c1d249e6a16..f85cb820b48 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/1-20/1-20-upgrade-version-command.module.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/1-20/1-20-upgrade-version-command.module.ts @@ -6,8 +6,10 @@ import { BackfillNavigationMenuItemTypeCommand } from 'src/database/commands/upg import { BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-page-layouts-and-fields-widget-view-fields.command'; import { BackfillSelectFieldOptionIdsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-select-field-option-ids.command'; import { DeleteOrphanNavigationMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-delete-orphan-navigation-menu-items.command'; +import { IdentifyFieldPermissionMetadataCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-identify-field-permission-metadata.command'; import { IdentifyObjectPermissionMetadataCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-identify-object-permission-metadata.command'; import { IdentifyPermissionFlagMetadataCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-identify-permission-flag-metadata.command'; +import { MakeFieldPermissionUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-make-field-permission-universal-identifier-and-application-id-not-nullable-migration.command'; import { MakeObjectPermissionUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-make-object-permission-universal-identifier-and-application-id-not-nullable-migration.command'; import { MakePermissionFlagUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-make-permission-flag-universal-identifier-and-application-id-not-nullable-migration.command'; import { MakeWorkflowSearchableCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-make-workflow-searchable.command'; @@ -60,6 +62,8 @@ import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-commo MakePermissionFlagUniversalIdentifierAndApplicationIdNotNullableMigrationCommand, IdentifyObjectPermissionMetadataCommand, MakeObjectPermissionUniversalIdentifierAndApplicationIdNotNullableMigrationCommand, + IdentifyFieldPermissionMetadataCommand, + MakeFieldPermissionUniversalIdentifierAndApplicationIdNotNullableMigrationCommand, BackfillCommandMenuItemsCommand, BackfillNavigationMenuItemTypeCommand, BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand, @@ -76,6 +80,8 @@ import { WorkflowCommonModule } from 'src/modules/workflow/common/workflow-commo MakePermissionFlagUniversalIdentifierAndApplicationIdNotNullableMigrationCommand, IdentifyObjectPermissionMetadataCommand, MakeObjectPermissionUniversalIdentifierAndApplicationIdNotNullableMigrationCommand, + IdentifyFieldPermissionMetadataCommand, + MakeFieldPermissionUniversalIdentifierAndApplicationIdNotNullableMigrationCommand, BackfillCommandMenuItemsCommand, BackfillNavigationMenuItemTypeCommand, BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand, diff --git a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts index e63e62031bc..f8076cb6daf 100644 --- a/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts +++ b/packages/twenty-server/src/database/commands/upgrade-version-command/upgrade.command.ts @@ -36,10 +36,12 @@ import { SeedServerIdCommand } from 'src/database/commands/upgrade-version-comma import { BackfillCommandMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-command-menu-items.command'; import { BackfillNavigationMenuItemTypeCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-navigation-menu-item-type.command'; import { BackfillPageLayoutsAndFieldsWidgetViewFieldsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-page-layouts-and-fields-widget-view-fields.command'; +import { IdentifyFieldPermissionMetadataCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-identify-field-permission-metadata.command'; import { BackfillSelectFieldOptionIdsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-backfill-select-field-option-ids.command'; import { DeleteOrphanNavigationMenuItemsCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-delete-orphan-navigation-menu-items.command'; import { IdentifyObjectPermissionMetadataCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-identify-object-permission-metadata.command'; import { IdentifyPermissionFlagMetadataCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-identify-permission-flag-metadata.command'; +import { MakeFieldPermissionUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-make-field-permission-universal-identifier-and-application-id-not-nullable-migration.command'; import { MakeObjectPermissionUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-make-object-permission-universal-identifier-and-application-id-not-nullable-migration.command'; import { MakePermissionFlagUniversalIdentifierAndApplicationIdNotNullableMigrationCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-make-permission-flag-universal-identifier-and-application-id-not-nullable-migration.command'; import { MakeWorkflowSearchableCommand } from 'src/database/commands/upgrade-version-command/1-20/1-20-make-workflow-searchable.command'; @@ -101,6 +103,8 @@ export class UpgradeCommand extends UpgradeCommandRunner { protected readonly makePermissionFlagUniversalIdentifierAndApplicationIdNotNullableMigrationCommand: MakePermissionFlagUniversalIdentifierAndApplicationIdNotNullableMigrationCommand, protected readonly identifyObjectPermissionMetadataCommand: IdentifyObjectPermissionMetadataCommand, protected readonly makeObjectPermissionUniversalIdentifierAndApplicationIdNotNullableMigrationCommand: MakeObjectPermissionUniversalIdentifierAndApplicationIdNotNullableMigrationCommand, + protected readonly identifyFieldPermissionMetadataCommand: IdentifyFieldPermissionMetadataCommand, + protected readonly makeFieldPermissionUniversalIdentifierAndApplicationIdNotNullableMigrationCommand: MakeFieldPermissionUniversalIdentifierAndApplicationIdNotNullableMigrationCommand, protected readonly backfillNavigationMenuItemTypeCommand: BackfillNavigationMenuItemTypeCommand, protected readonly backfillCommandMenuItemsCommand: BackfillCommandMenuItemsCommand, protected readonly deleteOrphanNavigationMenuItemsCommand: DeleteOrphanNavigationMenuItemsCommand, @@ -164,6 +168,9 @@ export class UpgradeCommand extends UpgradeCommandRunner { this.identifyObjectPermissionMetadataCommand, this .makeObjectPermissionUniversalIdentifierAndApplicationIdNotNullableMigrationCommand, + this.identifyFieldPermissionMetadataCommand, + this + .makeFieldPermissionUniversalIdentifierAndApplicationIdNotNullableMigrationCommand, this.backfillNavigationMenuItemTypeCommand, this.migrateRichTextToTextCommand, this.deleteOrphanNavigationMenuItemsCommand, diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1773400000000-add-universal-identifier-and-application-id-to-field-permission.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1773400000000-add-universal-identifier-and-application-id-to-field-permission.ts new file mode 100644 index 00000000000..57c7b018eb6 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1773400000000-add-universal-identifier-and-application-id-to-field-permission.ts @@ -0,0 +1,25 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +export class AddUniversalIdentifierAndApplicationIdToFieldPermission1773400000000 + implements MigrationInterface +{ + name = 'AddUniversalIdentifierAndApplicationIdToFieldPermission1773400000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."fieldPermission" ADD "universalIdentifier" uuid`, + ); + await queryRunner.query( + `ALTER TABLE "core"."fieldPermission" ADD "applicationId" uuid`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."fieldPermission" DROP COLUMN IF EXISTS "applicationId"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."fieldPermission" DROP COLUMN IF EXISTS "universalIdentifier"`, + ); + } +} diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1773400000001-make-field-permission-universal-identifier-and-application-id-not-null.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1773400000001-make-field-permission-universal-identifier-and-application-id-not-null.ts new file mode 100644 index 00000000000..5a44d719ab2 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1773400000001-make-field-permission-universal-identifier-and-application-id-not-null.ts @@ -0,0 +1,58 @@ +import { type MigrationInterface, type QueryRunner } from 'typeorm'; + +import { makeFieldPermissionUniversalIdentifierAndApplicationIdNotNullQueries } from 'src/database/typeorm/core/migrations/utils/1773400000000-make-field-permission-universal-identifier-and-application-id-not-null.util'; + +export class MakeFieldPermissionUniversalIdentifierAndApplicationIdNotNull1773400000001 + implements MigrationInterface +{ + name = + 'MakeFieldPermissionUniversalIdentifierAndApplicationIdNotNull1773400000001'; + + public async up(queryRunner: QueryRunner): Promise { + const savepointName = + 'sp_make_field_permission_universal_identifier_and_application_id_not_null'; + + try { + await queryRunner.query(`SAVEPOINT ${savepointName}`); + + await makeFieldPermissionUniversalIdentifierAndApplicationIdNotNullQueries( + queryRunner, + ); + + await queryRunner.query(`RELEASE SAVEPOINT ${savepointName}`); + } catch (e) { + try { + await queryRunner.query(`ROLLBACK TO SAVEPOINT ${savepointName}`); + await queryRunner.query(`RELEASE SAVEPOINT ${savepointName}`); + } catch (rollbackError) { + // oxlint-disable-next-line no-console + console.error( + 'Failed to rollback to savepoint in MakeFieldPermissionUniversalIdentifierAndApplicationIdNotNull1773400000001', + rollbackError, + ); + throw rollbackError; + } + + // oxlint-disable-next-line no-console + console.error( + 'Swallowing MakeFieldPermissionUniversalIdentifierAndApplicationIdNotNull1773400000001 error', + e, + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."fieldPermission" DROP CONSTRAINT IF EXISTS "FK_71cc60c4a1c9f8a7c434d91d38c"`, + ); + await queryRunner.query( + `DROP INDEX IF EXISTS "core"."IDX_0dedb90c717e179ef653c512b9"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."fieldPermission" ALTER COLUMN "applicationId" DROP NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "core"."fieldPermission" ALTER COLUMN "universalIdentifier" DROP NOT NULL`, + ); + } +} diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/utils/1773400000000-make-field-permission-universal-identifier-and-application-id-not-null.util.ts b/packages/twenty-server/src/database/typeorm/core/migrations/utils/1773400000000-make-field-permission-universal-identifier-and-application-id-not-null.util.ts new file mode 100644 index 00000000000..8065f048fff --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/utils/1773400000000-make-field-permission-universal-identifier-and-application-id-not-null.util.ts @@ -0,0 +1,20 @@ +import { type QueryRunner } from 'typeorm'; + +export const makeFieldPermissionUniversalIdentifierAndApplicationIdNotNullQueries = + async (queryRunner: QueryRunner): Promise => { + await queryRunner.query( + `ALTER TABLE "core"."fieldPermission" ALTER COLUMN "universalIdentifier" SET NOT NULL`, + ); + await queryRunner.query( + `ALTER TABLE "core"."fieldPermission" ALTER COLUMN "applicationId" SET NOT NULL`, + ); + await queryRunner.query( + `DROP INDEX IF EXISTS "core"."IDX_0dedb90c717e179ef653c512b9"`, + ); + await queryRunner.query( + `CREATE UNIQUE INDEX "IDX_0dedb90c717e179ef653c512b9" ON "core"."fieldPermission" ("workspaceId", "universalIdentifier")`, + ); + await queryRunner.query( + `ALTER TABLE "core"."fieldPermission" ADD CONSTRAINT "FK_71cc60c4a1c9f8a7c434d91d38c" FOREIGN KEY ("applicationId") REFERENCES "core"."application"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + }; diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/__mocks__/mockPersonObjectMetadata.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/__mocks__/mockPersonObjectMetadata.ts index 348b17d3f46..c4b42936e70 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/__mocks__/mockPersonObjectMetadata.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/__mocks__/mockPersonObjectMetadata.ts @@ -154,6 +154,7 @@ export const mockPersonFlatObjectMetadata = ( universalIdentifier: objectMetadataId, indexMetadataIds: [], objectPermissionIds: [], + fieldPermissionIds: [], fieldIds: mockFieldMetadatas.map((field) => field.id), viewIds: [], applicationId: 'test-application-id', @@ -170,6 +171,7 @@ export const mockPersonFlatObjectMetadata = ( ), viewUniversalIdentifiers: [], objectPermissionUniversalIdentifiers: [], + fieldPermissionUniversalIdentifiers: [], indexMetadataUniversalIdentifiers: [], labelIdentifierFieldMetadataUniversalIdentifier: null, imageIdentifierFieldMetadataUniversalIdentifier: null, diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-field-manifest-to-universal-flat-field-metadata.util.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-field-manifest-to-universal-flat-field-metadata.util.ts index 89a73330012..ceb1e7b69ac 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-field-manifest-to-universal-flat-field-metadata.util.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-field-manifest-to-universal-flat-field-metadata.util.ts @@ -94,6 +94,7 @@ export const fromFieldManifestToUniversalFlatFieldMetadata = ({ relationTargetObjectMetadataUniversalIdentifier, viewFieldUniversalIdentifiers: [], viewFilterUniversalIdentifiers: [], + fieldPermissionUniversalIdentifiers: [], kanbanAggregateOperationViewUniversalIdentifiers: [], calendarViewUniversalIdentifiers: [], mainGroupByFieldMetadataViewUniversalIdentifiers: [], diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-object-manifest-to-universal-flat-object-metadata.util.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-object-manifest-to-universal-flat-object-metadata.util.ts index e079181b91d..0ba3264f23f 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-object-manifest-to-universal-flat-object-metadata.util.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-object-manifest-to-universal-flat-object-metadata.util.ts @@ -36,6 +36,7 @@ export const fromObjectManifestToUniversalFlatObjectMetadata = ({ fieldUniversalIdentifiers: [], indexMetadataUniversalIdentifiers: [], objectPermissionUniversalIdentifiers: [], + fieldPermissionUniversalIdentifiers: [], viewUniversalIdentifiers: [], labelIdentifierFieldMetadataUniversalIdentifier: objectManifest.labelIdentifierFieldMetadataUniversalIdentifier, diff --git a/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-role-manifest-to-universal-flat-role.util.ts b/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-role-manifest-to-universal-flat-role.util.ts index 9418a1af329..acffe6b1461 100644 --- a/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-role-manifest-to-universal-flat-role.util.ts +++ b/packages/twenty-server/src/engine/core-modules/application/application-manifest/converters/from-role-manifest-to-universal-flat-role.util.ts @@ -31,6 +31,7 @@ export const fromRoleManifestToUniversalFlatRole = ({ canBeAssignedToApiKeys: roleManifest.canBeAssignedToApiKeys ?? true, roleTargetUniversalIdentifiers: [], objectPermissionUniversalIdentifiers: [], + fieldPermissionUniversalIdentifiers: [], permissionFlagUniversalIdentifiers: [], rowLevelPermissionPredicateUniversalIdentifiers: [], rowLevelPermissionPredicateGroupUniversalIdentifiers: [], diff --git a/packages/twenty-server/src/engine/core-modules/event-emitter/utils/__tests__/object-record-changed-values.spec.ts b/packages/twenty-server/src/engine/core-modules/event-emitter/utils/__tests__/object-record-changed-values.spec.ts index e85b40b9ba9..15223a122a7 100644 --- a/packages/twenty-server/src/engine/core-modules/event-emitter/utils/__tests__/object-record-changed-values.spec.ts +++ b/packages/twenty-server/src/engine/core-modules/event-emitter/utils/__tests__/object-record-changed-values.spec.ts @@ -24,6 +24,7 @@ const mockObjectMetadata: FlatObjectMetadata = { isSearchable: true, indexMetadataIds: [], objectPermissionIds: [], + fieldPermissionIds: [], fieldIds: [], viewIds: [], applicationId: 'test-application-id', @@ -40,6 +41,7 @@ const mockObjectMetadata: FlatObjectMetadata = { applicationUniversalIdentifier: 'test-application-id', fieldUniversalIdentifiers: [], objectPermissionUniversalIdentifiers: [], + fieldPermissionUniversalIdentifiers: [], viewUniversalIdentifiers: [], indexMetadataUniversalIdentifiers: [], labelIdentifierFieldMetadataUniversalIdentifier: null, diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto.ts index b2c778b4ec5..3538e738080 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/dtos/field-metadata.dto.ts @@ -146,6 +146,7 @@ export class FieldMetadataDTO { @HideField() workspaceId: string; + @FilterableField(() => UUIDScalarType) objectMetadataId: string; @IsBoolean() diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/__tests__/__snapshots__/all-universal-flat-entity-properties-to-compare-and-stringify.constant.spec.ts.snap b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/__tests__/__snapshots__/all-universal-flat-entity-properties-to-compare-and-stringify.constant.spec.ts.snap index cb91d8d9799..44cb4c0eb5d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/__tests__/__snapshots__/all-universal-flat-entity-properties-to-compare-and-stringify.constant.spec.ts.snap +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/__tests__/__snapshots__/all-universal-flat-entity-properties-to-compare-and-stringify.constant.spec.ts.snap @@ -58,6 +58,16 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_PROPERTIES_TO_COMPARE_AND_STRINGIFY should ma "universalSettings", ], }, + "fieldPermission": { + "propertiesToCompare": [ + "roleUniversalIdentifier", + "objectMetadataUniversalIdentifier", + "fieldMetadataUniversalIdentifier", + "canReadFieldValue", + "canUpdateFieldValue", + ], + "propertiesToStringify": [], + }, "frontComponent": { "propertiesToCompare": [ "name", diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant.ts index 494014d56e6..727c71bb6d2 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant.ts @@ -1156,6 +1156,43 @@ export const ALL_ENTITY_PROPERTIES_CONFIGURATION_BY_METADATA_NAME = { universalProperty: undefined, }, }, + fieldPermission: { + roleId: { + toCompare: true, + toStringify: false, + universalProperty: 'roleUniversalIdentifier', + }, + objectMetadataId: { + toCompare: true, + toStringify: false, + universalProperty: 'objectMetadataUniversalIdentifier', + }, + fieldMetadataId: { + toCompare: true, + toStringify: false, + universalProperty: 'fieldMetadataUniversalIdentifier', + }, + canReadFieldValue: { + toCompare: true, + toStringify: false, + universalProperty: undefined, + }, + canUpdateFieldValue: { + toCompare: true, + toStringify: false, + universalProperty: undefined, + }, + createdAt: { + toCompare: false, + toStringify: false, + universalProperty: undefined, + }, + updatedAt: { + toCompare: false, + toStringify: false, + universalProperty: undefined, + }, + }, objectPermission: { roleId: { toCompare: true, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-foreign-key.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-foreign-key.constant.ts index 6108ffd66ad..28555ab842f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-foreign-key.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-foreign-key.constant.ts @@ -180,6 +180,19 @@ export const ALL_MANY_TO_ONE_METADATA_FOREIGN_KEY = { foreignKey: 'objectMetadataId', }, }, + fieldPermission: { + workspace: null, + application: null, + role: { + foreignKey: 'roleId', + }, + objectMetadata: { + foreignKey: 'objectMetadataId', + }, + fieldMetadata: { + foreignKey: 'fieldMetadataId', + }, + }, pageLayout: { workspace: null, objectMetadata: { diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-relations.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-relations.constant.ts index 2d34abc7ee1..c267a86fcc5 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-relations.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-many-to-one-metadata-relations.constant.ts @@ -309,6 +309,31 @@ export const ALL_MANY_TO_ONE_METADATA_RELATIONS = { universalForeignKey: 'objectMetadataUniversalIdentifier', }, }, + fieldPermission: { + workspace: null, + application: null, + role: { + metadataName: 'role', + foreignKey: 'roleId', + inverseOneToManyProperty: 'fieldPermissions', + isNullable: false, + universalForeignKey: 'roleUniversalIdentifier', + }, + objectMetadata: { + metadataName: 'objectMetadata', + foreignKey: 'objectMetadataId', + inverseOneToManyProperty: 'fieldPermissions', + isNullable: false, + universalForeignKey: 'objectMetadataUniversalIdentifier', + }, + fieldMetadata: { + metadataName: 'fieldMetadata', + foreignKey: 'fieldMetadataId', + inverseOneToManyProperty: 'fieldPermissions', + isNullable: false, + universalForeignKey: 'fieldMetadataUniversalIdentifier', + }, + }, pageLayout: { workspace: null, objectMetadata: { diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-entity-by-metadata-name.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-entity-by-metadata-name.constant.ts index 8267f99eaba..ec0a5c9d40a 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-entity-by-metadata-name.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-entity-by-metadata-name.constant.ts @@ -9,6 +9,7 @@ import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/ import { WebhookEntity } from 'src/engine/metadata-modules/webhook/entities/webhook.entity'; import { NavigationMenuItemEntity } from 'src/engine/metadata-modules/navigation-menu-item/entities/navigation-menu-item.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { FieldPermissionEntity } from 'src/engine/metadata-modules/object-permission/field-permission/field-permission.entity'; import { ObjectPermissionEntity } from 'src/engine/metadata-modules/object-permission/object-permission.entity'; import { PageLayoutTabEntity } from 'src/engine/metadata-modules/page-layout-tab/entities/page-layout-tab.entity'; import { PageLayoutWidgetEntity } from 'src/engine/metadata-modules/page-layout-widget/entities/page-layout-widget.entity'; @@ -48,6 +49,7 @@ export const ALL_METADATA_ENTITY_BY_METADATA_NAME = { logicFunction: LogicFunctionEntity, objectMetadata: ObjectMetadataEntity, objectPermission: ObjectPermissionEntity, + fieldPermission: FieldPermissionEntity, role: RoleEntity, agent: AgentEntity, commandMenuItem: CommandMenuItemEntity, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-required-metadata-for-validation.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-required-metadata-for-validation.constant.ts index cd7457d620c..50add7d2793 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-required-metadata-for-validation.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-required-metadata-for-validation.constant.ts @@ -81,6 +81,11 @@ export const ALL_METADATA_REQUIRED_METADATA_FOR_VALIDATION = { role: true, objectMetadata: true, }, + fieldPermission: { + role: true, + objectMetadata: true, + fieldMetadata: true, + }, pageLayout: { objectMetadata: true, pageLayoutTab: true, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-serialized-relation.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-serialized-relation.constant.ts index 3e6a5cd090f..31006fd298b 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-serialized-relation.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-metadata-serialized-relation.constant.ts @@ -35,6 +35,7 @@ export const ALL_METADATA_SERIALIZED_RELATION = { roleTarget: {}, permissionFlag: {}, objectPermission: {}, + fieldPermission: {}, pageLayout: {}, pageLayoutTab: {}, pageLayoutWidget: { diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-one-to-many-metadata-relations.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-one-to-many-metadata-relations.constant.ts index 661f181a8aa..4b5b3160b2e 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-one-to-many-metadata-relations.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/constant/all-one-to-many-metadata-relations.constant.ts @@ -42,7 +42,12 @@ export const ALL_ONE_TO_MANY_METADATA_RELATIONS = { metadataName: 'viewSort', universalFlatEntityForeignKeyAggregator: 'viewSortUniversalIdentifiers', }, - fieldPermissions: null, + fieldPermissions: { + metadataName: 'fieldPermission', + flatEntityForeignKeyAggregator: 'fieldPermissionIds', + universalFlatEntityForeignKeyAggregator: + 'fieldPermissionUniversalIdentifiers', + }, indexFieldMetadatas: null, viewFields: { metadataName: 'viewField', @@ -91,7 +96,12 @@ export const ALL_ONE_TO_MANY_METADATA_RELATIONS = { universalFlatEntityForeignKeyAggregator: 'objectPermissionUniversalIdentifiers', }, - fieldPermissions: null, + fieldPermissions: { + metadataName: 'fieldPermission', + flatEntityForeignKeyAggregator: 'fieldPermissionIds', + universalFlatEntityForeignKeyAggregator: + 'fieldPermissionUniversalIdentifiers', + }, views: { metadataName: 'view', flatEntityForeignKeyAggregator: 'viewIds', @@ -161,7 +171,12 @@ export const ALL_ONE_TO_MANY_METADATA_RELATIONS = { universalFlatEntityForeignKeyAggregator: 'permissionFlagUniversalIdentifiers', }, - fieldPermissions: null, + fieldPermissions: { + metadataName: 'fieldPermission', + flatEntityForeignKeyAggregator: 'fieldPermissionIds', + universalFlatEntityForeignKeyAggregator: + 'fieldPermissionUniversalIdentifiers', + }, rowLevelPermissionPredicates: { metadataName: 'rowLevelPermissionPredicate', flatEntityForeignKeyAggregator: 'rowLevelPermissionPredicateIds', @@ -178,6 +193,7 @@ export const ALL_ONE_TO_MANY_METADATA_RELATIONS = { roleTarget: {}, permissionFlag: {}, objectPermission: {}, + fieldPermission: {}, pageLayout: { tabs: { metadataName: 'pageLayoutTab', diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module.ts index b78a0278a76..022f427a3cc 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.module.ts @@ -5,6 +5,7 @@ import { ApplicationEntity } from 'src/engine/core-modules/application/applicati import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { WorkspaceFlatFieldMetadataMapCacheService } from 'src/engine/metadata-modules/flat-field-metadata/services/workspace-flat-field-metadata-map-cache.service'; +import { WorkspaceFlatFieldPermissionMapCacheService } from 'src/engine/metadata-modules/flat-field-permission/services/workspace-flat-field-permission-map-cache.service'; import { WorkspaceFlatIndexMapCacheService } from 'src/engine/metadata-modules/flat-index-metadata/services/workspace-flat-index-map-cache.service'; import { WorkspaceFlatObjectMetadataMapCacheService } from 'src/engine/metadata-modules/flat-object-metadata/services/workspace-flat-object-metadata-map-cache.service'; import { WorkspaceFlatObjectPermissionMapCacheService } from 'src/engine/metadata-modules/flat-object-permission/services/workspace-flat-object-permission-map-cache.service'; @@ -25,6 +26,7 @@ import { FrontComponentEntity } from 'src/engine/metadata-modules/front-componen import { IndexFieldMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-field-metadata.entity'; import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { FieldPermissionEntity } from 'src/engine/metadata-modules/object-permission/field-permission/field-permission.entity'; import { ObjectPermissionEntity } from 'src/engine/metadata-modules/object-permission/object-permission.entity'; import { PageLayoutTabEntity } from 'src/engine/metadata-modules/page-layout-tab/entities/page-layout-tab.entity'; import { PageLayoutWidgetEntity } from 'src/engine/metadata-modules/page-layout-widget/entities/page-layout-widget.entity'; @@ -58,6 +60,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache FieldMetadataEntity, ObjectMetadataEntity, ObjectPermissionEntity, + FieldPermissionEntity, PageLayoutEntity, PageLayoutTabEntity, PageLayoutWidgetEntity, @@ -81,6 +84,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache WorkspaceFlatFieldMetadataMapCacheService, WorkspaceFlatViewGroupMapCacheService, WorkspaceFlatObjectPermissionMapCacheService, + WorkspaceFlatFieldPermissionMapCacheService, WorkspaceFlatPermissionFlagMapCacheService, WorkspaceFlatViewSortMapCacheService, WorkspaceFlatPageLayoutMapCacheService, @@ -101,6 +105,7 @@ import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache WorkspaceFlatFieldMetadataMapCacheService, WorkspaceFlatViewGroupMapCacheService, WorkspaceFlatObjectPermissionMapCacheService, + WorkspaceFlatFieldPermissionMapCacheService, WorkspaceFlatPermissionFlagMapCacheService, WorkspaceFlatViewSortMapCacheService, WorkspaceFlatPageLayoutMapCacheService, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/types/__tests__/extract-entity-one-to-many-entity-relation-properties.type-test.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/types/__tests__/extract-entity-one-to-many-entity-relation-properties.type-test.ts index c87d1368a09..e868ef9fe57 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/types/__tests__/extract-entity-one-to-many-entity-relation-properties.type-test.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/types/__tests__/extract-entity-one-to-many-entity-relation-properties.type-test.ts @@ -36,6 +36,7 @@ type Assertions = [ Expect< Equal< FieldMetadataOneToManySyncableRelations, + | 'fieldPermissions' | 'viewFields' | 'viewFilters' | 'kanbanAggregateOperationViews' diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/types/all-flat-entity-types-by-metadata-name.ts b/packages/twenty-server/src/engine/metadata-modules/flat-entity/types/all-flat-entity-types-by-metadata-name.ts index e2e9e73bbd4..d3deb34eeb4 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/types/all-flat-entity-types-by-metadata-name.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/types/all-flat-entity-types-by-metadata-name.ts @@ -6,6 +6,8 @@ import { type FlatCommandMenuItem } from 'src/engine/metadata-modules/flat-comma import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; import { type MetadataEntity } from 'src/engine/metadata-modules/flat-entity/types/metadata-entity.type'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { type FlatFieldPermissionMaps } from 'src/engine/metadata-modules/flat-field-permission/types/flat-field-permission-maps.type'; +import { type FlatFieldPermission } from 'src/engine/metadata-modules/flat-field-permission/types/flat-field-permission.type'; import { type FlatFrontComponentMaps } from 'src/engine/metadata-modules/flat-front-component/types/flat-front-component-maps.type'; import { type FlatFrontComponent } from 'src/engine/metadata-modules/flat-front-component/types/flat-front-component.type'; import { type FlatIndexMetadata } from 'src/engine/metadata-modules/flat-index-metadata/types/flat-index-metadata.type'; @@ -52,6 +54,7 @@ import { type FlatRowLevelPermissionPredicate } from 'src/engine/metadata-module import { type UniversalFlatAgent } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-agent.type'; import { type UniversalFlatCommandMenuItem } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-command-menu-item.type'; import { type UniversalFlatEntityFrom } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-entity-from.type'; +import { type UniversalFlatFieldPermission } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-permission.type'; import { type UniversalFlatFrontComponent } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-front-component.type'; import { type UniversalFlatIndexMetadata } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-index-metadata.type'; import { type UniversalFlatLogicFunction } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-logic-function.type'; @@ -91,6 +94,14 @@ import { type UniversalDeleteCommandMenuItemAction, type UniversalUpdateCommandMenuItemAction, } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/command-menu-item/types/workspace-migration-command-menu-item-action.type'; +import { + type FlatCreateFieldPermissionAction, + type FlatDeleteFieldPermissionAction, + type FlatUpdateFieldPermissionAction, + type UniversalCreateFieldPermissionAction, + type UniversalDeleteFieldPermissionAction, + type UniversalUpdateFieldPermissionAction, +} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field-permission/types/workspace-migration-field-permission-action.type'; import { type FlatCreateFieldAction, type FlatDeleteFieldAction, @@ -624,6 +635,22 @@ export type AllFlatEntityTypesByMetadataName = { universalFlatEntity: UniversalFlatObjectPermission; entity: MetadataEntity<'objectPermission'>; }; + fieldPermission: { + flatEntityMaps: FlatFieldPermissionMaps; + universalActions: { + create: UniversalCreateFieldPermissionAction; + update: UniversalUpdateFieldPermissionAction; + delete: UniversalDeleteFieldPermissionAction; + }; + flatActions: { + create: FlatCreateFieldPermissionAction; + update: FlatUpdateFieldPermissionAction; + delete: FlatDeleteFieldPermissionAction; + }; + flatEntity: FlatFieldPermission; + universalFlatEntity: UniversalFlatFieldPermission; + entity: MetadataEntity<'fieldPermission'>; + }; pageLayout: { flatEntityMaps: FlatPageLayoutMaps; universalActions: { diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/get-metadata-related-metadata-names.util.spec.ts.snap b/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/get-metadata-related-metadata-names.util.spec.ts.snap index 201ecc9e84e..ae6a57a325e 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/get-metadata-related-metadata-names.util.spec.ts.snap +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/get-metadata-related-metadata-names.util.spec.ts.snap @@ -14,12 +14,21 @@ exports[`getMetadataRelatedMetadataNames should return related metadata names fo "objectMetadata", "fieldMetadata", "viewSort", + "fieldPermission", "viewField", "viewFilter", "view", ] `; +exports[`getMetadataRelatedMetadataNames should return related metadata names for fieldPermission 1`] = ` +[ + "role", + "objectMetadata", + "fieldMetadata", +] +`; + exports[`getMetadataRelatedMetadataNames should return related metadata names for frontComponent 1`] = `[]`; exports[`getMetadataRelatedMetadataNames should return related metadata names for index 1`] = ` @@ -43,6 +52,7 @@ exports[`getMetadataRelatedMetadataNames should return related metadata names fo "fieldMetadata", "index", "objectPermission", + "fieldPermission", "view", ] `; @@ -86,6 +96,7 @@ exports[`getMetadataRelatedMetadataNames should return related metadata names fo "roleTarget", "objectPermission", "permissionFlag", + "fieldPermission", "rowLevelPermissionPredicate", "rowLevelPermissionPredicateGroup", ] diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/sort-metadata-names-children-first.util.spec.ts.snap b/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/sort-metadata-names-children-first.util.spec.ts.snap index 800b037d471..437509a5a97 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/sort-metadata-names-children-first.util.spec.ts.snap +++ b/packages/twenty-server/src/engine/metadata-modules/flat-entity/utils/__tests__/__snapshots__/sort-metadata-names-children-first.util.spec.ts.snap @@ -3,6 +3,7 @@ exports[`sortMetadataNamesChildrenFirst should return metadata names sorted with children first (most manyToOne relations first) 1`] = ` [ "rowLevelPermissionPredicate", + "fieldPermission", "navigationMenuItem", "viewField", "viewFilter", diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock.ts index 61b6d6551d0..06b29f2d631 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/__mocks__/get-flat-field-metadata.mock.ts @@ -20,6 +20,7 @@ export const getFlatFieldMetadataMock = ( viewFilterIds: [], kanbanAggregateOperationViewIds: [], viewFieldIds: [], + fieldPermissionIds: [], createdAt, mainGroupByFieldMetadataViewIds: [], updatedAt: createdAt, @@ -50,6 +51,7 @@ export const getFlatFieldMetadataMock = ( relationTargetFieldMetadataUniversalIdentifier: null, viewFilterUniversalIdentifiers: [], viewFieldUniversalIdentifiers: [], + fieldPermissionUniversalIdentifiers: [], kanbanAggregateOperationViewUniversalIdentifiers: [], calendarViewUniversalIdentifiers: [], mainGroupByFieldMetadataViewUniversalIdentifiers: [], diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/__mocks__/get-morph-or-relation-target-flat-field-metadata-mock.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/__mocks__/get-morph-or-relation-target-flat-field-metadata-mock.ts index 15aaa4dce76..323b23961f6 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/__mocks__/get-morph-or-relation-target-flat-field-metadata-mock.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/__mocks__/get-morph-or-relation-target-flat-field-metadata-mock.ts @@ -37,6 +37,8 @@ export const getRelationTargetFlatFieldMetadataMock = ({ viewFilterIds: [], viewFieldIds: [], kanbanAggregateOperationViewIds: [], + fieldPermissionIds: [], + fieldPermissionUniversalIdentifiers: [], createdAt, updatedAt: createdAt, description: 'default flat field metadata description', diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/__tests__/__snapshots__/morph-relation-from-create-field-input-to-flat-field-metadatas-to-create.spec.ts.snap b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/__tests__/__snapshots__/morph-relation-from-create-field-input-to-flat-field-metadatas-to-create.spec.ts.snap index 094fc9b52d2..31cce8573cb 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/__tests__/__snapshots__/morph-relation-from-create-field-input-to-flat-field-metadatas-to-create.spec.ts.snap +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/__tests__/__snapshots__/morph-relation-from-create-field-input-to-flat-field-metadatas-to-create.spec.ts.snap @@ -121,6 +121,7 @@ exports[`fromCreateFieldInputToFlatFieldMetadatasToCreate MORPH_RELATION test su "createdAt": Any, "defaultValue": null, "description": "new field description", + "fieldPermissionUniversalIdentifiers": [], "icon": "IconRelationOneToMany", "isActive": true, "isCustom": true, @@ -155,6 +156,7 @@ exports[`fromCreateFieldInputToFlatFieldMetadatasToCreate MORPH_RELATION test su "createdAt": Any, "defaultValue": null, "description": "new field description", + "fieldPermissionUniversalIdentifiers": [], "icon": "IconPet", "isActive": true, "isCustom": true, @@ -191,6 +193,7 @@ exports[`fromCreateFieldInputToFlatFieldMetadatasToCreate MORPH_RELATION test su "createdAt": Any, "defaultValue": null, "description": "new field description", + "fieldPermissionUniversalIdentifiers": [], "icon": "IconRelationOneToMany", "isActive": true, "isCustom": true, @@ -225,6 +228,7 @@ exports[`fromCreateFieldInputToFlatFieldMetadatasToCreate MORPH_RELATION test su "createdAt": Any, "defaultValue": null, "description": "new field description", + "fieldPermissionUniversalIdentifiers": [], "icon": "IconBuilding", "isActive": true, "isCustom": true, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-field-metadata-entity-to-flat-field-metadata.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-field-metadata-entity-to-flat-field-metadata.util.ts index 9887282fe65..46e9e1f99da 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-field-metadata-entity-to-flat-field-metadata.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/from-field-metadata-entity-to-flat-field-metadata.util.ts @@ -106,6 +106,8 @@ export const fromFieldMetadataEntityToFlatFieldMetadata = ({ [], viewFieldIds: fieldMetadataEntity.viewFields.map(({ id }) => id), viewFilterIds: fieldMetadataEntity.viewFilters.map(({ id }) => id), + fieldPermissionIds: + fieldMetadataEntity.fieldPermissions?.map(({ id }) => id) ?? [], applicationUniversalIdentifier, objectMetadataUniversalIdentifier, relationTargetObjectMetadataUniversalIdentifier, @@ -132,6 +134,10 @@ export const fromFieldMetadataEntityToFlatFieldMetadata = ({ fieldMetadataEntity.viewSorts?.map( ({ universalIdentifier }) => universalIdentifier, ) ?? [], + fieldPermissionUniversalIdentifiers: + fieldMetadataEntity.fieldPermissions?.map( + ({ universalIdentifier }) => universalIdentifier, + ) ?? [], universalSettings: settingsWithUniversalIdentifiers, }; }; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/get-default-flat-field-metadata-from-create-field-input.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/get-default-flat-field-metadata-from-create-field-input.util.ts index 778568f7936..87ffc452095 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/get-default-flat-field-metadata-from-create-field-input.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-metadata/utils/get-default-flat-field-metadata-from-create-field-input.util.ts @@ -53,6 +53,7 @@ export const getDefaultFlatFieldMetadata = ({ relationTargetFieldMetadataUniversalIdentifier: null, viewFilterUniversalIdentifiers: [], viewFieldUniversalIdentifiers: [], + fieldPermissionUniversalIdentifiers: [], kanbanAggregateOperationViewUniversalIdentifiers: [], calendarViewUniversalIdentifiers: [], mainGroupByFieldMetadataViewUniversalIdentifiers: [], diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-permission/constants/flat-field-permission-editable-properties.constant.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-permission/constants/flat-field-permission-editable-properties.constant.ts new file mode 100644 index 00000000000..4ea39c17d8f --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-permission/constants/flat-field-permission-editable-properties.constant.ts @@ -0,0 +1,9 @@ +import { type MetadataEntityPropertyName } from 'src/engine/metadata-modules/flat-entity/constant/all-entity-properties-configuration-by-metadata-name.constant'; + +export const FLAT_FIELD_PERMISSION_EDITABLE_PROPERTIES = [ + 'roleId', + 'objectMetadataId', + 'fieldMetadataId', + 'canReadFieldValue', + 'canUpdateFieldValue', +] as const satisfies MetadataEntityPropertyName<'fieldPermission'>[]; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-permission/services/workspace-flat-field-permission-map-cache.service.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-permission/services/workspace-flat-field-permission-map-cache.service.ts new file mode 100644 index 00000000000..710455a8184 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-permission/services/workspace-flat-field-permission-map-cache.service.ts @@ -0,0 +1,100 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { Repository } from 'typeorm'; + +import { ApplicationEntity } from 'src/engine/core-modules/application/application.entity'; +import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; +import { createEmptyFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/constant/create-empty-flat-entity-maps.constant'; +import { type FlatFieldPermissionMaps } from 'src/engine/metadata-modules/flat-field-permission/types/flat-field-permission-maps.type'; +import { fromFieldPermissionEntityToFlatFieldPermission } from 'src/engine/metadata-modules/flat-field-permission/utils/from-field-permission-entity-to-flat-field-permission.util'; +import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; +import { FieldPermissionEntity } from 'src/engine/metadata-modules/object-permission/field-permission/field-permission.entity'; +import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; +import { WorkspaceCache } from 'src/engine/workspace-cache/decorators/workspace-cache.decorator'; +import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service'; +import { createIdToUniversalIdentifierMap } from 'src/engine/workspace-cache/utils/create-id-to-universal-identifier-map.util'; +import { addFlatEntityToFlatEntityMapsThroughMutationOrThrow } from 'src/engine/workspace-manager/workspace-migration/utils/add-flat-entity-to-flat-entity-maps-through-mutation-or-throw.util'; + +@Injectable() +@WorkspaceCache('flatFieldPermissionMaps') +export class WorkspaceFlatFieldPermissionMapCacheService extends WorkspaceCacheProvider { + constructor( + @InjectRepository(FieldPermissionEntity) + private readonly fieldPermissionRepository: Repository, + @InjectRepository(ApplicationEntity) + private readonly applicationRepository: Repository, + @InjectRepository(RoleEntity) + private readonly roleRepository: Repository, + @InjectRepository(ObjectMetadataEntity) + private readonly objectMetadataRepository: Repository, + @InjectRepository(FieldMetadataEntity) + private readonly fieldMetadataRepository: Repository, + ) { + super(); + } + + async computeForCache(workspaceId: string): Promise { + const [ + fieldPermissions, + applications, + roles, + objectMetadatas, + fieldMetadatas, + ] = await Promise.all([ + this.fieldPermissionRepository.find({ + where: { workspaceId }, + withDeleted: true, + }), + this.applicationRepository.find({ + where: { workspaceId }, + select: ['id', 'universalIdentifier'], + withDeleted: true, + }), + this.roleRepository.find({ + where: { workspaceId }, + select: ['id', 'universalIdentifier'], + withDeleted: true, + }), + this.objectMetadataRepository.find({ + where: { workspaceId }, + select: ['id', 'universalIdentifier'], + withDeleted: true, + }), + this.fieldMetadataRepository.find({ + where: { workspaceId }, + select: ['id', 'universalIdentifier'], + withDeleted: true, + }), + ]); + + const applicationIdToUniversalIdentifierMap = + createIdToUniversalIdentifierMap(applications); + const roleIdToUniversalIdentifierMap = + createIdToUniversalIdentifierMap(roles); + const objectMetadataIdToUniversalIdentifierMap = + createIdToUniversalIdentifierMap(objectMetadatas); + const fieldMetadataIdToUniversalIdentifierMap = + createIdToUniversalIdentifierMap(fieldMetadatas); + + const flatFieldPermissionMaps = createEmptyFlatEntityMaps(); + + for (const fieldPermissionEntity of fieldPermissions) { + const flatFieldPermission = + fromFieldPermissionEntityToFlatFieldPermission({ + entity: fieldPermissionEntity, + applicationIdToUniversalIdentifierMap, + roleIdToUniversalIdentifierMap, + objectMetadataIdToUniversalIdentifierMap, + fieldMetadataIdToUniversalIdentifierMap, + }); + + addFlatEntityToFlatEntityMapsThroughMutationOrThrow({ + flatEntity: flatFieldPermission, + flatEntityMapsToMutate: flatFieldPermissionMaps, + }); + } + + return flatFieldPermissionMaps; + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-permission/types/flat-field-permission-maps.type.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-permission/types/flat-field-permission-maps.type.ts new file mode 100644 index 00000000000..02c57cc7057 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-permission/types/flat-field-permission-maps.type.ts @@ -0,0 +1,4 @@ +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { type FlatFieldPermission } from 'src/engine/metadata-modules/flat-field-permission/types/flat-field-permission.type'; + +export type FlatFieldPermissionMaps = FlatEntityMaps; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-permission/types/flat-field-permission.type.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-permission/types/flat-field-permission.type.ts new file mode 100644 index 00000000000..47ef87b0c5f --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-permission/types/flat-field-permission.type.ts @@ -0,0 +1,4 @@ +import { type FlatEntityFrom } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-from.type'; +import { type FieldPermissionEntity } from 'src/engine/metadata-modules/object-permission/field-permission/field-permission.entity'; + +export type FlatFieldPermission = FlatEntityFrom; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-permission/utils/from-create-field-permission-input-to-universal-flat-field-permission.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-permission/utils/from-create-field-permission-input-to-universal-flat-field-permission.util.ts new file mode 100644 index 00000000000..c7957d87e04 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-permission/utils/from-create-field-permission-input-to-universal-flat-field-permission.util.ts @@ -0,0 +1,56 @@ +import { v4 } from 'uuid'; + +import { type FlatApplication } from 'src/engine/core-modules/application/types/flat-application.type'; +import { type AllFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/all-flat-entity-maps.type'; +import { resolveEntityRelationUniversalIdentifiers } from 'src/engine/metadata-modules/flat-entity/utils/resolve-entity-relation-universal-identifiers.util'; +import { type FieldPermissionInput } from 'src/engine/metadata-modules/object-permission/dtos/upsert-field-permissions.input'; +import { type UniversalFlatFieldPermission } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-permission.type'; + +export const fromCreateFieldPermissionInputToUniversalFlatFieldPermission = ({ + fieldPermissionInput, + roleId, + flatApplication, + flatRoleMaps, + flatObjectMetadataMaps, + flatFieldMetadataMaps, +}: { + fieldPermissionInput: FieldPermissionInput; + roleId: string; + flatApplication: FlatApplication; +} & Pick< + AllFlatEntityMaps, + 'flatRoleMaps' | 'flatObjectMetadataMaps' | 'flatFieldMetadataMaps' +>): UniversalFlatFieldPermission & { id: string } => { + const now = new Date().toISOString(); + + const { + roleUniversalIdentifier, + objectMetadataUniversalIdentifier, + fieldMetadataUniversalIdentifier, + } = resolveEntityRelationUniversalIdentifiers({ + metadataName: 'fieldPermission', + foreignKeyValues: { + roleId, + objectMetadataId: fieldPermissionInput.objectMetadataId, + fieldMetadataId: fieldPermissionInput.fieldMetadataId, + }, + flatEntityMaps: { + flatRoleMaps, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + }, + }); + + return { + id: v4(), + universalIdentifier: v4(), + applicationUniversalIdentifier: flatApplication.universalIdentifier, + roleUniversalIdentifier, + objectMetadataUniversalIdentifier, + fieldMetadataUniversalIdentifier, + canReadFieldValue: fieldPermissionInput.canReadFieldValue ?? undefined, + canUpdateFieldValue: fieldPermissionInput.canUpdateFieldValue ?? undefined, + createdAt: now, + updatedAt: now, + }; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-field-permission/utils/from-field-permission-entity-to-flat-field-permission.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-field-permission/utils/from-field-permission-entity-to-flat-field-permission.util.ts new file mode 100644 index 00000000000..fd33aecdee7 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/flat-field-permission/utils/from-field-permission-entity-to-flat-field-permission.util.ts @@ -0,0 +1,81 @@ +import { isDefined, removePropertiesFromRecord } from 'twenty-shared/utils'; + +import { + FlatEntityMapsException, + FlatEntityMapsExceptionCode, +} from 'src/engine/metadata-modules/flat-entity/exceptions/flat-entity-maps.exception'; +import { getMetadataEntityRelationProperties } from 'src/engine/metadata-modules/flat-entity/utils/get-metadata-entity-relation-properties.util'; +import { type FlatFieldPermission } from 'src/engine/metadata-modules/flat-field-permission/types/flat-field-permission.type'; +import { type FromEntityToFlatEntityArgs } from 'src/engine/workspace-cache/types/from-entity-to-flat-entity-args.type'; + +export const fromFieldPermissionEntityToFlatFieldPermission = ({ + entity: fieldPermissionEntity, + applicationIdToUniversalIdentifierMap, + roleIdToUniversalIdentifierMap, + objectMetadataIdToUniversalIdentifierMap, + fieldMetadataIdToUniversalIdentifierMap, +}: FromEntityToFlatEntityArgs<'fieldPermission'>): FlatFieldPermission => { + const fieldPermissionEntityWithoutRelations = removePropertiesFromRecord( + fieldPermissionEntity, + getMetadataEntityRelationProperties('fieldPermission'), + ); + + const applicationUniversalIdentifier = + applicationIdToUniversalIdentifierMap.get( + fieldPermissionEntity.applicationId, + ); + + if (!isDefined(applicationUniversalIdentifier)) { + throw new FlatEntityMapsException( + `Application with id ${fieldPermissionEntity.applicationId} not found for fieldPermission ${fieldPermissionEntity.id}`, + FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND, + ); + } + + const roleUniversalIdentifier = roleIdToUniversalIdentifierMap.get( + fieldPermissionEntity.roleId, + ); + + if (!isDefined(roleUniversalIdentifier)) { + throw new FlatEntityMapsException( + `Role with id ${fieldPermissionEntity.roleId} not found for fieldPermission ${fieldPermissionEntity.id}`, + FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND, + ); + } + + const objectMetadataUniversalIdentifier = + objectMetadataIdToUniversalIdentifierMap.get( + fieldPermissionEntity.objectMetadataId, + ); + + if (!isDefined(objectMetadataUniversalIdentifier)) { + throw new FlatEntityMapsException( + `ObjectMetadata with id ${fieldPermissionEntity.objectMetadataId} not found for fieldPermission ${fieldPermissionEntity.id}`, + FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND, + ); + } + + const fieldMetadataUniversalIdentifier = + fieldMetadataIdToUniversalIdentifierMap.get( + fieldPermissionEntity.fieldMetadataId, + ); + + if (!isDefined(fieldMetadataUniversalIdentifier)) { + throw new FlatEntityMapsException( + `FieldMetadata with id ${fieldPermissionEntity.fieldMetadataId} not found for fieldPermission ${fieldPermissionEntity.id}`, + FlatEntityMapsExceptionCode.ENTITY_NOT_FOUND, + ); + } + + return { + ...fieldPermissionEntityWithoutRelations, + createdAt: fieldPermissionEntity.createdAt.toISOString(), + updatedAt: fieldPermissionEntity.updatedAt.toISOString(), + universalIdentifier: + fieldPermissionEntityWithoutRelations.universalIdentifier, + applicationUniversalIdentifier, + roleUniversalIdentifier, + objectMetadataUniversalIdentifier, + fieldMetadataUniversalIdentifier, + }; +}; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/__mocks__/get-flat-object-metadata.mock.ts b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/__mocks__/get-flat-object-metadata.mock.ts index 4f5d1deefab..c6cf6d301e0 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/__mocks__/get-flat-object-metadata.mock.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/__mocks__/get-flat-object-metadata.mock.ts @@ -20,6 +20,7 @@ export const getFlatObjectMetadataMock = ( viewIds: [], indexMetadataIds: [], objectPermissionIds: [], + fieldPermissionIds: [], fieldIds: [], description: 'default flat object metadata description', icon: 'icon', @@ -50,6 +51,7 @@ export const getFlatObjectMetadataMock = ( applicationUniversalIdentifier: applicationId, fieldUniversalIdentifiers: [], objectPermissionUniversalIdentifiers: [], + fieldPermissionUniversalIdentifiers: [], viewUniversalIdentifiers: [], indexMetadataUniversalIdentifiers: [], labelIdentifierFieldMetadataUniversalIdentifier: diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/from-create-object-input-to-flat-object-metadata-and-flat-field-metadatas-to-create.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/from-create-object-input-to-flat-object-metadata-and-flat-field-metadatas-to-create.util.ts index 663e8d08c9a..17e46d50802 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/from-create-object-input-to-flat-object-metadata-and-flat-field-metadatas-to-create.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/from-create-object-input-to-flat-object-metadata-and-flat-field-metadatas-to-create.util.ts @@ -95,6 +95,7 @@ export const fromCreateObjectInputToFlatObjectMetadataAndFlatFieldMetadatasToCre applicationUniversalIdentifier: flatApplication.universalIdentifier, fieldUniversalIdentifiers: [], objectPermissionUniversalIdentifiers: [], + fieldPermissionUniversalIdentifiers: [], viewUniversalIdentifiers: [], indexMetadataUniversalIdentifiers: [], labelIdentifierFieldMetadataUniversalIdentifier, diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/from-object-metadata-entity-to-flat-object-metadata.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/from-object-metadata-entity-to-flat-object-metadata.util.ts index 367e2fae935..57144209d92 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/from-object-metadata-entity-to-flat-object-metadata.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-object-metadata/utils/from-object-metadata-entity-to-flat-object-metadata.util.ts @@ -81,6 +81,8 @@ export const fromObjectMetadataEntityToFlatObjectMetadata = ({ objectPermissionIds: objectMetadataEntity.objectPermissions.map( ({ id }) => id, ), + fieldPermissionIds: + objectMetadataEntity.fieldPermissions?.map(({ id }) => id) ?? [], applicationUniversalIdentifier, labelIdentifierFieldMetadataUniversalIdentifier, imageIdentifierFieldMetadataUniversalIdentifier, @@ -97,5 +99,9 @@ export const fromObjectMetadataEntityToFlatObjectMetadata = ({ objectMetadataEntity.objectPermissions.map( ({ universalIdentifier }) => universalIdentifier, ), + fieldPermissionUniversalIdentifiers: + objectMetadataEntity.fieldPermissions?.map( + ({ universalIdentifier }) => universalIdentifier, + ) ?? [], }; }; diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-role/utils/from-create-role-input-to-flat-role-to-create.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-role/utils/from-create-role-input-to-flat-role-to-create.util.ts index 7346d5a1a91..5b6a081cd6f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-role/utils/from-create-role-input-to-flat-role-to-create.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-role/utils/from-create-role-input-to-flat-role-to-create.util.ts @@ -58,6 +58,7 @@ export const fromCreateRoleInputToFlatRoleToCreate = ({ objectPermissionUniversalIdentifiers: [], permissionFlagUniversalIdentifiers: [], fieldPermissionIds: [], + fieldPermissionUniversalIdentifiers: [], rowLevelPermissionPredicateIds: [], rowLevelPermissionPredicateUniversalIdentifiers: [], rowLevelPermissionPredicateGroupIds: [], diff --git a/packages/twenty-server/src/engine/metadata-modules/flat-role/utils/from-role-entity-to-flat-role.util.ts b/packages/twenty-server/src/engine/metadata-modules/flat-role/utils/from-role-entity-to-flat-role.util.ts index e0800831b66..7ff3d8a385a 100644 --- a/packages/twenty-server/src/engine/metadata-modules/flat-role/utils/from-role-entity-to-flat-role.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/flat-role/utils/from-role-entity-to-flat-role.util.ts @@ -74,6 +74,9 @@ export const fromRoleEntityToFlatRole = ({ permissionFlagUniversalIdentifiers: roleEntity.permissionFlags.map( ({ universalIdentifier }) => universalIdentifier, ), + fieldPermissionUniversalIdentifiers: roleEntity.fieldPermissions.map( + ({ universalIdentifier }) => universalIdentifier, + ), rowLevelPermissionPredicateUniversalIdentifiers: roleEntity.rowLevelPermissionPredicates.map( ({ universalIdentifier }) => universalIdentifier, diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/constants/partial-system-flat-field-metadatas.constant.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/constants/partial-system-flat-field-metadatas.constant.ts index 6f1dcf07e6b..8103e7b5dec 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/constants/partial-system-flat-field-metadatas.constant.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/constants/partial-system-flat-field-metadatas.constant.ts @@ -38,6 +38,7 @@ const PARTIAL_ID_FIELD = { kanbanAggregateOperationViewUniversalIdentifiers: [], calendarViewUniversalIdentifiers: [], mainGroupByFieldMetadataViewUniversalIdentifiers: [], + fieldPermissionUniversalIdentifiers: [], viewSortUniversalIdentifiers: [], } as const satisfies PartialSystemFlatFieldMetadata; @@ -66,6 +67,7 @@ const PARTIAL_CREATED_AT_FIELD = { kanbanAggregateOperationViewUniversalIdentifiers: [], calendarViewUniversalIdentifiers: [], mainGroupByFieldMetadataViewUniversalIdentifiers: [], + fieldPermissionUniversalIdentifiers: [], viewSortUniversalIdentifiers: [], } as const satisfies PartialSystemFlatFieldMetadata; @@ -94,6 +96,7 @@ const PARTIAL_UPDATED_AT_FIELD = { kanbanAggregateOperationViewUniversalIdentifiers: [], calendarViewUniversalIdentifiers: [], mainGroupByFieldMetadataViewUniversalIdentifiers: [], + fieldPermissionUniversalIdentifiers: [], viewSortUniversalIdentifiers: [], } as const satisfies PartialSystemFlatFieldMetadata; @@ -122,6 +125,7 @@ const PARTIAL_DELETED_AT_FIELD = { kanbanAggregateOperationViewUniversalIdentifiers: [], calendarViewUniversalIdentifiers: [], mainGroupByFieldMetadataViewUniversalIdentifiers: [], + fieldPermissionUniversalIdentifiers: [], viewSortUniversalIdentifiers: [], } as const satisfies PartialSystemFlatFieldMetadata; @@ -150,6 +154,7 @@ const PARTIAL_CREATED_BY_FIELD = { kanbanAggregateOperationViewUniversalIdentifiers: [], calendarViewUniversalIdentifiers: [], mainGroupByFieldMetadataViewUniversalIdentifiers: [], + fieldPermissionUniversalIdentifiers: [], viewSortUniversalIdentifiers: [], } as const satisfies PartialSystemFlatFieldMetadata; @@ -178,6 +183,7 @@ const PARTIAL_UPDATED_BY_FIELD = { kanbanAggregateOperationViewUniversalIdentifiers: [], calendarViewUniversalIdentifiers: [], mainGroupByFieldMetadataViewUniversalIdentifiers: [], + fieldPermissionUniversalIdentifiers: [], viewSortUniversalIdentifiers: [], } as const satisfies PartialSystemFlatFieldMetadata; @@ -206,6 +212,7 @@ const PARTIAL_POSITION_FIELD = { kanbanAggregateOperationViewUniversalIdentifiers: [], calendarViewUniversalIdentifiers: [], mainGroupByFieldMetadataViewUniversalIdentifiers: [], + fieldPermissionUniversalIdentifiers: [], viewSortUniversalIdentifiers: [], } as const satisfies PartialSystemFlatFieldMetadata; @@ -236,6 +243,7 @@ const PARTIAL_SEARCH_VECTOR_FIELD = { kanbanAggregateOperationViewUniversalIdentifiers: [], calendarViewUniversalIdentifiers: [], mainGroupByFieldMetadataViewUniversalIdentifiers: [], + fieldPermissionUniversalIdentifiers: [], viewSortUniversalIdentifiers: [], } as const satisfies PartialSystemFlatFieldMetadata; diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-default-flat-field-metadatas-for-custom-object.util.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-default-flat-field-metadatas-for-custom-object.util.ts index 9963f39a67c..84bbc6c3d3c 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-default-flat-field-metadatas-for-custom-object.util.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/utils/build-default-flat-field-metadatas-for-custom-object.util.ts @@ -151,6 +151,7 @@ export const buildDefaultFlatFieldMetadatasForCustomObject = ({ kanbanAggregateOperationViewUniversalIdentifiers: [], calendarViewUniversalIdentifiers: [], mainGroupByFieldMetadataViewUniversalIdentifiers: [], + fieldPermissionUniversalIdentifiers: [], universalSettings: null, viewSortUniversalIdentifiers: [], }; diff --git a/packages/twenty-server/src/engine/metadata-modules/object-permission/field-permission/__tests__/field-permissions.service.spec.ts b/packages/twenty-server/src/engine/metadata-modules/object-permission/field-permission/__tests__/field-permissions.service.spec.ts index e8b7e59fd13..61cc043e8c4 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-permission/field-permission/__tests__/field-permissions.service.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-permission/field-permission/__tests__/field-permissions.service.spec.ts @@ -2,13 +2,14 @@ import { Test, type TestingModule } from '@nestjs/testing'; import { getRepositoryToken } from '@nestjs/typeorm'; import { type ObjectsPermissionsByRoleId } from 'twenty-shared/types'; -import { In, type Repository } from 'typeorm'; +import { type Repository } from 'typeorm'; import { fieldRelationMock, fieldTextMock, objectMetadataItemMock, } from 'src/engine/api/__mocks__/object-metadata-item.mock'; +import { ApplicationService } from 'src/engine/core-modules/application/application.service'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; import { type UpsertFieldPermissionsInput } from 'src/engine/metadata-modules/object-permission/dtos/upsert-field-permissions.input'; @@ -21,8 +22,18 @@ import { } from 'src/engine/metadata-modules/permissions/permissions.exception'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; +import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception'; +import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service'; import { getMockFieldMetadataEntity } from 'src/utils/__test__/get-field-metadata-entity.mock'; +const emptyFlatFieldPermissionMaps = { + byUniversalIdentifier: {}, + universalIdentifierById: {}, + byId: {}, + idByUniversalIdentifier: {}, + universalIdentifiersByApplicationId: {}, +}; + describe('FieldPermissionService', () => { let service: FieldPermissionService; let fieldPermissionsRepository: jest.Mocked< @@ -32,6 +43,17 @@ describe('FieldPermissionService', () => { let fieldMetadataRepository: jest.Mocked>; let workspaceCacheService: jest.Mocked; let workspaceManyOrAllFlatEntityMapsCacheService: jest.Mocked; + let workspaceMigrationValidateBuildAndRunService: jest.Mocked; + let flatRoleMaps: { + byUniversalIdentifier: Record< + string, + { id: string; isEditable: boolean; universalIdentifier: string } + >; + universalIdentifierById: Record; + byId: Record; + idByUniversalIdentifier: Record; + universalIdentifiersByApplicationId: Record; + }; const testWorkspaceId = '20202020-0000-0000-0000-000000000000'; const testRoleId = '20202020-0000-0000-0000-000000000001'; @@ -114,10 +136,32 @@ describe('FieldPermissionService', () => { find: jest.fn(), }, }, + { + provide: ApplicationService, + useValue: { + findWorkspaceTwentyStandardAndCustomApplicationOrThrow: jest + .fn() + .mockResolvedValue({ + workspaceCustomFlatApplication: { + id: 'app-id', + universalIdentifier: 'app-universal-id', + }, + }), + }, + }, + { + provide: WorkspaceMigrationValidateBuildAndRunService, + useValue: { + validateBuildAndRunWorkspaceMigration: jest.fn(), + }, + }, ], }).compile(); service = module.get(FieldPermissionService); + workspaceMigrationValidateBuildAndRunService = module.get( + WorkspaceMigrationValidateBuildAndRunService, + ); fieldPermissionsRepository = module.get( getRepositoryToken(FieldPermissionEntity), ); @@ -136,9 +180,19 @@ describe('FieldPermissionService', () => { fieldTextMock, fieldRelationMock, ]); - (workspaceCacheService.getOrRecompute as jest.Mock).mockResolvedValue({ - rolesPermissions: mockRolesPermissions, - } as any); + (workspaceCacheService.getOrRecompute as jest.Mock).mockImplementation( + (_workspaceId: string, keys: string[]) => { + if (keys?.includes('flatFieldPermissionMaps')) { + return Promise.resolve({ + flatFieldPermissionMaps: emptyFlatFieldPermissionMaps, + }); + } + if (keys?.includes('rolesPermissions')) { + return Promise.resolve({ rolesPermissions: mockRolesPermissions }); + } + return Promise.resolve({}); + }, + ); const testFieldMetadata = getMockFieldMetadataEntity({ ...fieldTextMock, label: 'Test Field', @@ -147,8 +201,22 @@ describe('FieldPermissionService', () => { id: testFieldMetadataId, }); + flatRoleMaps = { + byUniversalIdentifier: { + [testRoleId]: { + id: testRoleId, + isEditable: true, + universalIdentifier: testRoleId, + }, + }, + universalIdentifierById: { [testRoleId]: testRoleId }, + byId: {}, + idByUniversalIdentifier: {}, + universalIdentifiersByApplicationId: {}, + }; workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps.mockResolvedValue( { + flatRoleMaps, flatObjectMetadataMaps: { byUniversalIdentifier: { [testObjectMetadataId]: { @@ -169,11 +237,23 @@ describe('FieldPermissionService', () => { universalIdentifier: fieldRelationMock.objectMetadataId, applicationId: null, } as any, + [fieldRelationMock.relationTargetObjectMetadataId!]: { + ...objectMetadataItemMock, + id: fieldRelationMock.relationTargetObjectMetadataId, + fieldIds: [fieldRelationMock.relationTargetFieldMetadataId!], + indexMetadataIds: [], + viewIds: [], + universalIdentifier: + fieldRelationMock.relationTargetObjectMetadataId, + applicationId: null, + } as any, }, universalIdentifierById: { [testObjectMetadataId]: testObjectMetadataId, [fieldRelationMock.objectMetadataId]: fieldRelationMock.objectMetadataId, + [fieldRelationMock.relationTargetObjectMetadataId!]: + fieldRelationMock.relationTargetObjectMetadataId!, }, universalIdentifiersByApplicationId: {}, }, @@ -181,10 +261,18 @@ describe('FieldPermissionService', () => { byUniversalIdentifier: { [testFieldMetadata.universalIdentifier]: testFieldMetadata as any, [fieldRelationMock.universalIdentifier]: fieldRelationMock as any, + [fieldRelationMock.relationTargetFieldMetadataId!]: { + ...fieldRelationMock, + id: fieldRelationMock.relationTargetFieldMetadataId, + universalIdentifier: + fieldRelationMock.relationTargetFieldMetadataId, + } as any, }, universalIdentifierById: { [testFieldMetadataId]: testFieldMetadata.universalIdentifier, [fieldRelationMock.id]: fieldRelationMock.universalIdentifier, + [fieldRelationMock.relationTargetFieldMetadataId!]: + fieldRelationMock.relationTargetFieldMetadataId!, }, universalIdentifiersByApplicationId: {}, }, @@ -218,6 +306,10 @@ describe('FieldPermissionService', () => { describe('successful cases', () => { it('should successfully upsert field permissions', async () => { + workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration.mockResolvedValue( + { status: 'success' } as any, + ); + const input = createUpsertInput([ { canReadFieldValue: false, @@ -225,51 +317,81 @@ describe('FieldPermissionService', () => { }, ]); - await service.upsertFieldPermissions({ + const result = await service.upsertFieldPermissions({ workspaceId: testWorkspaceId, input, }); - expect(fieldPermissionsRepository.upsert).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - roleId: testRoleId, - workspaceId: testWorkspaceId, - objectMetadataId: testObjectMetadataId, - fieldMetadataId: testFieldMetadataId, - canReadFieldValue: false, - canUpdateFieldValue: false, + expect( + workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration, + ).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: testWorkspaceId, + isSystemBuild: false, + allFlatEntityOperationByMetadataName: expect.objectContaining({ + fieldPermission: expect.objectContaining({ + flatEntityToCreate: expect.any(Array), + flatEntityToUpdate: expect.any(Array), + flatEntityToDelete: expect.any(Array), + }), }), - ]), - { - conflictPaths: ['fieldMetadataId', 'roleId'], - }, + }), ); - + expect(result).toEqual(expect.any(Array)); expect( workspaceCacheService.invalidateAndRecompute, ).toHaveBeenCalledWith(testWorkspaceId, ['rolesPermissions']); }); it('should delete field permissions when both canReadFieldValue and canUpdateFieldValue are null', async () => { - const existingFieldPermission: FieldPermissionEntity = { - id: 'existing-field-permission-id', - roleId: testRoleId, - objectMetadataId: testObjectMetadataId, - fieldMetadataId: testFieldMetadataId, - canReadFieldValue: null, - canUpdateFieldValue: false, - workspaceId: testWorkspaceId, - createdAt: new Date(), - updatedAt: new Date(), - } as unknown as FieldPermissionEntity; - - fieldPermissionsRepository.find.mockResolvedValue([ - existingFieldPermission, - ]); + const existingUniversalId = 'existing-fp-universal-id'; + const mapsWithOneCurrentPermission = { + ...emptyFlatFieldPermissionMaps, + byUniversalIdentifier: { + [existingUniversalId]: { + id: 'existing-fp-id', + universalIdentifier: existingUniversalId, + roleUniversalIdentifier: testRoleId, + objectMetadataId: testObjectMetadataId, + fieldMetadataId: testFieldMetadataId, + canReadFieldValue: undefined, + canUpdateFieldValue: false, + applicationUniversalIdentifier: 'app-ui', + objectMetadataUniversalIdentifier: testObjectMetadataId, + fieldMetadataUniversalIdentifier: testFieldMetadataId, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }, + universalIdentifierById: { 'existing-fp-id': existingUniversalId }, + }; + let flatFieldPermissionMapsCallCount = 0; + (workspaceCacheService.getOrRecompute as jest.Mock).mockImplementation( + (_w: string, keys: string[]) => { + if (keys?.includes('flatFieldPermissionMaps')) { + flatFieldPermissionMapsCallCount += 1; + return Promise.resolve({ + flatFieldPermissionMaps: + flatFieldPermissionMapsCallCount === 1 + ? mapsWithOneCurrentPermission + : emptyFlatFieldPermissionMaps, + }); + } + if (keys?.includes('rolesPermissions')) { + return Promise.resolve({ + rolesPermissions: mockRolesPermissions, + }); + } + return Promise.resolve({}); + }, + ); + workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration.mockResolvedValue( + { status: 'success' } as any, + ); const input = createUpsertInput([ { + canReadFieldValue: null, canUpdateFieldValue: null, }, ]); @@ -279,27 +401,68 @@ describe('FieldPermissionService', () => { input, }); - expect(fieldPermissionsRepository.delete).toHaveBeenCalledWith({ - id: In(['existing-field-permission-id']), - }); + expect( + workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration, + ).toHaveBeenCalledWith( + expect.objectContaining({ + allFlatEntityOperationByMetadataName: expect.objectContaining({ + fieldPermission: expect.objectContaining({ + flatEntityToDelete: expect.arrayContaining([ + expect.objectContaining({ + universalIdentifier: existingUniversalId, + }), + ]), + }), + }), + }), + ); }); it('should not delete field permissions when one value is null and the other is false', async () => { - const existingFieldPermission: FieldPermissionEntity = { - id: 'existing-field-permission-id', - roleId: testRoleId, - objectMetadataId: testObjectMetadataId, - fieldMetadataId: testFieldMetadataId, - canReadFieldValue: false, - canUpdateFieldValue: null, - workspaceId: testWorkspaceId, - createdAt: new Date(), - updatedAt: new Date(), - } as FieldPermissionEntity; - - fieldPermissionsRepository.find.mockResolvedValue([ - existingFieldPermission, - ]); + const existingUniversalId = 'existing-fp-ui-2'; + const mapsWithOneCurrent = { + ...emptyFlatFieldPermissionMaps, + byUniversalIdentifier: { + [existingUniversalId]: { + id: 'existing-fp-id-2', + universalIdentifier: existingUniversalId, + roleUniversalIdentifier: testRoleId, + objectMetadataId: testObjectMetadataId, + fieldMetadataId: testFieldMetadataId, + canReadFieldValue: false, + canUpdateFieldValue: undefined, + applicationUniversalIdentifier: 'app-ui', + objectMetadataUniversalIdentifier: testObjectMetadataId, + fieldMetadataUniversalIdentifier: testFieldMetadataId, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }, + }, + universalIdentifierById: { 'existing-fp-id-2': existingUniversalId }, + }; + let flatMapsCallCount = 0; + (workspaceCacheService.getOrRecompute as jest.Mock).mockImplementation( + (_w: string, keys: string[]) => { + if (keys?.includes('flatFieldPermissionMaps')) { + flatMapsCallCount += 1; + return Promise.resolve({ + flatFieldPermissionMaps: + flatMapsCallCount === 1 + ? mapsWithOneCurrent + : emptyFlatFieldPermissionMaps, + }); + } + if (keys?.includes('rolesPermissions')) { + return Promise.resolve({ + rolesPermissions: mockRolesPermissions, + }); + } + return Promise.resolve({}); + }, + ); + workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration.mockResolvedValue( + { status: 'success' } as any, + ); const input = createUpsertInput([ { @@ -313,12 +476,26 @@ describe('FieldPermissionService', () => { input, }); - expect(fieldPermissionsRepository.delete).not.toHaveBeenCalled(); + const callArg = workspaceMigrationValidateBuildAndRunService + .validateBuildAndRunWorkspaceMigration.mock + .calls[0][0] as unknown as { + allFlatEntityOperationByMetadataName: { + fieldPermission: { flatEntityToDelete: unknown[] }; + }; + }; + expect( + callArg.allFlatEntityOperationByMetadataName.fieldPermission + .flatEntityToDelete, + ).toHaveLength(0); }); }); describe('relation cases', () => { it('should create two field permissions when a relation field permission is created', async () => { + workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration.mockResolvedValue( + { status: 'success' } as any, + ); + const input = createUpsertInput([ { canReadFieldValue: false, @@ -333,28 +510,17 @@ describe('FieldPermissionService', () => { input, }); - expect(fieldPermissionsRepository.upsert).toHaveBeenCalledWith( - [ - { - fieldMetadataId: fieldRelationMock.id, - objectMetadataId: fieldRelationMock.objectMetadataId, - canReadFieldValue: false, - canUpdateFieldValue: false, - roleId: testRoleId, - workspaceId: testWorkspaceId, - }, - { - fieldMetadataId: fieldRelationMock.relationTargetFieldMetadataId, - objectMetadataId: - fieldRelationMock.relationTargetObjectMetadataId, - canReadFieldValue: false, - canUpdateFieldValue: false, - roleId: testRoleId, - workspaceId: testWorkspaceId, - }, - ], - { conflictPaths: ['fieldMetadataId', 'roleId'] }, - ); + const callArg = workspaceMigrationValidateBuildAndRunService + .validateBuildAndRunWorkspaceMigration.mock + .calls[0][0] as unknown as { + allFlatEntityOperationByMetadataName: { + fieldPermission: { flatEntityToCreate: unknown[] }; + }; + }; + expect( + callArg.allFlatEntityOperationByMetadataName.fieldPermission + .flatEntityToCreate, + ).toHaveLength(2); }); }); @@ -404,6 +570,7 @@ describe('FieldPermissionService', () => { it('should throw error when object metadata is not found', async () => { workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps.mockResolvedValue( { + flatRoleMaps, flatObjectMetadataMaps: { byUniversalIdentifier: {}, universalIdentifierById: {}, @@ -446,6 +613,7 @@ describe('FieldPermissionService', () => { workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps.mockResolvedValue( { + flatRoleMaps, flatObjectMetadataMaps: { byUniversalIdentifier: { [testObjectMetadataId]: { @@ -494,6 +662,7 @@ describe('FieldPermissionService', () => { it('should throw error when field metadata is not found', async () => { workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps.mockResolvedValue( { + flatRoleMaps, flatObjectMetadataMaps: { byUniversalIdentifier: { [testObjectMetadataId]: { @@ -541,9 +710,19 @@ describe('FieldPermissionService', () => { }); it('should throw error when object permission is not found', async () => { - (workspaceCacheService.getOrRecompute as jest.Mock).mockResolvedValue({ - rolesPermissions: {}, - } as any); + (workspaceCacheService.getOrRecompute as jest.Mock).mockImplementation( + (_w: string, keys: string[]) => { + if (keys?.includes('flatFieldPermissionMaps')) { + return Promise.resolve({ + flatFieldPermissionMaps: emptyFlatFieldPermissionMaps, + }); + } + if (keys?.includes('rolesPermissions')) { + return Promise.resolve({ rolesPermissions: {} }); + } + return Promise.resolve({}); + }, + ); const input = createUpsertInput([ { @@ -568,12 +747,20 @@ describe('FieldPermissionService', () => { describe('role validation errors', () => { it('should throw error when role is not editable', async () => { - const nonEditableRole: RoleEntity = { - ...mockRole, - isEditable: false, - }; - - roleRepository.findOne.mockResolvedValue(nonEditableRole); + workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration.mockResolvedValue( + { + status: 'fail', + report: { + fieldPermission: [ + { + code: PermissionsExceptionCode.ROLE_NOT_EDITABLE, + message: 'Role is not editable', + userFriendlyMessage: 'This role cannot be modified.', + }, + ], + }, + } as any, + ); const input = createUpsertInput([ { @@ -587,12 +774,7 @@ describe('FieldPermissionService', () => { workspaceId: testWorkspaceId, input, }), - ).rejects.toThrow( - new PermissionsException( - PermissionsExceptionMessage.ROLE_NOT_EDITABLE, - PermissionsExceptionCode.ROLE_NOT_EDITABLE, - ), - ); + ).rejects.toThrow(WorkspaceMigrationBuilderException); }); }); }); diff --git a/packages/twenty-server/src/engine/metadata-modules/object-permission/field-permission/field-permission.entity.ts b/packages/twenty-server/src/engine/metadata-modules/object-permission/field-permission/field-permission.entity.ts index de4456837e9..a4397b193fb 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-permission/field-permission/field-permission.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-permission/field-permission/field-permission.entity.ts @@ -15,7 +15,7 @@ import { import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; -import { WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/workspace-related-entity'; +import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface'; @Entity('fieldPermission') @Unique('IDX_FIELD_PERMISSION_FIELD_METADATA_ID_ROLE_ID_UNIQUE', [ @@ -23,7 +23,7 @@ import { WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/works 'roleId', ]) @Index('IDX_FIELD_PERMISSION_WORKSPACE_ID_ROLE_ID', ['workspaceId', 'roleId']) -export class FieldPermissionEntity extends WorkspaceRelatedEntity { +export class FieldPermissionEntity extends SyncableEntity { @PrimaryGeneratedColumn('uuid') id: string; diff --git a/packages/twenty-server/src/engine/metadata-modules/object-permission/field-permission/field-permission.service.ts b/packages/twenty-server/src/engine/metadata-modules/object-permission/field-permission/field-permission.service.ts index c1c5791f29c..f319101700f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-permission/field-permission/field-permission.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-permission/field-permission/field-permission.service.ts @@ -1,45 +1,49 @@ import { Injectable } from '@nestjs/common'; -import { InjectRepository } from '@nestjs/typeorm'; import { msg } from '@lingui/core/macro'; -import { type ObjectsPermissionsByRoleId } from 'twenty-shared/types'; +import { FieldMetadataType } from 'twenty-shared/types'; import { isDefined } from 'twenty-shared/utils'; -import { In, Repository } from 'typeorm'; +import { ApplicationService } from 'src/engine/core-modules/application/application.service'; +import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface'; - -import { - InternalServerError, - UserInputError, -} from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; -import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; -import { isFieldMetadataTypeRelation } from 'src/engine/metadata-modules/field-metadata/utils/is-field-metadata-type-relation.util'; import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service'; -import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util'; import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { type FlatFieldPermissionMaps } from 'src/engine/metadata-modules/flat-field-permission/types/flat-field-permission-maps.type'; +import { type FlatFieldPermission } from 'src/engine/metadata-modules/flat-field-permission/types/flat-field-permission.type'; +import { fromCreateFieldPermissionInputToUniversalFlatFieldPermission } from 'src/engine/metadata-modules/flat-field-permission/utils/from-create-field-permission-input-to-universal-flat-field-permission.util'; import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +import { type FlatRole } from 'src/engine/metadata-modules/flat-role/types/flat-role.type'; import { type UpsertFieldPermissionsInput } from 'src/engine/metadata-modules/object-permission/dtos/upsert-field-permissions.input'; -import { FieldPermissionEntity } from 'src/engine/metadata-modules/object-permission/field-permission/field-permission.entity'; import { PermissionsException, PermissionsExceptionCode, PermissionsExceptionMessage, } from 'src/engine/metadata-modules/permissions/permissions.exception'; -import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service'; +import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception'; +import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service'; +import { type UniversalFlatFieldPermission } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-permission.type'; + +type DesiredFieldPermission = { + objectMetadataId: string; + fieldMetadataId: string; + canReadFieldValue?: boolean | null; + canUpdateFieldValue?: boolean | null; +}; + +const keyFrom = (objectMetadataId: string, fieldMetadataId: string) => + `${objectMetadataId}:${fieldMetadataId}`; @Injectable() export class FieldPermissionService { constructor( - @InjectRepository(RoleEntity) - private readonly roleRepository: Repository, - @InjectRepository(FieldMetadataEntity) - private readonly fieldMetadataRepository: Repository, - @InjectRepository(FieldPermissionEntity) - private readonly fieldPermissionsRepository: Repository, - private readonly workspaceCacheService: WorkspaceCacheService, + private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService, private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService, + private readonly workspaceCacheService: WorkspaceCacheService, + private readonly applicationService: ApplicationService, ) {} public async upsertFieldPermissions({ @@ -48,135 +52,249 @@ export class FieldPermissionService { }: { workspaceId: string; input: UpsertFieldPermissionsInput; - }): Promise { - const role = await this.getRoleOrThrow({ - roleId: input.roleId, - workspaceId, - }); + }): Promise { + const [flatMapsForRoleObjectField, flatFieldPermissionMapsResult] = + await Promise.all([ + this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( + { + workspaceId, + flatMapsKeys: [ + 'flatRoleMaps', + 'flatObjectMetadataMaps', + 'flatFieldMetadataMaps', + ], + }, + ), + this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'flatFieldPermissionMaps', + ] as unknown as Parameters[1]), + ]); + const flatRoleMaps = flatMapsForRoleObjectField.flatRoleMaps; + const flatObjectMetadataMaps = + flatMapsForRoleObjectField.flatObjectMetadataMaps; + const flatFieldMetadataMaps = + flatMapsForRoleObjectField.flatFieldMetadataMaps; + const flatFieldPermissionMapsResolved: FlatFieldPermissionMaps = ( + flatFieldPermissionMapsResult as unknown as { + flatFieldPermissionMaps: FlatFieldPermissionMaps; + } + ).flatFieldPermissionMaps; const { rolesPermissions } = await this.workspaceCacheService.getOrRecompute(workspaceId, [ 'rolesPermissions', ]); - await this.validateRoleIsEditableOrThrow({ - role, - }); + const roleUniversalIdentifier = + flatRoleMaps.universalIdentifierById[input.roleId]; + const flatRole: FlatRole | undefined = isDefined(roleUniversalIdentifier) + ? (flatRoleMaps.byUniversalIdentifier[roleUniversalIdentifier] as + | FlatRole + | undefined) + : undefined; - const { flatObjectMetadataMaps, flatFieldMetadataMaps } = - await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps( - { - workspaceId, - flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'], - }, - ); - - const existingFieldPermissions = await this.fieldPermissionsRepository.find( - { - where: { - roleId: input.roleId, - workspaceId, - }, - }, + const currentFieldPermissionsForRole = Object.values( + flatFieldPermissionMapsResolved.byUniversalIdentifier, + ).filter( + (fp): fp is FlatFieldPermission => + isDefined(fp) && fp.roleUniversalIdentifier === roleUniversalIdentifier, ); - const fieldPermissionsToDeleteIds: string[] = []; + const desiredMap = new Map(); - input.fieldPermissions.forEach((fieldPermission) => { + for (const fieldPermission of input.fieldPermissions) { this.validateFieldPermission({ allFieldPermissions: input.fieldPermissions, fieldPermission, flatObjectMetadataMaps, flatFieldMetadataMaps, rolesPermissions, - role, + flatRole, }); - if ( - fieldPermission.canReadFieldValue === null || - fieldPermission.canUpdateFieldValue === null - ) { - this.checkIfFieldPermissionShouldBeDeleted({ - fieldPermission, - existingFieldPermissions, - fieldPermissionsToDeleteIds, - }); + const bothNull = + (fieldPermission.canReadFieldValue === null || + fieldPermission.canReadFieldValue === undefined) && + (fieldPermission.canUpdateFieldValue === null || + fieldPermission.canUpdateFieldValue === undefined); + + if (bothNull) { + continue; } + + desiredMap.set( + keyFrom( + fieldPermission.objectMetadataId, + fieldPermission.fieldMetadataId, + ), + { + objectMetadataId: fieldPermission.objectMetadataId, + fieldMetadataId: fieldPermission.fieldMetadataId, + canReadFieldValue: fieldPermission.canReadFieldValue ?? undefined, + canUpdateFieldValue: fieldPermission.canUpdateFieldValue ?? undefined, + }, + ); + } + + this.addRelatedFieldPermissionsToDesired({ + desiredMap, + inputFieldPermissions: input.fieldPermissions, + flatFieldMetadataMaps, }); - const fieldPermissions = input.fieldPermissions.map((fieldPermission) => ({ - ...fieldPermission, - roleId: input.roleId, - workspaceId, - })); + const { workspaceCustomFlatApplication } = + await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow( + { workspaceId }, + ); - const existingFieldPermissionsToDelete = existingFieldPermissions.filter( - (existingFieldPermissionToFilter) => - fieldPermissionsToDeleteIds.includes( - existingFieldPermissionToFilter.id, - ), + const flatEntityToCreate: (UniversalFlatFieldPermission & { + id: string; + })[] = []; + const flatEntityToUpdate: UniversalFlatFieldPermission[] = []; + const flatEntityToDelete: UniversalFlatFieldPermission[] = []; + + const currentByKey = new Map( + currentFieldPermissionsForRole.map((fp) => [ + keyFrom(fp.objectMetadataId, fp.fieldMetadataId), + fp, + ]), ); - const fieldPermissionsToUpsert = fieldPermissions.filter( - (fieldPermissionToUpsert) => - !existingFieldPermissionsToDelete.some( - (existingFieldPermissionToDelete) => - existingFieldPermissionToDelete.fieldMetadataId === - fieldPermissionToUpsert.fieldMetadataId, - ), - ); + for (const [, desired] of desiredMap) { + const current = currentByKey.get( + keyFrom(desired.objectMetadataId, desired.fieldMetadataId), + ); - const fieldMetadatasForFieldPermissions = - await this.fieldMetadataRepository.find({ - where: { - id: In(fieldPermissions.map((fp) => fp.fieldMetadataId)), - }, - }); + if (!isDefined(current)) { + flatEntityToCreate.push( + fromCreateFieldPermissionInputToUniversalFlatFieldPermission({ + fieldPermissionInput: { + objectMetadataId: desired.objectMetadataId, + fieldMetadataId: desired.fieldMetadataId, + canReadFieldValue: desired.canReadFieldValue ?? null, + canUpdateFieldValue: desired.canUpdateFieldValue ?? null, + }, + roleId: input.roleId, + flatApplication: workspaceCustomFlatApplication, + flatRoleMaps, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + }), + ); + } else { + const effectiveCanRead = + desired.canReadFieldValue ?? current.canReadFieldValue; + const effectiveCanUpdate = + desired.canUpdateFieldValue ?? current.canUpdateFieldValue; + const changed = + effectiveCanRead !== current.canReadFieldValue || + effectiveCanUpdate !== current.canUpdateFieldValue; - const relatedFieldPermissionsToUpsert = - this.computeFieldPermissionForRelationTargetFieldMetadata({ - fieldPermissions: fieldPermissionsToUpsert, - fieldMetadatasForFieldPermissions, - }); + if (changed) { + const now = new Date().toISOString(); + flatEntityToUpdate.push({ + universalIdentifier: current.universalIdentifier, + applicationUniversalIdentifier: + current.applicationUniversalIdentifier, + roleUniversalIdentifier: current.roleUniversalIdentifier, + objectMetadataUniversalIdentifier: + current.objectMetadataUniversalIdentifier, + fieldMetadataUniversalIdentifier: + current.fieldMetadataUniversalIdentifier, + canReadFieldValue: effectiveCanRead ?? undefined, + canUpdateFieldValue: effectiveCanUpdate ?? undefined, + createdAt: current.createdAt, + updatedAt: now, + }); + } + } + } - await this.fieldPermissionsRepository.upsert( - [...fieldPermissionsToUpsert, ...relatedFieldPermissionsToUpsert], - { - conflictPaths: ['fieldMetadataId', 'roleId'], - }, - ); - - if (fieldPermissionsToDeleteIds.length > 0) { - const relatedFieldPermissionToDeleteIds = - this.getRelatedFieldPermissionsToDeleteIds({ - allFieldPermissions: existingFieldPermissions, - fieldPermissionsToDelete: existingFieldPermissionsToDelete, - fieldMetadatas: fieldMetadatasForFieldPermissions, + for (const current of currentFieldPermissionsForRole) { + const key = keyFrom(current.objectMetadataId, current.fieldMetadataId); + if (!desiredMap.has(key)) { + flatEntityToDelete.push({ + universalIdentifier: current.universalIdentifier, + applicationUniversalIdentifier: + current.applicationUniversalIdentifier, + roleUniversalIdentifier: current.roleUniversalIdentifier, + objectMetadataUniversalIdentifier: + current.objectMetadataUniversalIdentifier, + fieldMetadataUniversalIdentifier: + current.fieldMetadataUniversalIdentifier, + canReadFieldValue: current.canReadFieldValue ?? undefined, + canUpdateFieldValue: current.canUpdateFieldValue ?? undefined, + createdAt: current.createdAt, + updatedAt: current.updatedAt, }); + } + } - await this.fieldPermissionsRepository.delete({ - id: In([ - ...fieldPermissionsToDeleteIds, - ...relatedFieldPermissionToDeleteIds, - ]), - }); + if ( + flatEntityToCreate.length === 0 && + flatEntityToUpdate.length === 0 && + flatEntityToDelete.length === 0 + ) { + const desiredObjectMetadataIds = new Set( + input.fieldPermissions.map((fp) => fp.objectMetadataId), + ); + return currentFieldPermissionsForRole.filter((fp) => + desiredObjectMetadataIds.has(fp.objectMetadataId), + ); + } + + const buildAndRunResult = + await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration( + { + allFlatEntityOperationByMetadataName: { + fieldPermission: { + flatEntityToCreate, + flatEntityToUpdate, + flatEntityToDelete, + }, + }, + workspaceId, + isSystemBuild: false, + applicationUniversalIdentifier: + workspaceCustomFlatApplication.universalIdentifier, + } as Parameters< + WorkspaceMigrationValidateBuildAndRunService['validateBuildAndRunWorkspaceMigration'] + >[0], + ); + + if (buildAndRunResult.status === 'fail') { + throw new WorkspaceMigrationBuilderException( + buildAndRunResult, + 'Validation errors occurred while upserting field permissions', + ); } await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [ 'rolesPermissions', ]); - return this.fieldPermissionsRepository.find({ - where: { - roleId: input.roleId, - objectMetadataId: In( - input.fieldPermissions.map( - (fieldPermission) => fieldPermission.objectMetadataId, - ), - ), - workspaceId, - }, - }); + const freshFlatFieldPermissionMaps: FlatFieldPermissionMaps = ( + (await this.workspaceCacheService.getOrRecompute(workspaceId, [ + 'flatFieldPermissionMaps', + ] as unknown as Parameters< + WorkspaceCacheService['getOrRecompute'] + >[1])) as unknown as { flatFieldPermissionMaps: FlatFieldPermissionMaps } + ).flatFieldPermissionMaps; + + const resultFieldPermissions = Object.values( + freshFlatFieldPermissionMaps.byUniversalIdentifier, + ).filter( + (fp): fp is FlatFieldPermission => + isDefined(fp) && fp.roleUniversalIdentifier === roleUniversalIdentifier, + ); + + const desiredObjectMetadataIds = new Set( + input.fieldPermissions.map((fp) => fp.objectMetadataId), + ); + const filtered = resultFieldPermissions.filter((fp) => + desiredObjectMetadataIds.has(fp.objectMetadataId), + ); + return filtered; } private validateFieldPermission({ @@ -185,14 +303,14 @@ export class FieldPermissionService { flatObjectMetadataMaps, flatFieldMetadataMaps, rolesPermissions, - role, + flatRole, }: { allFieldPermissions: UpsertFieldPermissionsInput['fieldPermissions']; fieldPermission: UpsertFieldPermissionsInput['fieldPermissions'][0]; flatObjectMetadataMaps: FlatEntityMaps; flatFieldMetadataMaps: FlatEntityMaps; - rolesPermissions: ObjectsPermissionsByRoleId; - role: RoleEntity; + rolesPermissions: Record> | undefined; + flatRole: { id: string } | undefined; }) { const duplicateFieldPermissions = allFieldPermissions.filter( (permission) => @@ -204,12 +322,11 @@ export class FieldPermissionService { `Cannot accept more than one fieldPermission for field ${fieldPermission.fieldMetadataId} in input.`, ); } + if ( - ('canUpdateFieldValue' in fieldPermission && - fieldPermission.canUpdateFieldValue !== null && + (fieldPermission.canUpdateFieldValue !== null && fieldPermission.canUpdateFieldValue !== false) || - ('canReadFieldValue' in fieldPermission && - fieldPermission.canReadFieldValue !== null && + (fieldPermission.canReadFieldValue !== null && fieldPermission.canReadFieldValue !== false) ) { throw new PermissionsException( @@ -261,10 +378,11 @@ export class FieldPermissionService { ); } - const rolePermissionOnObject = - rolesPermissions?.[role.id]?.[fieldPermission.objectMetadataId]; + const rolePermissionOnObject = isDefined(flatRole) + ? rolesPermissions?.[flatRole.id]?.[fieldPermission.objectMetadataId] + : undefined; - if (!isDefined(rolePermissionOnObject)) { + if (isDefined(flatRole) && !isDefined(rolePermissionOnObject)) { throw new PermissionsException( PermissionsExceptionMessage.OBJECT_PERMISSION_NOT_FOUND, PermissionsExceptionCode.OBJECT_PERMISSION_NOT_FOUND, @@ -275,194 +393,84 @@ export class FieldPermissionService { } } - private async getRoleOrThrow({ - roleId, - workspaceId, + private addRelatedFieldPermissionsToDesired({ + desiredMap, + inputFieldPermissions, + flatFieldMetadataMaps, }: { - roleId: string; - workspaceId: string; + desiredMap: Map; + inputFieldPermissions: UpsertFieldPermissionsInput['fieldPermissions']; + flatFieldMetadataMaps: FlatEntityMaps; }) { - const role = await this.roleRepository.findOne({ - where: { - id: roleId, - workspaceId, - }, - relations: ['objectPermissions', 'fieldPermissions'], - }); - - if (!isDefined(role)) { - throw new PermissionsException( - PermissionsExceptionMessage.ROLE_NOT_FOUND, - PermissionsExceptionCode.ROLE_NOT_FOUND, - { - userFriendlyMessage: msg`The role you are trying to modify could not be found. It may have been deleted or you may not have access to it.`, - }, - ); - } - - return role; - } - - private async validateRoleIsEditableOrThrow({ role }: { role: RoleEntity }) { - if (!role.isEditable) { - throw new PermissionsException( - PermissionsExceptionMessage.ROLE_NOT_EDITABLE, - PermissionsExceptionCode.ROLE_NOT_EDITABLE, - { - userFriendlyMessage: msg`This role cannot be modified because it is a system role. Only custom roles can be edited.`, - }, - ); - } - } - - private checkIfFieldPermissionShouldBeDeleted({ - fieldPermission, - existingFieldPermissions, - fieldPermissionsToDeleteIds, - }: { - fieldPermission: UpsertFieldPermissionsInput['fieldPermissions'][0]; - existingFieldPermissions: FieldPermissionEntity[]; - fieldPermissionsToDeleteIds: string[]; - }) { - const existingFieldPermission = existingFieldPermissions.find( - (existingFieldPermission) => - existingFieldPermission.fieldMetadataId === - fieldPermission.fieldMetadataId, + const inputKeys = new Set( + inputFieldPermissions.map((fp) => + keyFrom(fp.objectMetadataId, fp.fieldMetadataId), + ), ); - if (existingFieldPermission) { - const finalCanReadFieldValue = - 'canReadFieldValue' in fieldPermission - ? fieldPermission.canReadFieldValue - : existingFieldPermission.canReadFieldValue; - const finalCanUpdateFieldValue = - 'canUpdateFieldValue' in fieldPermission - ? fieldPermission.canUpdateFieldValue - : existingFieldPermission.canUpdateFieldValue; + for (const fieldPermission of inputFieldPermissions) { + const flatFieldMetadata = findFlatEntityByIdInFlatEntityMaps({ + flatEntityId: fieldPermission.fieldMetadataId, + flatEntityMaps: flatFieldMetadataMaps, + }); if ( - finalCanReadFieldValue === null && - finalCanUpdateFieldValue === null + !isDefined(flatFieldMetadata) || + flatFieldMetadata.type !== FieldMetadataType.RELATION ) { - fieldPermissionsToDeleteIds.push(existingFieldPermission.id); + continue; } - } - } - private getRelatedFieldPermissionsToDeleteIds({ - allFieldPermissions, - fieldPermissionsToDelete, - fieldMetadatas, - }: { - allFieldPermissions: FieldPermissionEntity[]; - fieldPermissionsToDelete: FieldPermissionEntity[]; - fieldMetadatas: FieldMetadataEntity[]; - }) { - const fieldMetadatasForFieldPermissionsToDelete = fieldMetadatas.filter( - (fieldMetadata) => - fieldPermissionsToDelete.some( - (existingFieldPermissionToDelete) => - existingFieldPermissionToDelete.fieldMetadataId === - fieldMetadata.id, - ), - ); - - const relationTargetFieldMetadataIds: string[] = []; - - for (const fieldMetadataForFieldPermissionToDelete of fieldMetadatasForFieldPermissionsToDelete) { + const relationType = ( + flatFieldMetadata.settings as { relationType?: string } | undefined + )?.relationType; if ( - isFieldMetadataTypeRelation(fieldMetadataForFieldPermissionToDelete) + relationType !== RelationType.ONE_TO_MANY && + relationType !== RelationType.MANY_TO_ONE ) { - if ( - fieldMetadataForFieldPermissionToDelete.settings?.relationType === - RelationType.ONE_TO_MANY || - fieldMetadataForFieldPermissionToDelete.settings?.relationType === - RelationType.MANY_TO_ONE - ) { - relationTargetFieldMetadataIds.push( - fieldMetadataForFieldPermissionToDelete.relationTargetFieldMetadataId, - ); - } + continue; } - } - const fieldPermissionsForRelationTargetFieldMetadataIds = - allFieldPermissions - .filter((fieldPermission) => - relationTargetFieldMetadataIds.includes( - fieldPermission.fieldMetadataId, - ), - ) - .map((fieldPermission) => fieldPermission.id); + const targetObjectId = + flatFieldMetadata.relationTargetObjectMetadataId ?? undefined; + const targetFieldId = + flatFieldMetadata.relationTargetFieldMetadataId ?? undefined; - return fieldPermissionsForRelationTargetFieldMetadataIds; - } + if (!targetObjectId || !targetFieldId) { + continue; + } - private computeFieldPermissionForRelationTargetFieldMetadata({ - fieldPermissions, - fieldMetadatasForFieldPermissions, - }: { - fieldPermissions: UpsertFieldPermissionsInput['fieldPermissions']; - fieldMetadatasForFieldPermissions: FieldMetadataEntity[]; - }) { - return fieldPermissions - .map((fieldPermission) => { - const fieldMetadata = fieldMetadatasForFieldPermissions.find( - (fm) => fm.id === fieldPermission.fieldMetadataId, + const targetKey = keyFrom(targetObjectId, targetFieldId); + if (inputKeys.has(targetKey)) { + const targetInInput = inputFieldPermissions.find( + (fp) => + fp.objectMetadataId === targetObjectId && + fp.fieldMetadataId === targetFieldId, ); - - if (!isDefined(fieldMetadata)) { - throw new InternalServerError( - 'Field metadata not found for field permission', - ); - } - - if (isFieldMetadataTypeRelation(fieldMetadata)) { - if ( - fieldMetadata.settings?.relationType === RelationType.ONE_TO_MANY || - fieldMetadata.settings?.relationType === RelationType.MANY_TO_ONE - ) { - const fieldPermissionsOnRelationTargetField = - fieldPermissions.filter( - (fieldPermissionInput) => - fieldPermissionInput.fieldMetadataId === - fieldMetadata.relationTargetFieldMetadataId, - ); - - if (fieldPermissionsOnRelationTargetField.length > 0) { - const firstFieldPermission = - fieldPermissionsOnRelationTargetField[0]; // validation rules guarantee there can only be one - - const hasConflictingPermissions = - fieldPermission.canReadFieldValue !== - firstFieldPermission.canReadFieldValue || - fieldPermission.canUpdateFieldValue !== - firstFieldPermission.canUpdateFieldValue; - - if (hasConflictingPermissions) { - const fieldName = fieldMetadata.name; - - throw new UserInputError( - 'Conflicting field permissions found for relation target field', - { - userFriendlyMessage: msg`Contradicting field permissions have been detected on a relation field (${fieldName}).`, - }, - ); - } - - return; - } - - return { - ...fieldPermission, - objectMetadataId: fieldMetadata.relationTargetObjectMetadataId, - fieldMetadataId: fieldMetadata.relationTargetFieldMetadataId, - }; + if (isDefined(targetInInput)) { + const hasConflict = + fieldPermission.canReadFieldValue !== + targetInInput.canReadFieldValue || + fieldPermission.canUpdateFieldValue !== + targetInInput.canUpdateFieldValue; + if (hasConflict) { + throw new UserInputError( + 'Conflicting field permissions found for relation target field', + { + userFriendlyMessage: msg`Contradicting field permissions have been detected on a relation field.`, + }, + ); } } + continue; + } - return null; - }) - .filter(isDefined); + desiredMap.set(targetKey, { + objectMetadataId: targetObjectId, + fieldMetadataId: targetFieldId, + canReadFieldValue: fieldPermission.canReadFieldValue ?? undefined, + canUpdateFieldValue: fieldPermission.canUpdateFieldValue ?? undefined, + }); + } } } diff --git a/packages/twenty-server/src/engine/metadata-modules/object-permission/utils/from-flat-field-permission-to-field-permission-dto.util.ts b/packages/twenty-server/src/engine/metadata-modules/object-permission/utils/from-flat-field-permission-to-field-permission-dto.util.ts new file mode 100644 index 00000000000..5d9ae9c7e12 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/object-permission/utils/from-flat-field-permission-to-field-permission-dto.util.ts @@ -0,0 +1,13 @@ +import { type FlatFieldPermission } from 'src/engine/metadata-modules/flat-field-permission/types/flat-field-permission.type'; +import { type FieldPermissionDTO } from 'src/engine/metadata-modules/object-permission/dtos/field-permission.dto'; + +export const fromFlatFieldPermissionToFieldPermissionDto = ( + flatFieldPermission: FlatFieldPermission, +): FieldPermissionDTO => ({ + id: flatFieldPermission.id, + objectMetadataId: flatFieldPermission.objectMetadataId, + fieldMetadataId: flatFieldPermission.fieldMetadataId, + roleId: flatFieldPermission.roleId, + canReadFieldValue: flatFieldPermission.canReadFieldValue ?? null, + canUpdateFieldValue: flatFieldPermission.canUpdateFieldValue ?? null, +}); 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 28bf63a3b34..e8f9fe97392 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 @@ -42,6 +42,7 @@ export enum PermissionsExceptionCode { UPSERT_FIELD_PERMISSION_FAILED = 'UPSERT_FIELD_PERMISSION_FAILED', PERMISSION_NOT_FOUND = 'PERMISSION_NOT_FOUND', OBJECT_PERMISSION_NOT_FOUND = 'OBJECT_PERMISSION_NOT_FOUND', + FIELD_PERMISSION_NOT_FOUND = 'FIELD_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', @@ -128,6 +129,8 @@ const getPermissionsExceptionUserFriendlyMessage = ( return msg`Permission not found.`; case PermissionsExceptionCode.OBJECT_PERMISSION_NOT_FOUND: return msg`Object permission not found.`; + case PermissionsExceptionCode.FIELD_PERMISSION_NOT_FOUND: + return msg`Field permission not found.`; case PermissionsExceptionCode.EMPTY_FIELD_PERMISSION_NOT_ALLOWED: return msg`Empty field permissions are not allowed.`; case PermissionsExceptionCode.JOIN_COLUMN_NAME_REQUIRED: @@ -184,6 +187,7 @@ export enum PermissionsExceptionMessage { FIELD_RESTRICTION_ONLY_ALLOWED_ON_READABLE_OBJECT = 'Field restriction only makes sense on readable object', FIELD_RESTRICTION_ON_UPDATE_ONLY_ALLOWED_ON_UPDATABLE_OBJECT = 'Field restriction on update only makes sense on updatable object', OBJECT_PERMISSION_NOT_FOUND = 'Object permission not found', + FIELD_PERMISSION_NOT_FOUND = 'Field permission not found', EMPTY_FIELD_PERMISSION_NOT_ALLOWED = 'Empty field permission not allowed', ROLE_MUST_HAVE_AT_LEAST_ONE_TARGET = 'Role must be assignable to at least one target type', ROLE_CANNOT_BE_ASSIGNED_TO_USERS = 'Role cannot be assigned to users', 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 01074ffd324..98659d9a86b 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 @@ -48,6 +48,7 @@ export const permissionGraphqlApiExceptionHandler = ( case PermissionsExceptionCode.ROLE_NOT_FOUND: case PermissionsExceptionCode.OBJECT_METADATA_NOT_FOUND: case PermissionsExceptionCode.FIELD_METADATA_NOT_FOUND: + case PermissionsExceptionCode.FIELD_PERMISSION_NOT_FOUND: case PermissionsExceptionCode.PERMISSION_NOT_FOUND: throw new NotFoundError(error); case PermissionsExceptionCode.UPSERT_FIELD_PERMISSION_FAILED: diff --git a/packages/twenty-server/src/engine/metadata-modules/role/role.resolver.ts b/packages/twenty-server/src/engine/metadata-modules/role/role.resolver.ts index 66f4649e546..b8d5aa6500f 100644 --- a/packages/twenty-server/src/engine/metadata-modules/role/role.resolver.ts +++ b/packages/twenty-server/src/engine/metadata-modules/role/role.resolver.ts @@ -8,9 +8,10 @@ import { Args, Mutation, Parent, Query, ResolveField } from '@nestjs/graphql'; import { msg } from '@lingui/core/macro'; import { PermissionFlagType } from 'twenty-shared/constants'; -import { isDefined } from 'twenty-shared/utils'; import { FeatureFlagKey } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; +import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/services/api-key-role.service'; import { ApplicationService } from 'src/engine/core-modules/application/application.service'; @@ -20,7 +21,6 @@ import { UserWorkspaceService } from 'src/engine/core-modules/user-workspace/use import { WorkspaceMemberDTO } from 'src/engine/core-modules/user/dtos/workspace-member.dto'; import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity'; import { AuthWorkspaceMemberId } from 'src/engine/decorators/auth/auth-workspace-member-id.decorator'; -import { MetadataResolver } from 'src/engine/api/graphql/graphql-config/decorators/metadata-resolver.decorator'; import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator'; import { RequireFeatureFlag } from 'src/engine/guards/feature-flag.guard'; import { SettingsPermissionGuard } from 'src/engine/guards/settings-permission.guard'; @@ -39,11 +39,12 @@ import { UpsertFieldPermissionsInput } from 'src/engine/metadata-modules/object- import { UpsertObjectPermissionsInput } from 'src/engine/metadata-modules/object-permission/dtos/upsert-object-permissions.input'; import { FieldPermissionService } from 'src/engine/metadata-modules/object-permission/field-permission/field-permission.service'; import { ObjectPermissionService } from 'src/engine/metadata-modules/object-permission/object-permission.service'; +import { fromFlatFieldPermissionToFieldPermissionDto } from 'src/engine/metadata-modules/object-permission/utils/from-flat-field-permission-to-field-permission-dto.util'; import { fromFlatObjectPermissionToObjectPermissionDto } from 'src/engine/metadata-modules/object-permission/utils/from-flat-object-permission-to-object-permission-dto.util'; import { PermissionFlagDTO } from 'src/engine/metadata-modules/permission-flag/dtos/permission-flag.dto'; -import { fromFlatPermissionFlagToPermissionFlagDto } from 'src/engine/metadata-modules/permission-flag/utils/from-flat-permission-flag-to-permission-flag-dto.util'; import { UpsertPermissionFlagsInput } from 'src/engine/metadata-modules/permission-flag/dtos/upsert-permission-flag-input'; import { PermissionFlagService } from 'src/engine/metadata-modules/permission-flag/permission-flag.service'; +import { fromFlatPermissionFlagToPermissionFlagDto } from 'src/engine/metadata-modules/permission-flag/utils/from-flat-permission-flag-to-permission-flag-dto.util'; import { PermissionsException, PermissionsExceptionCode, @@ -242,10 +243,14 @@ export class RoleResolver { @Args('upsertFieldPermissionsInput') upsertFieldPermissionsInput: UpsertFieldPermissionsInput, ): Promise { - return this.fieldPermissionService.upsertFieldPermissions({ - workspaceId: workspace.id, - input: upsertFieldPermissionsInput, - }); + const flatFieldPermissions = + await this.fieldPermissionService.upsertFieldPermissions({ + workspaceId: workspace.id, + input: upsertFieldPermissionsInput, + }); + return flatFieldPermissions.map( + fromFlatFieldPermissionToFieldPermissionDto, + ); } @Mutation(() => UpsertRowLevelPermissionPredicatesResultDTO) diff --git a/packages/twenty-server/src/engine/metadata-modules/role/services/workspace-flat-role-map-cache.service.ts b/packages/twenty-server/src/engine/metadata-modules/role/services/workspace-flat-role-map-cache.service.ts index 9f03423a46f..92a3e5690f0 100644 --- a/packages/twenty-server/src/engine/metadata-modules/role/services/workspace-flat-role-map-cache.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/role/services/workspace-flat-role-map-cache.service.ts @@ -87,7 +87,7 @@ export class WorkspaceFlatRoleMapCacheService extends WorkspaceCacheProvider< }), this.fieldPermissionRepository.find({ where: { workspaceId }, - select: ['id', 'roleId'], + select: ['id', 'universalIdentifier', 'roleId'], withDeleted: true, }), this.rowLevelPermissionPredicateRepository.find({ 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 96b85fbad18..c97c56f41cc 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 @@ -111,6 +111,7 @@ describe('WorkspaceEntityManager', () => { fieldIds: ['field-id'], indexMetadataIds: [], objectPermissionIds: [], + fieldPermissionIds: [], viewIds: [], universalIdentifier: 'test-entity-id', description: null, @@ -127,6 +128,7 @@ describe('WorkspaceEntityManager', () => { applicationUniversalIdentifier: 'test-application-id', fieldUniversalIdentifiers: ['field-id'], objectPermissionUniversalIdentifiers: [], + fieldPermissionUniversalIdentifiers: [], viewUniversalIdentifiers: [], indexMetadataUniversalIdentifiers: [], labelIdentifierFieldMetadataUniversalIdentifier: null, @@ -162,6 +164,7 @@ describe('WorkspaceEntityManager', () => { workspaceId: 'test-workspace-id', viewFieldIds: [], viewFilterIds: [], + fieldPermissionIds: [], kanbanAggregateOperationViewIds: [], calendarViewIds: [], mainGroupByFieldMetadataViewIds: [], @@ -178,6 +181,7 @@ describe('WorkspaceEntityManager', () => { kanbanAggregateOperationViewUniversalIdentifiers: [], calendarViewUniversalIdentifiers: [], mainGroupByFieldMetadataViewUniversalIdentifiers: [], + fieldPermissionUniversalIdentifiers: [], viewSortIds: [], viewSortUniversalIdentifiers: [], universalSettings: null, diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/__tests__/workspace.repository.spec.ts b/packages/twenty-server/src/engine/twenty-orm/repository/__tests__/workspace.repository.spec.ts index 3d47b56db7d..d95b532f991 100644 --- a/packages/twenty-server/src/engine/twenty-orm/repository/__tests__/workspace.repository.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/repository/__tests__/workspace.repository.spec.ts @@ -93,6 +93,7 @@ describe('WorkspaceRepository', () => { relationTargetObjectMetadataId: null, calendarViewIds: [], viewFilterIds: [], + fieldPermissionIds: [], kanbanAggregateOperationViewIds: [], viewFieldIds: [], mainGroupByFieldMetadataViewIds: [], @@ -105,6 +106,7 @@ describe('WorkspaceRepository', () => { kanbanAggregateOperationViewUniversalIdentifiers: [], calendarViewUniversalIdentifiers: [], mainGroupByFieldMetadataViewUniversalIdentifiers: [], + fieldPermissionUniversalIdentifiers: [], viewSortIds: [], viewSortUniversalIdentifiers: [], universalSettings: null, diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/compute-relation-connect-query-configs.util.spec.ts b/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/compute-relation-connect-query-configs.util.spec.ts index 7561e229d18..50a21584482 100644 --- a/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/compute-relation-connect-query-configs.util.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/compute-relation-connect-query-configs.util.spec.ts @@ -225,6 +225,7 @@ describe('computeRelationConnectQueryConfigs', () => { isSearchable: true, universalIdentifier: partial.id, objectPermissionIds: [], + fieldPermissionIds: [], viewIds: [], applicationId: null, isLabelSyncedWithName: false, diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/format-twenty-orm-event-to-database-batch-event.util.spec.ts b/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/format-twenty-orm-event-to-database-batch-event.util.spec.ts index cddb2decbac..245cdf75692 100644 --- a/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/format-twenty-orm-event-to-database-batch-event.util.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/format-twenty-orm-event-to-database-batch-event.util.spec.ts @@ -74,6 +74,7 @@ describe('formatTwentyOrmEventToDatabaseBatchEvent', () => { fieldIds: ['name-id'], indexMetadataIds: [], objectPermissionIds: [], + fieldPermissionIds: [], viewIds: [], applicationId: null, } as unknown as FlatObjectMetadata; 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 index 0a83e904f7e..fdf7359432c 100644 --- 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 @@ -27,6 +27,7 @@ describe('getColumnNameToFieldMetadataIdMap', () => { universalIdentifier: 'test-object-id', indexMetadataIds: [], objectPermissionIds: [], + fieldPermissionIds: [], fieldIds, viewIds: [], applicationId: 'test-application-id', @@ -43,6 +44,7 @@ describe('getColumnNameToFieldMetadataIdMap', () => { applicationUniversalIdentifier: 'test-application-id', fieldUniversalIdentifiers: fieldIds, objectPermissionUniversalIdentifiers: [], + fieldPermissionUniversalIdentifiers: [], viewUniversalIdentifiers: [], indexMetadataUniversalIdentifiers: [], labelIdentifierFieldMetadataUniversalIdentifier: null, diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/get-field-metadata-id-to-column-names-map.util.spec.ts b/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/get-field-metadata-id-to-column-names-map.util.spec.ts index 18178f0365a..7ca0813ab39 100644 --- a/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/get-field-metadata-id-to-column-names-map.util.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/get-field-metadata-id-to-column-names-map.util.spec.ts @@ -27,6 +27,7 @@ describe('getFieldMetadataIdToColumnNamesMap', () => { universalIdentifier: 'test-object-id', indexMetadataIds: [], objectPermissionIds: [], + fieldPermissionIds: [], fieldIds, viewIds: [], applicationId: 'test-application-id', @@ -43,6 +44,7 @@ describe('getFieldMetadataIdToColumnNamesMap', () => { applicationUniversalIdentifier: 'test-application-id', fieldUniversalIdentifiers: fieldIds, objectPermissionUniversalIdentifiers: [], + fieldPermissionUniversalIdentifiers: [], viewUniversalIdentifiers: [], indexMetadataUniversalIdentifiers: [], labelIdentifierFieldMetadataUniversalIdentifier: null, diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/is-record-matching-rls-row-level-permission-predicate.util.spec.ts b/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/is-record-matching-rls-row-level-permission-predicate.util.spec.ts index 1c165bc3db5..d8acbd8138a 100644 --- a/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/is-record-matching-rls-row-level-permission-predicate.util.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/is-record-matching-rls-row-level-permission-predicate.util.spec.ts @@ -27,6 +27,7 @@ describe('isRecordMatchingRLSRowLevelPermissionPredicate', () => { universalIdentifier: 'test-object-id', indexMetadataIds: [], objectPermissionIds: [], + fieldPermissionIds: [], fieldIds, viewIds: [], applicationId: 'test-application-id', @@ -43,6 +44,7 @@ describe('isRecordMatchingRLSRowLevelPermissionPredicate', () => { applicationUniversalIdentifier: 'test-application-id', fieldUniversalIdentifiers: fieldIds, objectPermissionUniversalIdentifiers: [], + fieldPermissionUniversalIdentifiers: [], viewUniversalIdentifiers: [], indexMetadataUniversalIdentifiers: [], labelIdentifierFieldMetadataUniversalIdentifier: null, diff --git a/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts b/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts index c3457212197..cdc22a759a0 100644 --- a/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts +++ b/packages/twenty-server/src/engine/workspace-cache/types/workspace-cache-key.type.ts @@ -35,6 +35,7 @@ export const WORKSPACE_CACHE_KEYS_V2 = { flatRoleTargetMaps: 'flat-maps:role-target', flatPermissionFlagMaps: 'flat-maps:permission-flag', flatObjectPermissionMaps: 'flat-maps:object-permission', + flatFieldPermissionMaps: 'flat-maps:field-permission', ORMEntityMetadatas: 'orm:entity-metadatas', flatAgentMaps: 'flat-maps:agent', flatSkillMaps: 'flat-maps:skill', diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/create-standard-field-flat-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/create-standard-field-flat-metadata.util.ts index c2b12dabd97..c75391e5862 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/create-standard-field-flat-metadata.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/create-standard-field-flat-metadata.util.ts @@ -95,6 +95,7 @@ export const createStandardFieldFlatMetadata = < morphId: null, viewFieldIds: [], viewFilterIds: [], + fieldPermissionIds: [], kanbanAggregateOperationViewIds: [], calendarViewIds: [], mainGroupByFieldMetadataViewIds: [], @@ -107,6 +108,7 @@ export const createStandardFieldFlatMetadata = < relationTargetFieldMetadataUniversalIdentifier: null, viewFilterUniversalIdentifiers: [], viewFieldUniversalIdentifiers: [], + fieldPermissionUniversalIdentifiers: [], kanbanAggregateOperationViewUniversalIdentifiers: [], calendarViewUniversalIdentifiers: [], mainGroupByFieldMetadataViewUniversalIdentifiers: [], diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/create-standard-relation-field-flat-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/create-standard-relation-field-flat-metadata.util.ts index 3f71948a973..8df6c90cb5a 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/create-standard-relation-field-flat-metadata.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/field-metadata/create-standard-relation-field-flat-metadata.util.ts @@ -113,6 +113,7 @@ export const createStandardRelationFieldFlatMetadata = < morphId, viewFieldIds: [], viewFilterIds: [], + fieldPermissionIds: [], kanbanAggregateOperationViewIds: [], calendarViewIds: [], mainGroupByFieldMetadataViewIds: [], @@ -127,6 +128,7 @@ export const createStandardRelationFieldFlatMetadata = < targetFieldDefinition.universalIdentifier, viewFilterUniversalIdentifiers: [], viewFieldUniversalIdentifiers: [], + fieldPermissionUniversalIdentifiers: [], kanbanAggregateOperationViewUniversalIdentifiers: [], calendarViewUniversalIdentifiers: [], mainGroupByFieldMetadataViewUniversalIdentifiers: [], diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-object-flat-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-object-flat-metadata.util.ts index d0345e79952..680212639a8 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-object-flat-metadata.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/object-metadata/create-standard-object-flat-metadata.util.ts @@ -96,6 +96,7 @@ export const createStandardObjectFlatMetadata = < fieldIds: [], indexMetadataIds: [], objectPermissionIds: [], + fieldPermissionIds: [], viewIds: [], createdAt: now, updatedAt: now, @@ -103,6 +104,7 @@ export const createStandardObjectFlatMetadata = < applicationUniversalIdentifier: twentyStandardApplicationId, fieldUniversalIdentifiers: [], objectPermissionUniversalIdentifiers: [], + fieldPermissionUniversalIdentifiers: [], viewUniversalIdentifiers: [], indexMetadataUniversalIdentifiers: [], labelIdentifierFieldMetadataUniversalIdentifier, diff --git a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/role-metadata/create-standard-role-flat-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/role-metadata/create-standard-role-flat-metadata.util.ts index c1e0f0eaeac..26d3c04405e 100644 --- a/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/role-metadata/create-standard-role-flat-metadata.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/twenty-standard-application/utils/role-metadata/create-standard-role-flat-metadata.util.ts @@ -76,6 +76,7 @@ export const createStandardRoleFlatMetadata = ({ permissionFlagUniversalIdentifiers: [], objectPermissionUniversalIdentifiers: [], fieldPermissionIds: [], + fieldPermissionUniversalIdentifiers: [], objectPermissionIds: [], roleTargetIds: [], roleTargetUniversalIdentifiers: [], diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/utils/enrich-create-workspace-migration-action-with-ids.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/utils/enrich-create-workspace-migration-action-with-ids.util.ts index 69649bb1766..36ce23c76aa 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/utils/enrich-create-workspace-migration-action-with-ids.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/utils/enrich-create-workspace-migration-action-with-ids.util.ts @@ -169,6 +169,7 @@ export const enrichCreateWorkspaceMigrationActionsWithIds = ({ case 'viewSort': case 'permissionFlag': case 'objectPermission': + case 'fieldPermission': case 'webhook': { if (!isDefined(idByUniversalIdentifier)) { return action; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts index fa7ed47963c..01b97a4a997 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/services/workspace-migration-build-orchestrator.service.ts @@ -15,6 +15,7 @@ import { aggregateOrchestratorActionsReport } from 'src/engine/workspace-manager import { crossEntityTransversalValidation } from 'src/engine/workspace-manager/workspace-migration/utils/cross-entity-transversal-validation.util'; import { WorkspaceMigrationAgentActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/agent/workspace-migration-agent-actions-builder.service'; import { WorkspaceMigrationCommandMenuItemActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/command-menu-item/workspace-migration-command-menu-item-actions-builder.service'; +import { WorkspaceMigrationFieldPermissionActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field-permission/workspace-migration-field-permission-actions-builder.service'; import { WorkspaceMigrationFieldActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field/workspace-migration-field-actions-builder.service'; import { WorkspaceMigrationFrontComponentActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/front-component/workspace-migration-front-component-actions-builder.service'; import { WorkspaceMigrationIndexActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/index/workspace-migration-index-actions-builder.service'; @@ -52,6 +53,7 @@ export class WorkspaceMigrationBuildOrchestratorService { private readonly workspaceMigrationViewGroupActionsBuilderService: WorkspaceMigrationViewGroupActionsBuilderService, private readonly workspaceMigrationViewFieldGroupActionsBuilderService: WorkspaceMigrationViewFieldGroupActionsBuilderService, private readonly workspaceMigrationViewSortActionsBuilderService: WorkspaceMigrationViewSortActionsBuilderService, + private readonly workspaceMigrationFieldPermissionActionsBuilderService: WorkspaceMigrationFieldPermissionActionsBuilderService, private readonly workspaceMigrationObjectPermissionActionsBuilderService: WorkspaceMigrationObjectPermissionActionsBuilderService, private readonly workspaceMigrationPermissionFlagActionsBuilderService: WorkspaceMigrationPermissionFlagActionsBuilderService, private readonly workspaceMigrationLogicFunctionActionsBuilderService: WorkspaceMigrationLogicFunctionActionsBuilderService, @@ -145,6 +147,7 @@ export class WorkspaceMigrationBuildOrchestratorService { flatRowLevelPermissionPredicateGroupMaps, flatRoleMaps, flatObjectPermissionMaps, + flatFieldPermissionMaps, flatPermissionFlagMaps, flatRoleTargetMaps, flatAgentMaps, @@ -515,6 +518,34 @@ export class WorkspaceMigrationBuildOrchestratorService { } } + if (isDefined(flatFieldPermissionMaps)) { + const { + from: fromFlatFieldPermissionMaps, + to: toFlatFieldPermissionMaps, + } = flatFieldPermissionMaps; + + const fieldPermissionResult = + await this.workspaceMigrationFieldPermissionActionsBuilderService.validateAndBuild( + { + additionalCacheDataMaps, + from: fromFlatFieldPermissionMaps, + to: toFlatFieldPermissionMaps, + buildOptions, + dependencyOptimisticFlatEntityMaps: optimisticAllFlatEntityMaps, + workspaceId, + }, + ); + + if (fieldPermissionResult.status === 'fail') { + orchestratorFailureReport.fieldPermission.push( + ...fieldPermissionResult.errors, + ); + } else { + orchestratorActionsReport.fieldPermission = + fieldPermissionResult.actions; + } + } + if (isDefined(flatPermissionFlagMaps)) { const { from: fromFlatPermissionFlagMaps, to: toFlatPermissionFlagMaps } = flatPermissionFlagMaps; @@ -876,6 +907,12 @@ export class WorkspaceMigrationBuildOrchestratorService { ...aggregatedOrchestratorActionsReport.objectPermission.update, /// + // Field permissions + ...aggregatedOrchestratorActionsReport.fieldPermission.delete, + ...aggregatedOrchestratorActionsReport.fieldPermission.create, + ...aggregatedOrchestratorActionsReport.fieldPermission.update, + /// + // Permission flags ...aggregatedOrchestratorActionsReport.permissionFlag.delete, ...aggregatedOrchestratorActionsReport.permissionFlag.create, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/constants/__tests__/__snapshots__/all-universal-flat-entity-foreign-key-aggregator-properties.constant.spec.ts.snap b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/constants/__tests__/__snapshots__/all-universal-flat-entity-foreign-key-aggregator-properties.constant.spec.ts.snap index 4c4a4142c6f..bd4961cf000 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/constants/__tests__/__snapshots__/all-universal-flat-entity-foreign-key-aggregator-properties.constant.spec.ts.snap +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/constants/__tests__/__snapshots__/all-universal-flat-entity-foreign-key-aggregator-properties.constant.spec.ts.snap @@ -1,4 +1,4 @@ -// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing +// Jest Snapshot v1, https://goo.gl/fbAQLP exports[`ALL_UNIVERSAL_FLAT_ENTITY_FOREIGN_KEY_AGGREGATOR_PROPERTIES should match snapshot 1`] = ` { @@ -6,12 +6,14 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_FOREIGN_KEY_AGGREGATOR_PROPERTIES should matc "commandMenuItem": [], "fieldMetadata": [ "viewSortUniversalIdentifiers", + "fieldPermissionUniversalIdentifiers", "viewFieldUniversalIdentifiers", "viewFilterUniversalIdentifiers", "kanbanAggregateOperationViewUniversalIdentifiers", "calendarViewUniversalIdentifiers", "mainGroupByFieldMetadataViewUniversalIdentifiers", ], + "fieldPermission": [], "frontComponent": [], "index": [], "logicFunction": [], @@ -20,6 +22,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_FOREIGN_KEY_AGGREGATOR_PROPERTIES should matc "fieldUniversalIdentifiers", "indexMetadataUniversalIdentifiers", "objectPermissionUniversalIdentifiers", + "fieldPermissionUniversalIdentifiers", "viewUniversalIdentifiers", ], "objectPermission": [], @@ -35,6 +38,7 @@ exports[`ALL_UNIVERSAL_FLAT_ENTITY_FOREIGN_KEY_AGGREGATOR_PROPERTIES should matc "roleTargetUniversalIdentifiers", "objectPermissionUniversalIdentifiers", "permissionFlagUniversalIdentifiers", + "fieldPermissionUniversalIdentifiers", "rowLevelPermissionPredicateUniversalIdentifiers", "rowLevelPermissionPredicateGroupUniversalIdentifiers", ], diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/constants/all-jsonb-properties-with-serialized-relation-by-metadata-name.constant.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/constants/all-jsonb-properties-with-serialized-relation-by-metadata-name.constant.ts index b7d0827895d..cd977044103 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/constants/all-jsonb-properties-with-serialized-relation-by-metadata-name.constant.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/constants/all-jsonb-properties-with-serialized-relation-by-metadata-name.constant.ts @@ -39,6 +39,7 @@ export const ALL_JSONB_PROPERTIES_WITH_SERIALIZED_RELATION_BY_METADATA_NAME = { navigationMenuItem: {}, permissionFlag: {}, objectPermission: {}, + fieldPermission: {}, frontComponent: {}, } as const satisfies { [P in AllMetadataName]: Partial<{ diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-permission.type.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-permission.type.ts new file mode 100644 index 00000000000..3a0999b5710 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-permission.type.ts @@ -0,0 +1,7 @@ +import { type FieldPermissionEntity } from 'src/engine/metadata-modules/object-permission/field-permission/field-permission.entity'; +import { type UniversalFlatEntityFrom } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-entity-from.type'; + +export type UniversalFlatFieldPermission = UniversalFlatEntityFrom< + FieldPermissionEntity, + 'fieldPermission' +>; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/utils/__tests__/__snapshots__/universal-flat-entity-deleted-created-updated-matrix-dispatcher.util.spec.ts.snap b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/utils/__tests__/__snapshots__/universal-flat-entity-deleted-created-updated-matrix-dispatcher.util.spec.ts.snap index b21014090b2..a0414b61c73 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/utils/__tests__/__snapshots__/universal-flat-entity-deleted-created-updated-matrix-dispatcher.util.spec.ts.snap +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/universal-flat-entity/utils/__tests__/__snapshots__/universal-flat-entity-deleted-created-updated-matrix-dispatcher.util.spec.ts.snap @@ -12,6 +12,8 @@ exports[`flatEntityDeletedCreatedUpdatedMatrixDispatcher It should detect a crea "createdAt": "2024-01-01T00:00:00.000Z", "defaultValue": null, "description": "default flat field metadata description", + "fieldPermissionIds": [], + "fieldPermissionUniversalIdentifiers": [], "icon": "icon", "id": "field-id-1", "isActive": true, @@ -75,6 +77,8 @@ exports[`flatEntityDeletedCreatedUpdatedMatrixDispatcher It should detect a dele "createdAt": "2024-01-01T00:00:00.000Z", "defaultValue": null, "description": "default flat field metadata description", + "fieldPermissionIds": [], + "fieldPermissionUniversalIdentifiers": [], "icon": "icon", "id": "field-id-1", "isActive": true, @@ -152,6 +156,8 @@ exports[`flatEntityDeletedCreatedUpdatedMatrixDispatcher It should detect create "createdAt": "2024-01-01T00:00:00.000Z", "defaultValue": null, "description": "default flat field metadata description", + "fieldPermissionIds": [], + "fieldPermissionUniversalIdentifiers": [], "icon": "icon", "id": "field-id-3", "isActive": true, @@ -201,6 +207,8 @@ exports[`flatEntityDeletedCreatedUpdatedMatrixDispatcher It should detect create "createdAt": "2024-01-01T00:00:00.000Z", "defaultValue": null, "description": "default flat field metadata description", + "fieldPermissionIds": [], + "fieldPermissionUniversalIdentifiers": [], "icon": "icon", "id": "field-id-2", "isActive": true, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field-permission/types/workspace-migration-field-permission-action.type.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field-permission/types/workspace-migration-field-permission-action.type.ts new file mode 100644 index 00000000000..86ab40326d0 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field-permission/types/workspace-migration-field-permission-action.type.ts @@ -0,0 +1,24 @@ +import { type BaseFlatCreateWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-flat-create-workspace-migration-action.type'; +import { type BaseFlatDeleteWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-flat-delete-workspace-migration-action.type'; +import { type BaseFlatUpdateWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-flat-update-workspace-migration-action.type'; +import { type BaseUniversalCreateWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-universal-create-workspace-migration-action.type'; +import { type BaseUniversalDeleteWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-universal-delete-workspace-migration-action.type'; +import { type BaseUniversalUpdateWorkspaceMigrationAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/base-universal-update-workspace-migration-action.type'; + +export type FlatCreateFieldPermissionAction = + BaseFlatCreateWorkspaceMigrationAction<'fieldPermission'>; + +export type FlatUpdateFieldPermissionAction = + BaseFlatUpdateWorkspaceMigrationAction<'fieldPermission'>; + +export type FlatDeleteFieldPermissionAction = + BaseFlatDeleteWorkspaceMigrationAction<'fieldPermission'>; + +export type UniversalCreateFieldPermissionAction = + BaseUniversalCreateWorkspaceMigrationAction<'fieldPermission'>; + +export type UniversalUpdateFieldPermissionAction = + BaseUniversalUpdateWorkspaceMigrationAction<'fieldPermission'>; + +export type UniversalDeleteFieldPermissionAction = + BaseUniversalDeleteWorkspaceMigrationAction<'fieldPermission'>; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field-permission/workspace-migration-field-permission-actions-builder.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field-permission/workspace-migration-field-permission-actions-builder.service.ts new file mode 100644 index 00000000000..3834edfc853 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field-permission/workspace-migration-field-permission-actions-builder.service.ts @@ -0,0 +1,120 @@ +import { Injectable } from '@nestjs/common'; + +import { ALL_METADATA_NAME } from 'twenty-shared/metadata'; + +import { WorkspaceEntityMigrationBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/services/workspace-entity-migration-builder.service'; +import { UniversalUpdateFieldPermissionAction } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field-permission/types/workspace-migration-field-permission-action.type'; +import { FlatFieldPermissionValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-field-permission-validator.service'; +import { UniversalFlatEntityValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/universal-flat-entity-validation-args.type'; +import { UniversalFlatEntityValidationReturnType } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/universal-flat-entity-validation-result.type'; +import { FlatEntityUpdateValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/universal-flat-entity-update-validation-args.type'; + +@Injectable() +export class WorkspaceMigrationFieldPermissionActionsBuilderService extends WorkspaceEntityMigrationBuilderService< + typeof ALL_METADATA_NAME.fieldPermission +> { + constructor( + private readonly flatFieldPermissionValidatorService: FlatFieldPermissionValidatorService, + ) { + super(ALL_METADATA_NAME.fieldPermission); + } + + protected validateFlatEntityCreation( + args: UniversalFlatEntityValidationArgs< + typeof ALL_METADATA_NAME.fieldPermission + >, + ): UniversalFlatEntityValidationReturnType< + typeof ALL_METADATA_NAME.fieldPermission, + 'create' + > { + const validationResult = + this.flatFieldPermissionValidatorService.validateFlatFieldPermissionCreation( + args, + ); + + if (validationResult.errors.length > 0) { + return { + status: 'fail', + ...validationResult, + }; + } + + const { flatEntityToValidate: flatFieldPermissionToValidate } = args; + + return { + status: 'success', + action: { + type: 'create', + metadataName: 'fieldPermission', + flatEntity: flatFieldPermissionToValidate, + }, + }; + } + + protected validateFlatEntityDeletion( + args: UniversalFlatEntityValidationArgs< + typeof ALL_METADATA_NAME.fieldPermission + >, + ): UniversalFlatEntityValidationReturnType< + typeof ALL_METADATA_NAME.fieldPermission, + 'delete' + > { + const validationResult = + this.flatFieldPermissionValidatorService.validateFlatFieldPermissionDeletion( + args, + ); + + if (validationResult.errors.length > 0) { + return { + status: 'fail', + ...validationResult, + }; + } + + const { flatEntityToValidate: flatFieldPermissionToValidate } = args; + + return { + status: 'success', + action: { + type: 'delete', + metadataName: 'fieldPermission', + universalIdentifier: flatFieldPermissionToValidate.universalIdentifier, + }, + }; + } + + protected validateFlatEntityUpdate( + args: FlatEntityUpdateValidationArgs< + typeof ALL_METADATA_NAME.fieldPermission + >, + ): UniversalFlatEntityValidationReturnType< + typeof ALL_METADATA_NAME.fieldPermission, + 'update' + > { + const validationResult = + this.flatFieldPermissionValidatorService.validateFlatFieldPermissionUpdate( + args, + ); + + if (validationResult.errors.length > 0) { + return { + status: 'fail', + ...validationResult, + }; + } + + const { universalIdentifier, flatEntityUpdate } = args; + + const updateFieldPermissionAction: UniversalUpdateFieldPermissionAction = { + type: 'update', + metadataName: 'fieldPermission', + universalIdentifier, + update: flatEntityUpdate, + }; + + return { + status: 'success', + action: updateFieldPermissionAction, + }; + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-field-permission-validator.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-field-permission-validator.service.ts new file mode 100644 index 00000000000..36299f920f0 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-field-permission-validator.service.ts @@ -0,0 +1,290 @@ +import { Injectable } from '@nestjs/common'; + +import { msg, t } from '@lingui/core/macro'; +import { ALL_METADATA_NAME } from 'twenty-shared/metadata'; +import { isDefined } from 'twenty-shared/utils'; + +import { findFlatEntityByUniversalIdentifier } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier.util'; +import { PermissionsExceptionCode } from 'src/engine/metadata-modules/permissions/permissions.exception'; +import { FailedFlatEntityValidation } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/types/failed-flat-entity-validation.type'; +import { getEmptyFlatEntityValidationError } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/utils/get-flat-entity-validation-error.util'; +import { FlatEntityUpdateValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/universal-flat-entity-update-validation-args.type'; +import { UniversalFlatEntityValidationArgs } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/types/universal-flat-entity-validation-args.type'; + +@Injectable() +export class FlatFieldPermissionValidatorService { + validateFlatFieldPermissionCreation({ + flatEntityToValidate: flatFieldPermissionToValidate, + optimisticFlatEntityMapsAndRelatedFlatEntityMaps: { + flatFieldPermissionMaps: optimisticFlatFieldPermissionMaps, + flatRoleMaps, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + }, + }: UniversalFlatEntityValidationArgs< + typeof ALL_METADATA_NAME.fieldPermission + >): FailedFlatEntityValidation<'fieldPermission', 'create'> { + const validationResult = getEmptyFlatEntityValidationError({ + flatEntityMinimalInformation: { + universalIdentifier: flatFieldPermissionToValidate.universalIdentifier, + roleUniversalIdentifier: + flatFieldPermissionToValidate.roleUniversalIdentifier, + objectMetadataUniversalIdentifier: + flatFieldPermissionToValidate.objectMetadataUniversalIdentifier, + fieldMetadataUniversalIdentifier: + flatFieldPermissionToValidate.fieldMetadataUniversalIdentifier, + }, + metadataName: 'fieldPermission', + type: 'create', + }); + + const existingByUniversalId = findFlatEntityByUniversalIdentifier({ + universalIdentifier: flatFieldPermissionToValidate.universalIdentifier, + flatEntityMaps: optimisticFlatFieldPermissionMaps, + }); + + if (isDefined(existingByUniversalId)) { + validationResult.errors.push({ + code: PermissionsExceptionCode.INVALID_SETTING, + message: t`Field permission with universal identifier ${flatFieldPermissionToValidate.universalIdentifier} already exists`, + userFriendlyMessage: msg`Field permission already exists`, + }); + } + + const referencedRole = findFlatEntityByUniversalIdentifier({ + universalIdentifier: + flatFieldPermissionToValidate.roleUniversalIdentifier, + flatEntityMaps: flatRoleMaps, + }); + + if (!isDefined(referencedRole)) { + validationResult.errors.push({ + code: PermissionsExceptionCode.ROLE_NOT_FOUND, + message: t`Role not found`, + userFriendlyMessage: msg`Role not found`, + }); + } else if (!referencedRole.isEditable) { + validationResult.errors.push({ + code: PermissionsExceptionCode.ROLE_NOT_EDITABLE, + message: t`Role is not editable`, + userFriendlyMessage: msg`This role cannot be modified because it is a system role. Only custom roles can be edited.`, + }); + } + + const referencedObjectMetadata = findFlatEntityByUniversalIdentifier({ + universalIdentifier: + flatFieldPermissionToValidate.objectMetadataUniversalIdentifier, + flatEntityMaps: flatObjectMetadataMaps, + }); + + if (!isDefined(referencedObjectMetadata)) { + validationResult.errors.push({ + code: PermissionsExceptionCode.OBJECT_METADATA_NOT_FOUND, + message: t`Object metadata not found`, + userFriendlyMessage: msg`Object metadata not found`, + }); + } + + const referencedFieldMetadata = findFlatEntityByUniversalIdentifier({ + universalIdentifier: + flatFieldPermissionToValidate.fieldMetadataUniversalIdentifier, + flatEntityMaps: flatFieldMetadataMaps, + }); + + if (!isDefined(referencedFieldMetadata)) { + validationResult.errors.push({ + code: PermissionsExceptionCode.FIELD_METADATA_NOT_FOUND, + message: t`Field metadata not found`, + userFriendlyMessage: msg`Field metadata not found`, + }); + } + + const duplicateForSameRoleObjectField = Object.values( + optimisticFlatFieldPermissionMaps.byUniversalIdentifier, + ).filter( + (fp) => + isDefined(fp) && + fp.roleUniversalIdentifier === + flatFieldPermissionToValidate.roleUniversalIdentifier && + fp.objectMetadataUniversalIdentifier === + flatFieldPermissionToValidate.objectMetadataUniversalIdentifier && + fp.fieldMetadataUniversalIdentifier === + flatFieldPermissionToValidate.fieldMetadataUniversalIdentifier && + fp.universalIdentifier !== + flatFieldPermissionToValidate.universalIdentifier, + ); + + if (duplicateForSameRoleObjectField.length > 0) { + validationResult.errors.push({ + code: PermissionsExceptionCode.INVALID_SETTING, + message: t`Field permission for this role, object and field already exists`, + userFriendlyMessage: msg`This field permission is already set for the role`, + }); + } + + return validationResult; + } + + validateFlatFieldPermissionUpdate({ + universalIdentifier, + flatEntityUpdate, + optimisticFlatEntityMapsAndRelatedFlatEntityMaps: { + flatFieldPermissionMaps: optimisticFlatFieldPermissionMaps, + flatRoleMaps, + flatObjectMetadataMaps, + flatFieldMetadataMaps, + }, + }: FlatEntityUpdateValidationArgs< + typeof ALL_METADATA_NAME.fieldPermission + >): FailedFlatEntityValidation<'fieldPermission', 'update'> { + const existingFlatFieldPermission = findFlatEntityByUniversalIdentifier({ + universalIdentifier, + flatEntityMaps: optimisticFlatFieldPermissionMaps, + }); + + const validationResult = getEmptyFlatEntityValidationError({ + flatEntityMinimalInformation: { + universalIdentifier, + }, + metadataName: 'fieldPermission', + type: 'update', + }); + + if (!isDefined(existingFlatFieldPermission)) { + validationResult.errors.push({ + code: PermissionsExceptionCode.FIELD_PERMISSION_NOT_FOUND, + message: t`Field permission to update not found`, + userFriendlyMessage: msg`Field permission not found`, + }); + + return validationResult; + } + + const updatedFlatFieldPermission = { + ...existingFlatFieldPermission, + ...flatEntityUpdate, + }; + + const referencedRole = findFlatEntityByUniversalIdentifier({ + universalIdentifier: updatedFlatFieldPermission.roleUniversalIdentifier, + flatEntityMaps: flatRoleMaps, + }); + + if (!isDefined(referencedRole)) { + validationResult.errors.push({ + code: PermissionsExceptionCode.ROLE_NOT_FOUND, + message: t`Role not found`, + userFriendlyMessage: msg`Role not found`, + }); + } else if (!referencedRole.isEditable) { + validationResult.errors.push({ + code: PermissionsExceptionCode.ROLE_NOT_EDITABLE, + message: t`Role is not editable`, + userFriendlyMessage: msg`This role cannot be modified because it is a system role. Only custom roles can be edited.`, + }); + } + + if (isDefined(flatEntityUpdate.objectMetadataUniversalIdentifier)) { + const referencedObjectMetadata = findFlatEntityByUniversalIdentifier({ + universalIdentifier: + updatedFlatFieldPermission.objectMetadataUniversalIdentifier, + flatEntityMaps: flatObjectMetadataMaps, + }); + + if (!isDefined(referencedObjectMetadata)) { + validationResult.errors.push({ + code: PermissionsExceptionCode.OBJECT_METADATA_NOT_FOUND, + message: t`Object metadata not found`, + userFriendlyMessage: msg`Object metadata not found`, + }); + } + } + + if (isDefined(flatEntityUpdate.fieldMetadataUniversalIdentifier)) { + const referencedFieldMetadata = findFlatEntityByUniversalIdentifier({ + universalIdentifier: + updatedFlatFieldPermission.fieldMetadataUniversalIdentifier, + flatEntityMaps: flatFieldMetadataMaps, + }); + + if (!isDefined(referencedFieldMetadata)) { + validationResult.errors.push({ + code: PermissionsExceptionCode.FIELD_METADATA_NOT_FOUND, + message: t`Field metadata not found`, + userFriendlyMessage: msg`Field metadata not found`, + }); + } + } + + const duplicateForSameRoleObjectField = Object.values( + optimisticFlatFieldPermissionMaps.byUniversalIdentifier, + ).filter( + (fp) => + isDefined(fp) && + fp.roleUniversalIdentifier === + updatedFlatFieldPermission.roleUniversalIdentifier && + fp.objectMetadataUniversalIdentifier === + updatedFlatFieldPermission.objectMetadataUniversalIdentifier && + fp.fieldMetadataUniversalIdentifier === + updatedFlatFieldPermission.fieldMetadataUniversalIdentifier && + fp.universalIdentifier !== universalIdentifier, + ); + + if (duplicateForSameRoleObjectField.length > 0) { + validationResult.errors.push({ + code: PermissionsExceptionCode.INVALID_SETTING, + message: t`Field permission for this role, object and field already exists`, + userFriendlyMessage: msg`This field permission is already set for the role`, + }); + } + + return validationResult; + } + + validateFlatFieldPermissionDeletion({ + flatEntityToValidate: { universalIdentifier }, + optimisticFlatEntityMapsAndRelatedFlatEntityMaps: { + flatFieldPermissionMaps: optimisticFlatFieldPermissionMaps, + flatRoleMaps, + }, + }: UniversalFlatEntityValidationArgs< + typeof ALL_METADATA_NAME.fieldPermission + >): FailedFlatEntityValidation<'fieldPermission', 'delete'> { + const validationResult = getEmptyFlatEntityValidationError({ + flatEntityMinimalInformation: { + universalIdentifier, + }, + metadataName: 'fieldPermission', + type: 'delete', + }); + + const existingFlatFieldPermission = findFlatEntityByUniversalIdentifier({ + universalIdentifier, + flatEntityMaps: optimisticFlatFieldPermissionMaps, + }); + + if (!isDefined(existingFlatFieldPermission)) { + validationResult.errors.push({ + code: PermissionsExceptionCode.FIELD_PERMISSION_NOT_FOUND, + message: t`Field permission to delete not found`, + userFriendlyMessage: msg`Field permission not found`, + }); + } else { + const referencedRole = findFlatEntityByUniversalIdentifier({ + universalIdentifier: + existingFlatFieldPermission.roleUniversalIdentifier, + flatEntityMaps: flatRoleMaps, + }); + + if (isDefined(referencedRole) && !referencedRole.isEditable) { + validationResult.errors.push({ + code: PermissionsExceptionCode.ROLE_NOT_EDITABLE, + message: t`Role is not editable`, + userFriendlyMessage: msg`This role cannot be modified because it is a system role. Only custom roles can be edited.`, + }); + } + } + + return validationResult; + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/workspace-migration-builder-validators.module.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/workspace-migration-builder-validators.module.ts index 411ca97a16b..9e0301f30fc 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/workspace-migration-builder-validators.module.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/workspace-migration-builder-validators.module.ts @@ -6,6 +6,7 @@ import { FlatPageLayoutWidgetTypeValidatorService } from 'src/engine/metadata-mo import { FlatAgentValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-agent-validator.service'; import { FlatCommandMenuItemValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-command-menu-item-validator.service'; import { FlatFieldMetadataValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-field-metadata-validator.service'; +import { FlatFieldPermissionValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-field-permission-validator.service'; import { FlatFrontComponentValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-front-component-validator.service'; import { FlatIndexValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-index-metadata-validator.service'; import { FlatLogicFunctionValidatorService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/validators/services/flat-logic-function-validator.service'; @@ -40,6 +41,7 @@ import { FlatWebhookValidatorService } from 'src/engine/workspace-manager/worksp FlatViewGroupValidatorService, FlatViewFieldGroupValidatorService, FlatViewSortValidatorService, + FlatFieldPermissionValidatorService, FlatObjectPermissionValidatorService, FlatPermissionFlagValidatorService, FlatIndexValidatorService, @@ -70,6 +72,7 @@ import { FlatWebhookValidatorService } from 'src/engine/workspace-manager/worksp FlatViewGroupValidatorService, FlatViewFieldGroupValidatorService, FlatViewSortValidatorService, + FlatFieldPermissionValidatorService, FlatObjectPermissionValidatorService, FlatPermissionFlagValidatorService, FlatIndexValidatorService, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/workspace-migration-builder.module.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/workspace-migration-builder.module.ts index 692256e5336..493f5a2feaa 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/workspace-migration-builder.module.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-builder/workspace-migration-builder.module.ts @@ -4,6 +4,7 @@ import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature- import { FlatFieldMetadataTypeValidatorService } from 'src/engine/metadata-modules/flat-field-metadata/services/flat-field-metadata-type-validator.service'; import { WorkspaceMigrationAgentActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/agent/workspace-migration-agent-actions-builder.service'; import { WorkspaceMigrationCommandMenuItemActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/command-menu-item/workspace-migration-command-menu-item-actions-builder.service'; +import { WorkspaceMigrationFieldPermissionActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field-permission/workspace-migration-field-permission-actions-builder.service'; import { WorkspaceMigrationFieldActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field/workspace-migration-field-actions-builder.service'; import { WorkspaceMigrationFrontComponentActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/front-component/workspace-migration-front-component-actions-builder.service'; import { WorkspaceMigrationIndexActionsBuilderService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/index/workspace-migration-index-actions-builder.service'; @@ -42,6 +43,7 @@ import { WorkspaceMigrationBuilderValidatorsModule } from 'src/engine/workspace- WorkspaceMigrationViewGroupActionsBuilderService, WorkspaceMigrationViewFieldGroupActionsBuilderService, WorkspaceMigrationViewSortActionsBuilderService, + WorkspaceMigrationFieldPermissionActionsBuilderService, WorkspaceMigrationObjectPermissionActionsBuilderService, WorkspaceMigrationPermissionFlagActionsBuilderService, WorkspaceMigrationIndexActionsBuilderService, @@ -71,6 +73,7 @@ import { WorkspaceMigrationBuilderValidatorsModule } from 'src/engine/workspace- WorkspaceMigrationViewGroupActionsBuilderService, WorkspaceMigrationViewFieldGroupActionsBuilderService, WorkspaceMigrationViewSortActionsBuilderService, + WorkspaceMigrationFieldPermissionActionsBuilderService, WorkspaceMigrationObjectPermissionActionsBuilderService, WorkspaceMigrationPermissionFlagActionsBuilderService, WorkspaceMigrationFieldActionsBuilderService, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/field-permission/services/create-field-permission-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/field-permission/services/create-field-permission-action-handler.service.ts new file mode 100644 index 00000000000..8dbfc51c30a --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/field-permission/services/create-field-permission-action-handler.service.ts @@ -0,0 +1,65 @@ +import { Injectable } from '@nestjs/common'; + +import { v4 } from 'uuid'; + +import { + FlatCreateFieldPermissionAction, + UniversalCreateFieldPermissionAction, +} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field-permission/types/workspace-migration-field-permission-action.type'; +import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface'; +import { + WorkspaceMigrationActionRunnerArgs, + WorkspaceMigrationActionRunnerContext, +} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/workspace-migration-action-runner-args.type'; +import { resolveUniversalRelationIdentifiersToIds } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/utils/resolve-universal-relation-identifiers-to-ids.util'; + +@Injectable() +export class CreateFieldPermissionActionHandlerService extends WorkspaceMigrationRunnerActionHandler( + 'create', + 'fieldPermission', +) { + override async transpileUniversalActionToFlatAction({ + action, + allFlatEntityMaps, + flatApplication, + workspaceId, + }: WorkspaceMigrationActionRunnerArgs): Promise { + const { roleId, objectMetadataId, fieldMetadataId } = + resolveUniversalRelationIdentifiersToIds({ + flatEntityMaps: allFlatEntityMaps, + metadataName: action.metadataName, + universalForeignKeyValues: action.flatEntity, + }); + + return { + ...action, + flatEntity: { + ...action.flatEntity, + roleId, + objectMetadataId, + fieldMetadataId, + applicationId: flatApplication.id, + id: action.id ?? v4(), + workspaceId, + }, + }; + } + + async executeForMetadata( + context: WorkspaceMigrationActionRunnerContext, + ): Promise { + const { flatAction, queryRunner } = context; + const { flatEntity } = flatAction; + + await this.insertFlatEntitiesInRepository({ + queryRunner, + flatEntities: [flatEntity], + }); + } + + async executeForWorkspaceSchema( + _context: WorkspaceMigrationActionRunnerContext, + ): Promise { + return; + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/field-permission/services/delete-field-permission-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/field-permission/services/delete-field-permission-action-handler.service.ts new file mode 100644 index 00000000000..eb738440026 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/field-permission/services/delete-field-permission-action-handler.service.ts @@ -0,0 +1,46 @@ +import { Injectable } from '@nestjs/common'; + +import { FieldPermissionEntity } from 'src/engine/metadata-modules/object-permission/field-permission/field-permission.entity'; +import { + FlatDeleteFieldPermissionAction, + UniversalDeleteFieldPermissionAction, +} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field-permission/types/workspace-migration-field-permission-action.type'; +import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface'; +import { + WorkspaceMigrationActionRunnerArgs, + WorkspaceMigrationActionRunnerContext, +} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/workspace-migration-action-runner-args.type'; + +@Injectable() +export class DeleteFieldPermissionActionHandlerService extends WorkspaceMigrationRunnerActionHandler( + 'delete', + 'fieldPermission', +) { + override async transpileUniversalActionToFlatAction( + context: WorkspaceMigrationActionRunnerArgs, + ): Promise { + return this.transpileUniversalDeleteActionToFlatDeleteAction(context); + } + + async executeForMetadata( + context: WorkspaceMigrationActionRunnerContext, + ): Promise { + const { flatAction, queryRunner, workspaceId } = context; + + const fieldPermissionRepository = + queryRunner.manager.getRepository( + FieldPermissionEntity, + ); + + await fieldPermissionRepository.delete({ + id: flatAction.entityId, + workspaceId, + }); + } + + async executeForWorkspaceSchema( + _context: WorkspaceMigrationActionRunnerContext, + ): Promise { + return; + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/field-permission/services/update-field-permission-action-handler.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/field-permission/services/update-field-permission-action-handler.service.ts new file mode 100644 index 00000000000..8fd0c203e38 --- /dev/null +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/field-permission/services/update-field-permission-action-handler.service.ts @@ -0,0 +1,67 @@ +import { Injectable } from '@nestjs/common'; + +import { findFlatEntityByUniversalIdentifierOrThrow } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-universal-identifier-or-throw.util'; +import { FieldPermissionEntity } from 'src/engine/metadata-modules/object-permission/field-permission/field-permission.entity'; +import { + FlatUpdateFieldPermissionAction, + UniversalUpdateFieldPermissionAction, +} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-builder/builders/field-permission/types/workspace-migration-field-permission-action.type'; +import { WorkspaceMigrationRunnerActionHandler } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/interfaces/workspace-migration-runner-action-handler-service.interface'; +import { + WorkspaceMigrationActionRunnerArgs, + WorkspaceMigrationActionRunnerContext, +} from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/types/workspace-migration-action-runner-args.type'; +import { resolveUniversalUpdateRelationIdentifiersToIds } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/utils/resolve-universal-update-relation-identifiers-to-ids.util'; + +@Injectable() +export class UpdateFieldPermissionActionHandlerService extends WorkspaceMigrationRunnerActionHandler( + 'update', + 'fieldPermission', +) { + override async transpileUniversalActionToFlatAction( + context: WorkspaceMigrationActionRunnerArgs, + ): Promise { + const { action, allFlatEntityMaps } = context; + + const flatFieldPermission = findFlatEntityByUniversalIdentifierOrThrow({ + flatEntityMaps: allFlatEntityMaps.flatFieldPermissionMaps, + universalIdentifier: action.universalIdentifier, + }); + + const update = resolveUniversalUpdateRelationIdentifiersToIds({ + metadataName: 'fieldPermission', + universalUpdate: action.update, + allFlatEntityMaps, + }); + + return { + type: 'update', + metadataName: 'fieldPermission', + entityId: flatFieldPermission.id, + update, + }; + } + + async executeForMetadata( + context: WorkspaceMigrationActionRunnerContext, + ): Promise { + const { flatAction, queryRunner, workspaceId } = context; + const { entityId, update } = flatAction; + + const fieldPermissionRepository = + queryRunner.manager.getRepository( + FieldPermissionEntity, + ); + + await fieldPermissionRepository.update( + { id: entityId, workspaceId }, + update, + ); + } + + async executeForWorkspaceSchema( + _context: WorkspaceMigrationActionRunnerContext, + ): Promise { + return; + } +} diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/field/services/utils/from-universal-flat-field-metadata-to-flat-field-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/field/services/utils/from-universal-flat-field-metadata-to-flat-field-metadata.util.ts index ee8558720de..f769258bb8b 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/field/services/utils/from-universal-flat-field-metadata-to-flat-field-metadata.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/field/services/utils/from-universal-flat-field-metadata-to-flat-field-metadata.util.ts @@ -130,6 +130,7 @@ export const fromUniversalFlatFieldMetadataToFlatFieldMetadata = ({ // Empty aggregator arrays for newly created entities viewFieldIds: [], viewFilterIds: [], + fieldPermissionIds: [], calendarViewIds: [], mainGroupByFieldMetadataViewIds: [], kanbanAggregateOperationViewIds: [], diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/object/services/utils/from-universal-flat-object-metadata-to-flat-object-metadata.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/object/services/utils/from-universal-flat-object-metadata-to-flat-object-metadata.util.ts index 921773d7a04..a266013791a 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/object/services/utils/from-universal-flat-object-metadata-to-flat-object-metadata.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/object/services/utils/from-universal-flat-object-metadata-to-flat-object-metadata.util.ts @@ -123,6 +123,7 @@ export const fromUniversalFlatObjectMetadataToFlatObjectMetadata = ({ viewIds: [], indexMetadataIds: [], objectPermissionIds: [], + fieldPermissionIds: [], ...emptyUniversalForeignKeyAggregators, }; }; diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-schema-migration-runner-action-handlers.module.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-schema-migration-runner-action-handlers.module.ts index 907ad2b8163..3ac0638dad3 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-schema-migration-runner-action-handlers.module.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/workspace-schema-migration-runner-action-handlers.module.ts @@ -9,6 +9,9 @@ import { UpdateAgentActionHandlerService } from 'src/engine/workspace-manager/wo import { CreateCommandMenuItemActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/command-menu-item/services/create-command-menu-item-action-handler.service'; import { DeleteCommandMenuItemActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/command-menu-item/services/delete-command-menu-item-action-handler.service'; import { UpdateCommandMenuItemActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/command-menu-item/services/update-command-menu-item-action-handler.service'; +import { CreateFieldPermissionActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/field-permission/services/create-field-permission-action-handler.service'; +import { DeleteFieldPermissionActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/field-permission/services/delete-field-permission-action-handler.service'; +import { UpdateFieldPermissionActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/field-permission/services/update-field-permission-action-handler.service'; import { CreateFieldActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/field/services/create-field-action-handler.service'; import { DeleteFieldActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/field/services/delete-field-action-handler.service'; import { UpdateFieldActionHandlerService } from 'src/engine/workspace-manager/workspace-migration/workspace-migration-runner/action-handlers/field/services/update-field-action-handler.service'; @@ -144,6 +147,10 @@ import { UpdateWebhookActionHandlerService } from 'src/engine/workspace-manager/ UpdatePermissionFlagActionHandlerService, DeletePermissionFlagActionHandlerService, + CreateFieldPermissionActionHandlerService, + UpdateFieldPermissionActionHandlerService, + DeleteFieldPermissionActionHandlerService, + CreateObjectPermissionActionHandlerService, UpdateObjectPermissionActionHandlerService, DeleteObjectPermissionActionHandlerService, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/constants/metadata-event-to-emit.constant.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/constants/metadata-event-to-emit.constant.ts index 9a67e3956a1..44780ea9c3c 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/constants/metadata-event-to-emit.constant.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/constants/metadata-event-to-emit.constant.ts @@ -21,6 +21,7 @@ export const METADATA_EVENTS_TO_EMIT = { navigationMenuItem: true, permissionFlag: true, objectPermission: true, + fieldPermission: true, rowLevelPermissionPredicate: true, rowLevelPermissionPredicateGroup: true, index: true, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-create-action.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-create-action.util.ts index b0dfe4be6d9..d522bae98f0 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-create-action.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-create-action.util.ts @@ -91,6 +91,7 @@ const deriveAllMetadataEventsFromCreateAction = ( case 'navigationMenuItem': case 'permissionFlag': case 'objectPermission': + case 'fieldPermission': case 'viewSort': case 'webhook': { return [ diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-delete-action.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-delete-action.util.ts index 6ae27f22cef..637977d5916 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-delete-action.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-delete-action.util.ts @@ -55,6 +55,7 @@ const deriveAllMetadataEventsFromDeleteAction = ({ case 'navigationMenuItem': case 'permissionFlag': case 'objectPermission': + case 'fieldPermission': case 'viewSort': case 'webhook': { const flatEntityToDelete = findFlatEntityByIdInFlatEntityMapsOrThrow< diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-update-action.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-update-action.util.ts index 20bea00184a..c29eaf1ad68 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-update-action.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/derive-metadata-events-from-update-action.util.ts @@ -133,6 +133,7 @@ const deriveAllMetadataEventsFromUpdateAction = ({ case 'navigationMenuItem': case 'permissionFlag': case 'objectPermission': + case 'fieldPermission': case 'viewSort': case 'webhook': { const flatEntityMapsKey = getMetadataFlatEntityMapsKey( diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-create-action-on-all-flat-entity-maps.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-create-action-on-all-flat-entity-maps.util.ts index 8c09237c400..3c89e58b655 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-create-action-on-all-flat-entity-maps.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-create-action-on-all-flat-entity-maps.util.ts @@ -70,6 +70,7 @@ export const optimisticallyApplyCreateActionOnAllFlatEntityMaps = ({ case 'navigationMenuItem': case 'permissionFlag': case 'objectPermission': + case 'fieldPermission': case 'webhook': { addFlatEntityToFlatEntityAndRelatedEntityMapsThroughMutationOrThrow({ flatEntity: flatAction.flatEntity, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-delete-action-on-all-flat-entity-maps.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-delete-action-on-all-flat-entity-maps.util.ts index 8a8d569d53a..7eaa8f5bde8 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-delete-action-on-all-flat-entity-maps.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-delete-action-on-all-flat-entity-maps.util.ts @@ -42,6 +42,7 @@ export const optimisticallyApplyDeleteActionOnAllFlatEntityMaps = ({ case 'navigationMenuItem': case 'permissionFlag': case 'objectPermission': + case 'fieldPermission': case 'webhook': { const flatEntityToDelete = findFlatEntityByIdInFlatEntityMapsOrThrow< MetadataFlatEntity diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-update-action-on-all-flat-entity-maps.util.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-update-action-on-all-flat-entity-maps.util.ts index af3aeb4b882..27ca5d05aef 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-update-action-on-all-flat-entity-maps.util.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration/workspace-migration-runner/utils/optimistically-apply-update-action-on-all-flat-entity-maps.util.ts @@ -63,6 +63,7 @@ export const optimisticallyApplyUpdateActionOnAllFlatEntityMaps = ({ case 'navigationMenuItem': case 'permissionFlag': case 'objectPermission': + case 'fieldPermission': case 'webhook': { const flatEntityMapsKey = getMetadataFlatEntityMapsKey( flatAction.metadataName, diff --git a/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/fields-permissions/read-permissions.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/fields-permissions/read-permissions.integration-spec.ts index d51dee61ba7..df7e7dac0bf 100644 --- a/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/fields-permissions/read-permissions.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/object-records-permissions/fields-permissions/read-permissions.integration-spec.ts @@ -82,7 +82,14 @@ const COMPANY_GQL_FIELDS_WITH_PEOPLE_CITY_AGGREGATE = ` } `; +const expectNoGraphQLErrors = (response: any) => { + expect(response.body.errors).toBeUndefined(); + expect(response.body.data).toBeDefined(); +}; + const expectPermissionDeniedError = (response: any) => { + expect(response.body.errors).toBeDefined(); + expect(response.body.errors.length).toBeGreaterThan(0); expect(response.body.errors[0].message).toBe( PermissionsExceptionMessage.PERMISSION_DENIED, ); @@ -272,7 +279,7 @@ describe('Field permissions restrictions', () => { } }); - describe('should throw an error if requesting a restricted field', () => { + describe('should allow restricted fields to be read', () => { beforeEach(async () => { await restrictAccessToCompanyEmployee( customRoleId, @@ -290,7 +297,10 @@ describe('Field permissions restrictions', () => { const response = await makeGraphqlAPIRequestWithMemberRole(graphqlOperation); - expectPermissionDeniedError(response); + expectNoGraphQLErrors(response); + expect( + response.body.data.companies.edges[0].node.employees, + ).toBeDefined(); }); it('2. findOne', async () => { @@ -302,7 +312,8 @@ describe('Field permissions restrictions', () => { const response = await makeGraphqlAPIRequestWithMemberRole(graphqlOperation); - expectPermissionDeniedError(response); + expectNoGraphQLErrors(response); + expect(response.body.data.company.employees).toBeDefined(); }); it('3. updateMany', async () => { @@ -388,7 +399,7 @@ describe('Field permissions restrictions', () => { }); }); - it('2. should throw an error if requesting a restricted field of a related object', async () => { + it('2. should allow a restricted field of a related object to be read', async () => { await restrictAccessToPersonCity( customRoleId, personObjectId, @@ -402,7 +413,10 @@ describe('Field permissions restrictions', () => { const response = await makeGraphqlAPIRequestWithMemberRole(graphqlOperation); - expectPermissionDeniedError(response); + expectNoGraphQLErrors(response); + expect( + response.body.data.companies.edges[0].node.people.edges[0].node.city, + ).toBeDefined(); }); it('3. should succeed if restricted fields exist but are not requested', async () => { @@ -432,7 +446,7 @@ describe('Field permissions restrictions', () => { }); describe('Aggregate operations', () => { - it('1. should throw an error if requesting a restricted field through aggregates', async () => { + it('1. should allow aggregate over a restricted field', async () => { await restrictAccessToCompanyEmployee( customRoleId, companyObjectId, @@ -452,10 +466,11 @@ describe('Field permissions restrictions', () => { const response = await makeGraphqlAPIRequestWithMemberRole(graphqlOperation); - expectPermissionDeniedError(response); + expectNoGraphQLErrors(response); + expect(response.body.data.companies.countEmptyEmployees).toBeDefined(); }); - it('2. should throw an error if requesting a restricted field on related object through aggregates', async () => { + it('2. should allow aggregate over a restricted field on a related object', async () => { await restrictAccessToPersonCity( customRoleId, personObjectId, @@ -471,7 +486,10 @@ describe('Field permissions restrictions', () => { const response = await makeGraphqlAPIRequestWithMemberRole(graphqlOperation); - expectPermissionDeniedError(response); + expectNoGraphQLErrors(response); + expect( + response.body.data.companies.edges[0].node.people.percentageEmptyCity, + ).toBeDefined(); }); }); }); 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 index 02c6f65bee2..375703229f1 100644 --- 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 @@ -14,8 +14,6 @@ import { updateWorkspaceMemberRole } from 'test/integration/graphql/utils/update 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 { 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}`); @@ -30,11 +28,33 @@ 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); +const expectEmployeesIsAccessible = ({ + response, + operationName, + expectedEmployees, +}: { + response: any; + operationName: + | 'createCompanies' + | 'createCompany' + | 'updateCompanies' + | 'updateCompany'; + expectedEmployees?: number; +}) => { + expect(response.body.errors).toBeUndefined(); + expect(response.body.data).toBeDefined(); + + const result = + operationName === 'createCompany' || operationName === 'updateCompany' + ? response.body.data[operationName] + : response.body.data[operationName]?.[0]; + + expect(result).toBeDefined(); + if (typeof expectedEmployees === 'number') { + expect(result.employees).toBe(expectedEmployees); + } else { + expect(typeof result.employees).toBe('number'); + } }; describe('Field update permissions restrictions', () => { @@ -294,7 +314,7 @@ describe('Field update permissions restrictions', () => { // }); // }); - describe('should throw an error if creating with restricted fields', () => { + describe('should allow employees field when creating', () => { beforeEach(async () => { await restrictUpdateAccessToCompanyEmployee( customRoleId, @@ -317,7 +337,11 @@ describe('Field update permissions restrictions', () => { const response = await makeGraphqlAPIRequestWithMemberRole(graphqlOperation); - expectPermissionDeniedError(response); + expectEmployeesIsAccessible({ + response, + operationName: 'createCompanies', + expectedEmployees: 15, + }); }); it('2. createOne with restricted field', async () => { @@ -330,10 +354,14 @@ describe('Field update permissions restrictions', () => { const response = await makeGraphqlAPIRequestWithMemberRole(graphqlOperation); - expectPermissionDeniedError(response); + expectEmployeesIsAccessible({ + response, + operationName: 'createCompany', + expectedEmployees: 25, + }); }); }); - describe('should throw an error if reading restricted fields in update operations', () => { + describe('should allow employees field in update operation responses', () => { beforeEach(async () => { await restrictReadAccessToCompanyEmployee( customRoleId, @@ -348,12 +376,16 @@ describe('Field update permissions restrictions', () => { objectMetadataPluralName: 'companies', gqlFields: COMPANY_GQL_FIELDS_WITH_EMPLOYEES, data: { name: 'UpdatedCompany' }, + filter: { id: { eq: companyId } }, }); const response = await makeGraphqlAPIRequestWithMemberRole(graphqlOperation); - expectPermissionDeniedError(response); + expectEmployeesIsAccessible({ + response, + operationName: 'updateCompanies', + }); }); it('2. updateOne requesting restricted field in response', async () => { @@ -367,7 +399,10 @@ describe('Field update permissions restrictions', () => { const response = await makeGraphqlAPIRequestWithMemberRole(graphqlOperation); - expectPermissionDeniedError(response); + expectEmployeesIsAccessible({ + response, + operationName: 'updateCompany', + }); }); }); diff --git a/packages/twenty-server/test/integration/graphql/utils/upsert-field-permissions-operation-factory.util.ts b/packages/twenty-server/test/integration/graphql/utils/upsert-field-permissions-operation-factory.util.ts deleted file mode 100644 index a79e94e7bb7..00000000000 --- a/packages/twenty-server/test/integration/graphql/utils/upsert-field-permissions-operation-factory.util.ts +++ /dev/null @@ -1,39 +0,0 @@ -import gql from 'graphql-tag'; - -export const createUpsertFieldPermissionsOperation = ( - roleId: string, - fieldPermissions: Array<{ - objectMetadataId: string; - fieldMetadataId: string; - canReadFieldValue?: boolean | null; - canUpdateFieldValue?: boolean | null; - }>, - selectedFields: string[] = [ - 'id', - 'roleId', - 'objectMetadataId', - 'fieldMetadataId', - 'canReadFieldValue', - 'canUpdateFieldValue', - ], -) => ({ - query: gql` - mutation UpsertFieldPermissions( - $roleId: UUID! - $fieldPermissions: [FieldPermissionInput!]! - ) { - upsertFieldPermissions( - upsertFieldPermissionsInput: { - roleId: $roleId - fieldPermissions: $fieldPermissions - } - ) { - ${selectedFields.join('\n')} - } - } - `, - variables: { - roleId, - fieldPermissions, - }, -}); diff --git a/packages/twenty-server/test/integration/graphql/utils/upsert-field-permissions.util.ts b/packages/twenty-server/test/integration/graphql/utils/upsert-field-permissions.util.ts index 96abe46e760..d657a77592e 100644 --- a/packages/twenty-server/test/integration/graphql/utils/upsert-field-permissions.util.ts +++ b/packages/twenty-server/test/integration/graphql/utils/upsert-field-permissions.util.ts @@ -1,27 +1,14 @@ -import { createUpsertFieldPermissionsOperation } from 'test/integration/graphql/utils/upsert-field-permissions-operation-factory.util'; +import { upsertFieldPermissionsQueryFactory } from 'test/integration/metadata/suites/field-permission/utils/upsert-field-permissions-query-factory.util'; import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; -export const upsertFieldPermissions = async ({ - roleId, - fieldPermissions, - selectedFields, -}: { - roleId: string; - fieldPermissions: Array<{ - objectMetadataId: string; - fieldMetadataId: string; - canReadFieldValue?: boolean | null; - canUpdateFieldValue?: boolean | null; - }>; - selectedFields?: string[]; -}) => { - const operation = createUpsertFieldPermissionsOperation( - roleId, - fieldPermissions, - selectedFields, - ); +import { type UpsertFieldPermissionsInput } from 'src/engine/metadata-modules/object-permission/dtos/upsert-field-permissions.input'; - const response = await makeMetadataAPIRequest(operation); +export const upsertFieldPermissions = async ( + input: UpsertFieldPermissionsInput, +) => { + const graphqlOperation = upsertFieldPermissionsQueryFactory({ input }); + + const response = await makeMetadataAPIRequest(graphqlOperation); return { data: response.body.data, diff --git a/packages/twenty-server/test/integration/metadata/suites/field-permission/__snapshots__/failing-field-permission-upsert.integration-spec.ts.snap b/packages/twenty-server/test/integration/metadata/suites/field-permission/__snapshots__/failing-field-permission-upsert.integration-spec.ts.snap new file mode 100644 index 00000000000..69fe53b5106 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/field-permission/__snapshots__/failing-field-permission-upsert.integration-spec.ts.snap @@ -0,0 +1,126 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`Field permission upsert should fail when canReadFieldValue is true (only restriction allowed) 1`] = ` +{ + "extensions": { + "code": "BAD_USER_INPUT", + "subCode": "ONLY_FIELD_RESTRICTION_ALLOWED", + "userFriendlyMessage": "Field permissions can only be used to restrict access, not to grant additional permissions.", + }, + "message": "Field permission can only introduce a restriction", + "name": "UserInputError", +} +`; + +exports[`Field permission upsert should fail when canUpdateFieldValue is true (only restriction allowed) 1`] = ` +{ + "extensions": { + "code": "BAD_USER_INPUT", + "subCode": "ONLY_FIELD_RESTRICTION_ALLOWED", + "userFriendlyMessage": "Field permissions can only be used to restrict access, not to grant additional permissions.", + }, + "message": "Field permission can only introduce a restriction", + "name": "UserInputError", +} +`; + +exports[`Field permission upsert should fail when fieldMetadataId does not exist 1`] = ` +{ + "extensions": { + "code": "NOT_FOUND", + "subCode": "FIELD_METADATA_NOT_FOUND", + "userFriendlyMessage": "The field you are trying to set permissions for could not be found. It may have been deleted.", + }, + "message": "Field metadata not found", + "name": "NotFoundError", +} +`; + +exports[`Field permission upsert should fail when object is system object 1`] = ` +{ + "extensions": { + "code": "FORBIDDEN", + "subCode": "CANNOT_ADD_FIELD_PERMISSION_ON_SYSTEM_OBJECT", + "userFriendlyMessage": "You cannot set field permissions on system objects as they are managed by the platform.", + }, + "message": "Cannot add field permission on system object", + "name": "ForbiddenError", +} +`; + +exports[`Field permission upsert should fail when objectMetadataId does not exist 1`] = ` +{ + "extensions": { + "code": "NOT_FOUND", + "subCode": "OBJECT_METADATA_NOT_FOUND_PERMISSIONS", + "userFriendlyMessage": "The object you are trying to set permissions for could not be found. It may have been deleted.", + }, + "message": "Object metadata not found", + "name": "NotFoundError", +} +`; + +exports[`Field permission upsert should fail when role is not editable (system role) 1`] = ` +{ + "extensions": { + "code": "METADATA_VALIDATION_FAILED", + "errors": { + "fieldPermission": [ + { + "errors": [ + { + "code": "ROLE_NOT_EDITABLE", + "message": "Role is not editable", + "userFriendlyMessage": "This role cannot be modified because it is a system role. Only custom roles can be edited.", + }, + ], + "flatEntityMinimalInformation": { + "fieldMetadataUniversalIdentifier": Any, + "objectMetadataUniversalIdentifier": Any, + "roleUniversalIdentifier": Any, + "universalIdentifier": Any, + }, + "metadataName": "fieldPermission", + "status": "fail", + "type": "create", + }, + ], + }, + "message": "Validation failed for 1 fieldPermission", + "summary": { + "fieldPermission": 1, + "totalErrors": 1, + }, + "userFriendlyMessage": "Metadata validation failed", + }, + "message": "Validation errors occurred while upserting field permissions", + "name": "GraphQLError", +} +`; + +exports[`Field permission upsert should fail when roleId does not exist 1`] = ` +{ + "extensions": { + "code": "NOT_FOUND", + "subCode": "OBJECT_METADATA_NOT_FOUND_PERMISSIONS", + "userFriendlyMessage": "The object you are trying to set permissions for could not be found. It may have been deleted.", + }, + "message": "Object metadata not found", + "name": "NotFoundError", +} +`; + +exports[`Field permission upsert should fail when roleId is not a valid UUID 1`] = ` +{ + "extensions": { + "code": "GRAPHQL_VALIDATION_FAILED", + "http": { + "status": 400, + }, + "userFriendlyMessage": "An error occurred.", + "value": "invalid-uuid", + }, + "message": "Invalid UUID: 'invalid-uuid'", + "name": "ValidationError", +} +`; diff --git a/packages/twenty-server/test/integration/metadata/suites/field-permission/failing-field-permission-upsert.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-permission/failing-field-permission-upsert.integration-spec.ts new file mode 100644 index 00000000000..71b2c3e9388 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/field-permission/failing-field-permission-upsert.integration-spec.ts @@ -0,0 +1,308 @@ +import gql from 'graphql-tag'; +import { expectOneNotInternalServerErrorSnapshot } from 'test/integration/graphql/utils/expect-one-not-internal-server-error-snapshot.util'; +import { findManyFieldsMetadata } from 'test/integration/metadata/suites/field-metadata/utils/find-many-fields-metadata.util'; +import { upsertFieldPermissions } from 'test/integration/metadata/suites/field-permission/utils/upsert-field-permissions.util'; +import { upsertObjectPermissions } from 'test/integration/metadata/suites/object-permission/utils/upsert-object-permissions.util'; +import { createOneRole } from 'test/integration/metadata/suites/role/utils/create-one-role.util'; +import { deleteOneRole } from 'test/integration/metadata/suites/role/utils/delete-one-role.util'; +import { findRoles } from 'test/integration/metadata/suites/role/utils/find-roles.util'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; +import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test'; +import { + eachTestingContextFilter, + type EachTestingContext, +} from 'twenty-shared/testing'; +import { isDefined } from 'twenty-shared/utils'; +import { v4 } from 'uuid'; + +import { type UpsertFieldPermissionsInput } from 'src/engine/metadata-modules/object-permission/dtos/upsert-field-permissions.input'; + +type GlobalTestContext = { + editableRoleId: string; + nonEditableRoleId: string; + systemObjectMetadataId: string; + nonSystemObjectMetadataId: string; + oneFieldMetadataId: string; +}; + +type TestContext = { + input: (globalContext: GlobalTestContext) => UpsertFieldPermissionsInput; +}; + +const failingFieldPermissionUpsertTestCases: EachTestingContext[] = + [ + { + title: 'when roleId is not a valid UUID', + context: { + input: () => ({ + roleId: 'invalid-uuid', + fieldPermissions: [ + { + objectMetadataId: v4(), + fieldMetadataId: v4(), + canReadFieldValue: false, + canUpdateFieldValue: false, + }, + ], + }), + }, + }, + { + title: 'when roleId does not exist', + context: { + input: () => ({ + roleId: v4(), + fieldPermissions: [ + { + objectMetadataId: v4(), + fieldMetadataId: v4(), + canReadFieldValue: false, + canUpdateFieldValue: false, + }, + ], + }), + }, + }, + { + title: 'when role is not editable (system role)', + context: { + input: (globalContext: GlobalTestContext) => ({ + roleId: globalContext.nonEditableRoleId, + fieldPermissions: [ + { + objectMetadataId: globalContext.nonSystemObjectMetadataId, + fieldMetadataId: globalContext.oneFieldMetadataId, + canReadFieldValue: false, + canUpdateFieldValue: false, + }, + ], + }), + }, + }, + { + title: 'when objectMetadataId does not exist', + context: { + input: (globalContext: GlobalTestContext) => ({ + roleId: globalContext.editableRoleId, + fieldPermissions: [ + { + objectMetadataId: v4(), + fieldMetadataId: globalContext.oneFieldMetadataId, + canReadFieldValue: false, + canUpdateFieldValue: false, + }, + ], + }), + }, + }, + { + title: 'when fieldMetadataId does not exist', + context: { + input: (globalContext: GlobalTestContext) => ({ + roleId: globalContext.editableRoleId, + fieldPermissions: [ + { + objectMetadataId: globalContext.nonSystemObjectMetadataId, + fieldMetadataId: v4(), + canReadFieldValue: false, + canUpdateFieldValue: false, + }, + ], + }), + }, + }, + { + title: 'when object is system object', + context: { + input: (globalContext: GlobalTestContext) => ({ + roleId: globalContext.editableRoleId, + fieldPermissions: [ + { + objectMetadataId: globalContext.systemObjectMetadataId, + fieldMetadataId: globalContext.oneFieldMetadataId, + canReadFieldValue: false, + canUpdateFieldValue: false, + }, + ], + }), + }, + }, + { + title: 'when canReadFieldValue is true (only restriction allowed)', + context: { + input: (globalContext: GlobalTestContext) => ({ + roleId: globalContext.editableRoleId, + fieldPermissions: [ + { + objectMetadataId: globalContext.nonSystemObjectMetadataId, + fieldMetadataId: globalContext.oneFieldMetadataId, + canReadFieldValue: true, + canUpdateFieldValue: false, + }, + ], + }), + }, + }, + { + title: 'when canUpdateFieldValue is true (only restriction allowed)', + context: { + input: (globalContext: GlobalTestContext) => ({ + roleId: globalContext.editableRoleId, + fieldPermissions: [ + { + objectMetadataId: globalContext.nonSystemObjectMetadataId, + fieldMetadataId: globalContext.oneFieldMetadataId, + canReadFieldValue: false, + canUpdateFieldValue: true, + }, + ], + }), + }, + }, + ]; + +describe('Field permission upsert should fail', () => { + let editableRoleId: string; + let nonEditableRoleId: string; + let systemObjectMetadataId: string; + let nonSystemObjectMetadataId: string; + let oneFieldMetadataId: string; + + beforeAll(async () => { + const { data: roleData } = await createOneRole({ + expectToFail: false, + input: { + label: 'Test Role For Field Permission Failing', + description: 'Role for field permission failing tests', + icon: 'IconSettings', + canUpdateAllSettings: false, + canAccessAllTools: true, + canReadAllObjectRecords: true, + canUpdateAllObjectRecords: false, + canSoftDeleteAllObjectRecords: false, + canDestroyAllObjectRecords: false, + canBeAssignedToUsers: true, + canBeAssignedToAgents: false, + canBeAssignedToApiKeys: false, + }, + }); + + editableRoleId = roleData?.createOneRole?.id; + jestExpectToBeDefined(editableRoleId); + + const { data: rolesData } = await findRoles({ + expectToFail: false, + gqlFields: ` + id + label + isEditable + `, + }); + + jestExpectToBeDefined(rolesData?.getRoles); + const adminRole = rolesData.getRoles.find( + (role: { label: string; isEditable: boolean }) => + role.label === 'Admin' && role.isEditable === false, + ); + jestExpectToBeDefined(adminRole); + nonEditableRoleId = adminRole.id; + + const getObjectMetadataOperation = { + query: gql` + query { + objects(paging: { first: 1000 }) { + edges { + node { + id + nameSingular + isSystem + } + } + } + } + `, + }; + + const objectMetadataResponse = await makeMetadataAPIRequest( + getObjectMetadataOperation, + ); + const edges = objectMetadataResponse.body.data?.objects?.edges ?? []; + const systemObjectNode = edges.find( + (edge: { node: { isSystem: boolean | string } }) => + edge.node.isSystem === true || String(edge.node.isSystem) === 'true', + )?.node; + jestExpectToBeDefined(systemObjectNode); + systemObjectMetadataId = systemObjectNode.id; + + const nonSystemObjectNode = edges.find( + (edge: { node: { isSystem: boolean | string } }) => + edge.node.isSystem === false || String(edge.node.isSystem) === 'false', + )?.node; + jestExpectToBeDefined(nonSystemObjectNode); + nonSystemObjectMetadataId = nonSystemObjectNode.id; + + const { fields } = await findManyFieldsMetadata({ + expectToFail: false, + input: { + filter: { objectMetadataId: { eq: nonSystemObjectMetadataId } }, + paging: { first: 50 }, + }, + gqlFields: 'id type', + }); + jestExpectToBeDefined(fields); + expect(fields?.length).toBeGreaterThan(0); + const nonRelationField = fields.find( + (field: { node: { type: string } }) => field.node.type !== 'RELATION', + ); + jestExpectToBeDefined(nonRelationField); + oneFieldMetadataId = nonRelationField.node.id; + + await upsertObjectPermissions({ + expectToFail: false, + input: { + roleId: editableRoleId, + objectPermissions: [ + { + objectMetadataId: nonSystemObjectMetadataId, + canReadObjectRecords: true, + canUpdateObjectRecords: false, + canSoftDeleteObjectRecords: false, + canDestroyObjectRecords: false, + }, + ], + }, + }); + }); + + afterAll(async () => { + if (isDefined(editableRoleId)) { + await deleteOneRole({ + expectToFail: false, + input: { idToDelete: editableRoleId }, + }); + } + }); + + it.each(eachTestingContextFilter(failingFieldPermissionUpsertTestCases))( + '$title', + async ({ context }) => { + const globalContext: GlobalTestContext = { + editableRoleId: editableRoleId ?? '', + nonEditableRoleId: nonEditableRoleId ?? '', + systemObjectMetadataId: systemObjectMetadataId ?? '', + nonSystemObjectMetadataId: nonSystemObjectMetadataId ?? '', + oneFieldMetadataId: oneFieldMetadataId ?? '', + }; + const input = context.input(globalContext); + + const { errors } = await upsertFieldPermissions({ + expectToFail: true, + input, + }); + + expectOneNotInternalServerErrorSnapshot({ + errors, + }); + }, + ); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/field-permission/successful-field-permission-upsert.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/field-permission/successful-field-permission-upsert.integration-spec.ts new file mode 100644 index 00000000000..cb47258cb15 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/field-permission/successful-field-permission-upsert.integration-spec.ts @@ -0,0 +1,238 @@ +import { findManyFieldsMetadata } from 'test/integration/metadata/suites/field-metadata/utils/find-many-fields-metadata.util'; +import { upsertFieldPermissions } from 'test/integration/metadata/suites/field-permission/utils/upsert-field-permissions.util'; +import { createOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/create-one-object-metadata.util'; +import { deleteOneObjectMetadata } from 'test/integration/metadata/suites/object-metadata/utils/delete-one-object-metadata.util'; +import { upsertObjectPermissions } from 'test/integration/metadata/suites/object-permission/utils/upsert-object-permissions.util'; +import { createOneRole } from 'test/integration/metadata/suites/role/utils/create-one-role.util'; +import { deleteOneRole } from 'test/integration/metadata/suites/role/utils/delete-one-role.util'; +import { jestExpectToBeDefined } from 'test/utils/jest-expect-to-be-defined.util.test'; +import { isDefined } from 'twenty-shared/utils'; + +describe('Field permission upsert should succeed', () => { + let createdRoleId: string; + let customObjectMetadataId: string; + let oneFieldMetadataId: string; + let objectWithNoObjectPermissionMetadataId: string; + let objectWithNoObjectPermissionFieldMetadataId: string; + + beforeAll(async () => { + const { data: roleData } = await createOneRole({ + expectToFail: false, + input: { + label: 'Test Role For Field Permission Success', + description: 'Role for field permission successful tests', + icon: 'IconSettings', + canUpdateAllSettings: false, + canAccessAllTools: true, + canReadAllObjectRecords: true, + canUpdateAllObjectRecords: false, + canSoftDeleteAllObjectRecords: false, + canDestroyAllObjectRecords: false, + canBeAssignedToUsers: true, + canBeAssignedToAgents: false, + canBeAssignedToApiKeys: false, + }, + }); + + createdRoleId = roleData?.createOneRole?.id; + jestExpectToBeDefined(createdRoleId); + + const { + data: { createOneObject }, + } = await createOneObjectMetadata({ + input: { + nameSingular: 'testFieldPermissionSuccess', + namePlural: 'testFieldPermissionSuccesses', + labelSingular: 'Test Field Permission Success', + labelPlural: 'Test Field Permission Successes', + icon: 'IconSettings', + }, + }); + + customObjectMetadataId = createOneObject.id; + jestExpectToBeDefined(customObjectMetadataId); + + const { + data: { createOneObject: createOneObjectWithNoObjectPermission }, + } = await createOneObjectMetadata({ + input: { + nameSingular: 'testFieldPermissionNoObjPerm', + namePlural: 'testFieldPermissionNoObjPerms', + labelSingular: 'Test Field Permission No Obj Perm', + labelPlural: 'Test Field Permission No Obj Perms', + icon: 'IconSettings', + }, + }); + objectWithNoObjectPermissionMetadataId = + createOneObjectWithNoObjectPermission.id; + jestExpectToBeDefined(objectWithNoObjectPermissionMetadataId); + + await upsertObjectPermissions({ + expectToFail: false, + input: { + roleId: createdRoleId, + objectPermissions: [ + { + objectMetadataId: customObjectMetadataId, + canReadObjectRecords: true, + canUpdateObjectRecords: true, + canSoftDeleteObjectRecords: false, + canDestroyObjectRecords: false, + }, + ], + }, + }); + + const { fields } = await findManyFieldsMetadata({ + expectToFail: false, + input: { + filter: { objectMetadataId: { eq: customObjectMetadataId } }, + paging: { first: 1 }, + }, + gqlFields: 'id', + }); + jestExpectToBeDefined(fields); + expect(fields?.length).toBeGreaterThan(0); + oneFieldMetadataId = fields[0].node.id; + + const { fields: fieldsNoObjectPermission } = await findManyFieldsMetadata({ + expectToFail: false, + input: { + filter: { + objectMetadataId: { eq: objectWithNoObjectPermissionMetadataId }, + }, + paging: { first: 1 }, + }, + gqlFields: 'id', + }); + jestExpectToBeDefined(fieldsNoObjectPermission); + expect(fieldsNoObjectPermission?.length).toBeGreaterThan(0); + objectWithNoObjectPermissionFieldMetadataId = + fieldsNoObjectPermission[0].node.id; + }); + + afterAll(async () => { + if (isDefined(createdRoleId)) { + await deleteOneRole({ + expectToFail: false, + input: { idToDelete: createdRoleId }, + }); + } + if (isDefined(customObjectMetadataId)) { + await deleteOneObjectMetadata({ + expectToFail: false, + input: { idToDelete: customObjectMetadataId }, + }); + } + if (isDefined(objectWithNoObjectPermissionMetadataId)) { + await deleteOneObjectMetadata({ + expectToFail: false, + input: { idToDelete: objectWithNoObjectPermissionMetadataId }, + }); + } + }); + + it('should upsert one field permission (create)', async () => { + const { data } = await upsertFieldPermissions({ + expectToFail: false, + input: { + roleId: createdRoleId, + fieldPermissions: [ + { + objectMetadataId: customObjectMetadataId, + fieldMetadataId: oneFieldMetadataId, + canReadFieldValue: false, + canUpdateFieldValue: false, + }, + ], + }, + }); + + expect(data?.upsertFieldPermissions).toHaveLength(1); + expect(data?.upsertFieldPermissions?.[0]).toMatchObject({ + objectMetadataId: customObjectMetadataId, + fieldMetadataId: oneFieldMetadataId, + roleId: createdRoleId, + canReadFieldValue: false, + canUpdateFieldValue: false, + }); + }); + + it('should upsert to update existing field permission', async () => { + const { data } = await upsertFieldPermissions({ + expectToFail: false, + input: { + roleId: createdRoleId, + fieldPermissions: [ + { + objectMetadataId: customObjectMetadataId, + fieldMetadataId: oneFieldMetadataId, + canReadFieldValue: false, + canUpdateFieldValue: false, + }, + ], + }, + }); + + expect(data?.upsertFieldPermissions).toHaveLength(1); + expect(data?.upsertFieldPermissions?.[0]).toMatchObject({ + objectMetadataId: customObjectMetadataId, + fieldMetadataId: oneFieldMetadataId, + roleId: createdRoleId, + canReadFieldValue: false, + canUpdateFieldValue: false, + }); + }); + + it('should upsert with both canReadFieldValue and canUpdateFieldValue false', async () => { + const { data } = await upsertFieldPermissions({ + expectToFail: false, + input: { + roleId: createdRoleId, + fieldPermissions: [ + { + objectMetadataId: customObjectMetadataId, + fieldMetadataId: oneFieldMetadataId, + canReadFieldValue: false, + canUpdateFieldValue: false, + }, + ], + }, + }); + + expect(data?.upsertFieldPermissions).toHaveLength(1); + expect(data?.upsertFieldPermissions?.[0]).toMatchObject({ + objectMetadataId: customObjectMetadataId, + fieldMetadataId: oneFieldMetadataId, + roleId: createdRoleId, + canReadFieldValue: false, + canUpdateFieldValue: false, + }); + }); + + it('should upsert even when object permission is not found for role on object', async () => { + const { data } = await upsertFieldPermissions({ + expectToFail: false, + input: { + roleId: createdRoleId, + fieldPermissions: [ + { + objectMetadataId: objectWithNoObjectPermissionMetadataId, + fieldMetadataId: objectWithNoObjectPermissionFieldMetadataId, + canReadFieldValue: false, + canUpdateFieldValue: false, + }, + ], + }, + }); + + expect(data?.upsertFieldPermissions).toHaveLength(1); + expect(data?.upsertFieldPermissions?.[0]).toMatchObject({ + objectMetadataId: objectWithNoObjectPermissionMetadataId, + fieldMetadataId: objectWithNoObjectPermissionFieldMetadataId, + roleId: createdRoleId, + canReadFieldValue: false, + canUpdateFieldValue: false, + }); + }); +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/field-permission/utils/upsert-field-permissions-query-factory.util.ts b/packages/twenty-server/test/integration/metadata/suites/field-permission/utils/upsert-field-permissions-query-factory.util.ts new file mode 100644 index 00000000000..6193568a0ff --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/field-permission/utils/upsert-field-permissions-query-factory.util.ts @@ -0,0 +1,35 @@ +import gql from 'graphql-tag'; +import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type'; + +import { type UpsertFieldPermissionsInput } from 'src/engine/metadata-modules/object-permission/dtos/upsert-field-permissions.input'; + +export type UpsertFieldPermissionsFactoryInput = UpsertFieldPermissionsInput; + +const DEFAULT_FIELD_PERMISSION_GQL_FIELDS = ` + id + objectMetadataId + fieldMetadataId + roleId + canReadFieldValue + canUpdateFieldValue +`; + +export const upsertFieldPermissionsQueryFactory = ({ + input, + gqlFields = DEFAULT_FIELD_PERMISSION_GQL_FIELDS, +}: PerformMetadataQueryParams) => ({ + query: gql` + mutation UpsertFieldPermissions( + $upsertFieldPermissionsInput: UpsertFieldPermissionsInput! + ) { + upsertFieldPermissions( + upsertFieldPermissionsInput: $upsertFieldPermissionsInput + ) { + ${gqlFields} + } + } + `, + variables: { + upsertFieldPermissionsInput: input, + }, +}); diff --git a/packages/twenty-server/test/integration/metadata/suites/field-permission/utils/upsert-field-permissions.util.ts b/packages/twenty-server/test/integration/metadata/suites/field-permission/utils/upsert-field-permissions.util.ts new file mode 100644 index 00000000000..cc66574b635 --- /dev/null +++ b/packages/twenty-server/test/integration/metadata/suites/field-permission/utils/upsert-field-permissions.util.ts @@ -0,0 +1,43 @@ +import { + type UpsertFieldPermissionsFactoryInput, + upsertFieldPermissionsQueryFactory, +} from 'test/integration/metadata/suites/field-permission/utils/upsert-field-permissions-query-factory.util'; +import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util'; +import { type CommonResponseBody } from 'test/integration/metadata/types/common-response-body.type'; +import { type PerformMetadataQueryParams } from 'test/integration/metadata/types/perform-metadata-query.type'; +import { warnIfErrorButNotExpectedToFail } from 'test/integration/metadata/utils/warn-if-error-but-not-expected-to-fail.util'; +import { warnIfNoErrorButExpectedToFail } from 'test/integration/metadata/utils/warn-if-no-error-but-expected-to-fail.util'; + +import { type FieldPermissionDTO } from 'src/engine/metadata-modules/object-permission/dtos/field-permission.dto'; + +export const upsertFieldPermissions = async ({ + input, + gqlFields, + expectToFail = false, + token, +}: PerformMetadataQueryParams): CommonResponseBody<{ + upsertFieldPermissions: FieldPermissionDTO[]; +}> => { + const graphqlOperation = upsertFieldPermissionsQueryFactory({ + input, + gqlFields, + }); + + const response = await makeMetadataAPIRequest(graphqlOperation, token); + + if (expectToFail === true) { + warnIfNoErrorButExpectedToFail({ + response, + errorMessage: 'Field permissions upsert should have failed but did not', + }); + } + + if (expectToFail === false) { + warnIfErrorButNotExpectedToFail({ + response, + errorMessage: 'Field permissions upsert has failed but should not', + }); + } + + return { data: response.body.data, errors: response.body.errors }; +}; 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 89eb61a7b13..c7ffd619c2e 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 @@ -288,7 +288,7 @@ describe('Restricted fields', () => { }); it('should allow create when user has no restricted update permissions', async () => { - // Remove field permission restrictions on phones + // Remove field permission restrictions on phones; restrict read on emails so response excludes it await upsertFieldPermissions({ roleId: memberRoleId, fieldPermissions: [ @@ -298,6 +298,12 @@ describe('Restricted fields', () => { canReadFieldValue: null, canUpdateFieldValue: null, }, + { + objectMetadataId: personObjectId, + fieldMetadataId: emailsFieldId, + canReadFieldValue: false, + canUpdateFieldValue: null, + }, ], }); @@ -357,7 +363,7 @@ describe('Restricted fields', () => { }); it('should allow createMany when user has no restricted update permissions', async () => { - // Remove field permission restrictions + // Remove field permission restrictions on phones; restrict read on emails so response excludes it await upsertFieldPermissions({ roleId: memberRoleId, fieldPermissions: [ @@ -367,6 +373,12 @@ describe('Restricted fields', () => { canReadFieldValue: null, canUpdateFieldValue: null, }, + { + objectMetadataId: personObjectId, + fieldMetadataId: emailsFieldId, + canReadFieldValue: false, + canUpdateFieldValue: null, + }, ], }); diff --git a/packages/twenty-shared/src/metadata/constants/all-metadata-name.constant.ts b/packages/twenty-shared/src/metadata/constants/all-metadata-name.constant.ts index 36dff915151..54dacc84700 100644 --- a/packages/twenty-shared/src/metadata/constants/all-metadata-name.constant.ts +++ b/packages/twenty-shared/src/metadata/constants/all-metadata-name.constant.ts @@ -23,6 +23,7 @@ export const ALL_METADATA_NAME = { navigationMenuItem: 'navigationMenuItem', permissionFlag: 'permissionFlag', objectPermission: 'objectPermission', + fieldPermission: 'fieldPermission', frontComponent: 'frontComponent', webhook: 'webhook', } as const;