From 1cb60f943e80a921cbb0da8142764fa354a6600f Mon Sep 17 00:00:00 2001 From: Marie <51697796+ijreilly@users.noreply.github.com> Date: Wed, 9 Jul 2025 10:47:59 +0200 Subject: [PATCH] [field-level permissions] Upsert fieldPermission + use fieldPermission to compute permissions (#13050) In this PR - introduction of fieldPermission entity - addition of upsertFieldPermission in role resolver - computing of permissions taking fieldPermission into account. In order to limit what is stored in Redis we only store fields restrictions. For instance for objectMetadata with id XXX with a restriction on field with id YYY we store: `"XXX":{"canRead":true,"canUpdate":false,"canSoftDelete":false,"canDestroy":false,"restrictedFields":{"YYY":{"canRead":false,"canUpdate":null}}}` --------- Co-authored-by: Charles Bochet --- .../src/generated-metadata/graphql.ts | 29 + .../twenty-front/src/generated/graphql.ts | 29 + ...751890088507-addIndexOnObjectPermission.ts | 19 + .../1751993324990-addFieldPermission.ts | 45 ++ .../enums/feature-flag-key.enum.ts | 1 + .../field-metadata/field-metadata.entity.ts | 7 + .../index-metadata/dtos/index-metadata.dto.ts | 2 +- .../index-metadata/index-metadata.entity.ts | 6 +- .../index-metadata/index-metadata.service.ts | 6 +- .../interfaces/index-metadata.interface.ts | 2 +- .../index-metadata/types/indexType.types.ts | 4 + .../getUniqueConstraintsFields.util.spec.ts | 2 +- .../object-metadata/object-metadata.entity.ts | 10 + .../dtos/field-permission.dto.ts | 22 + .../dtos/upsert-field-permissions.input.ts | 47 ++ .../field-permissions.service.spec.ts | 585 ++++++++++++++++++ .../field-permission.entity.ts | 92 +++ .../field-permission.service.ts | 281 +++++++++ .../object-permission.entity.ts | 2 + .../object-permission.module.ts | 15 +- .../permissions/permissions.exception.ts | 18 + ...sion-graphql-api-exception-handler.util.ts | 9 + .../metadata-modules/role/role.entity.ts | 7 + .../metadata-modules/role/role.resolver.ts | 16 + .../search-vector/search-vector.service.ts | 2 +- .../workspace-migration.entity.ts | 2 +- .../workspace-permissions-cache.service.ts | 38 +- .../twenty-orm/custom.workspace-entity.ts | 2 +- .../decorators/workspace-index.decorator.ts | 2 +- .../workspace-entity-manager.spec.ts | 1 + ...workspace-index-metadata-args.interface.ts | 2 +- .../repository/workspace.repository.spec.ts | 1 + ...get-default-columns-for-index.util.spec.ts | 2 +- .../get-default-columns-for-index.util.ts | 2 +- .../workspace-migration-runner.service.ts | 2 +- .../factories/standard-index.factory.ts | 6 +- .../company.workspace-entity.ts | 2 +- .../standard-objects/note.workspace-entity.ts | 2 +- .../opportunity.workspace-entity.ts | 2 +- .../person.workspace-entity.ts | 2 +- .../standard-objects/task.workspace-entity.ts | 2 +- .../workflow-run.workspace-entity.ts | 4 +- .../workflow-version.workspace-entity.ts | 2 +- .../workflow.workspace-entity.ts | 14 +- .../workspace-member.workspace-entity.ts | 2 +- .../roles.integration-spec.ts | 22 + .../agent-tool.service.integration-spec.ts | 5 + .../agent/utils/agent-tool-test-utils.ts | 2 + .../src/types/ObjectRecordsPermissions.ts | 11 +- 49 files changed, 1343 insertions(+), 47 deletions(-) create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1751890088507-addIndexOnObjectPermission.ts create mode 100644 packages/twenty-server/src/database/typeorm/core/migrations/common/1751993324990-addFieldPermission.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/index-metadata/types/indexType.types.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/object-permission/dtos/field-permission.dto.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/object-permission/dtos/upsert-field-permissions.input.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/object-permission/field-permission/__tests__/field-permissions.service.spec.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/object-permission/field-permission/field-permission.entity.ts create mode 100644 packages/twenty-server/src/engine/metadata-modules/object-permission/field-permission/field-permission.service.ts diff --git a/packages/twenty-front/src/generated-metadata/graphql.ts b/packages/twenty-front/src/generated-metadata/graphql.ts index 6b9d9e5b0..415dfdcea 100644 --- a/packages/twenty-front/src/generated-metadata/graphql.ts +++ b/packages/twenty-front/src/generated-metadata/graphql.ts @@ -678,6 +678,7 @@ export type FeatureFlagDto = { export enum FeatureFlagKey { IS_AIRTABLE_INTEGRATION_ENABLED = 'IS_AIRTABLE_INTEGRATION_ENABLED', IS_AI_ENABLED = 'IS_AI_ENABLED', + IS_FIELDS_PERMISSIONS_ENABLED = 'IS_FIELDS_PERMISSIONS_ENABLED', IS_IMAP_ENABLED = 'IS_IMAP_ENABLED', IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED', IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED', @@ -766,6 +767,23 @@ export enum FieldMetadataType { UUID = 'UUID' } +export type FieldPermission = { + __typename?: 'FieldPermission'; + canReadFieldValue?: Maybe; + canUpdateFieldValue?: Maybe; + fieldMetadataId: Scalars['String']; + id: Scalars['String']; + objectMetadataId: Scalars['String']; + roleId: Scalars['String']; +}; + +export type FieldPermissionInput = { + canReadFieldValue?: InputMaybe; + canUpdateFieldValue?: InputMaybe; + fieldMetadataId: Scalars['String']; + objectMetadataId: Scalars['String']; +}; + export enum FileFolder { Attachment = 'Attachment', PersonPicture = 'PersonPicture', @@ -1076,6 +1094,7 @@ export type Mutation = { uploadImage: SignedFileDto; uploadProfilePicture: SignedFileDto; uploadWorkspaceLogo: SignedFileDto; + upsertFieldPermissions: Array; upsertObjectPermissions: Array; upsertSettingPermissions: Array; userLookupAdminPanel: UserLookup; @@ -1494,6 +1513,11 @@ export type MutationUploadWorkspaceLogoArgs = { }; +export type MutationUpsertFieldPermissionsArgs = { + upsertFieldPermissionsInput: UpsertFieldPermissionsInput; +}; + + export type MutationUpsertObjectPermissionsArgs = { upsertObjectPermissionsInput: UpsertObjectPermissionsInput; }; @@ -2484,6 +2508,11 @@ export type UpdateWorkspaceInput = { subdomain?: InputMaybe; }; +export type UpsertFieldPermissionsInput = { + fieldPermissions: Array; + roleId: Scalars['String']; +}; + export type UpsertObjectPermissionsInput = { objectPermissions: Array; roleId: Scalars['String']; diff --git a/packages/twenty-front/src/generated/graphql.ts b/packages/twenty-front/src/generated/graphql.ts index b13566479..8de31c917 100644 --- a/packages/twenty-front/src/generated/graphql.ts +++ b/packages/twenty-front/src/generated/graphql.ts @@ -642,6 +642,7 @@ export type FeatureFlagDto = { export enum FeatureFlagKey { IS_AIRTABLE_INTEGRATION_ENABLED = 'IS_AIRTABLE_INTEGRATION_ENABLED', IS_AI_ENABLED = 'IS_AI_ENABLED', + IS_FIELDS_PERMISSIONS_ENABLED = 'IS_FIELDS_PERMISSIONS_ENABLED', IS_IMAP_ENABLED = 'IS_IMAP_ENABLED', IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED', IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED', @@ -730,6 +731,23 @@ export enum FieldMetadataType { UUID = 'UUID' } +export type FieldPermission = { + __typename?: 'FieldPermission'; + canReadFieldValue?: Maybe; + canUpdateFieldValue?: Maybe; + fieldMetadataId: Scalars['String']; + id: Scalars['String']; + objectMetadataId: Scalars['String']; + roleId: Scalars['String']; +}; + +export type FieldPermissionInput = { + canReadFieldValue?: InputMaybe; + canUpdateFieldValue?: InputMaybe; + fieldMetadataId: Scalars['String']; + objectMetadataId: Scalars['String']; +}; + export enum FileFolder { Attachment = 'Attachment', PersonPicture = 'PersonPicture', @@ -1027,6 +1045,7 @@ export type Mutation = { uploadImage: SignedFileDto; uploadProfilePicture: SignedFileDto; uploadWorkspaceLogo: SignedFileDto; + upsertFieldPermissions: Array; upsertObjectPermissions: Array; upsertSettingPermissions: Array; userLookupAdminPanel: UserLookup; @@ -1405,6 +1424,11 @@ export type MutationUploadWorkspaceLogoArgs = { }; +export type MutationUpsertFieldPermissionsArgs = { + upsertFieldPermissionsInput: UpsertFieldPermissionsInput; +}; + + export type MutationUpsertObjectPermissionsArgs = { upsertObjectPermissionsInput: UpsertObjectPermissionsInput; }; @@ -2322,6 +2346,11 @@ export type UpdateWorkspaceInput = { subdomain?: InputMaybe; }; +export type UpsertFieldPermissionsInput = { + fieldPermissions: Array; + roleId: Scalars['String']; +}; + export type UpsertObjectPermissionsInput = { objectPermissions: Array; roleId: Scalars['String']; diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1751890088507-addIndexOnObjectPermission.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1751890088507-addIndexOnObjectPermission.ts new file mode 100644 index 000000000..3647c8130 --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1751890088507-addIndexOnObjectPermission.ts @@ -0,0 +1,19 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddIndexOnObjectPermission1751890088507 + implements MigrationInterface +{ + name = 'AddIndexOnObjectPermission1751890088507'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE INDEX "IDX_OBJECT_PERMISSION_WORKSPACE_ID_ROLE_ID" ON "core"."objectPermission" ("workspaceId", "roleId") `, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DROP INDEX "core"."IDX_OBJECT_PERMISSION_WORKSPACE_ID_ROLE_ID"`, + ); + } +} diff --git a/packages/twenty-server/src/database/typeorm/core/migrations/common/1751993324990-addFieldPermission.ts b/packages/twenty-server/src/database/typeorm/core/migrations/common/1751993324990-addFieldPermission.ts new file mode 100644 index 000000000..2745c7e6c --- /dev/null +++ b/packages/twenty-server/src/database/typeorm/core/migrations/common/1751993324990-addFieldPermission.ts @@ -0,0 +1,45 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddFieldPermission1751993324990 implements MigrationInterface { + name = 'AddFieldPermission1751993324990'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TABLE "core"."fieldPermission" ("id" uuid NOT NULL DEFAULT uuid_generate_v4(), "roleId" uuid NOT NULL, "objectMetadataId" uuid NOT NULL, "fieldMetadataId" uuid NOT NULL, "canReadFieldValue" boolean, "canUpdateFieldValue" boolean, "workspaceId" uuid NOT NULL, "createdAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), "updatedAt" TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT now(), CONSTRAINT "IDX_FIELD_PERMISSION_FIELD_METADATA_ID_ROLE_ID_UNIQUE" UNIQUE ("fieldMetadataId", "roleId"), CONSTRAINT "PK_d7bb911e4f9b1b5e3bfcfdd1c4b" PRIMARY KEY ("id"))`, + ); + await queryRunner.query( + `CREATE INDEX "IDX_FIELD_PERMISSION_WORKSPACE_ID_ROLE_ID" ON "core"."fieldPermission" ("workspaceId", "roleId") `, + ); + await queryRunner.query( + `ALTER TABLE "core"."fieldPermission" ADD CONSTRAINT "FK_bbf16a91f5a10199e5b18c019ba" FOREIGN KEY ("roleId") REFERENCES "core"."role"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "core"."fieldPermission" ADD CONSTRAINT "FK_dc8e552397f5e44d175fedf752a" FOREIGN KEY ("objectMetadataId") REFERENCES "core"."objectMetadata"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "core"."fieldPermission" ADD CONSTRAINT "FK_d5c47a26fe71648894d05da3d3a" FOREIGN KEY ("fieldMetadataId") REFERENCES "core"."fieldMetadata"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + await queryRunner.query( + `ALTER TABLE "core"."fieldPermission" ADD CONSTRAINT "FK_2763aee5614b54019d692333fe1" FOREIGN KEY ("workspaceId") REFERENCES "core"."workspace"("id") ON DELETE CASCADE ON UPDATE NO ACTION`, + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `ALTER TABLE "core"."fieldPermission" DROP CONSTRAINT "FK_2763aee5614b54019d692333fe1"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."fieldPermission" DROP CONSTRAINT "FK_d5c47a26fe71648894d05da3d3a"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."fieldPermission" DROP CONSTRAINT "FK_dc8e552397f5e44d175fedf752a"`, + ); + await queryRunner.query( + `ALTER TABLE "core"."fieldPermission" DROP CONSTRAINT "FK_bbf16a91f5a10199e5b18c019ba"`, + ); + await queryRunner.query( + `DROP INDEX "core"."IDX_FIELD_PERMISSION_WORKSPACE_ID_ROLE_ID"`, + ); + await queryRunner.query(`DROP TABLE "core"."fieldPermission"`); + } +} diff --git a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts index 3c2bc881a..4560196ac 100644 --- a/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts +++ b/packages/twenty-server/src/engine/core-modules/feature-flag/enums/feature-flag-key.enum.ts @@ -9,4 +9,5 @@ export enum FeatureFlagKey { IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED', IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED', IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED', + IS_FIELDS_PERMISSIONS_ENABLED = 'IS_FIELDS_PERMISSIONS_ENABLED', } diff --git a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts index 510b606f2..73e088479 100644 --- a/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/field-metadata/field-metadata.entity.ts @@ -20,6 +20,7 @@ import { FieldMetadataSettings } from 'src/engine/metadata-modules/field-metadat import { FieldStandardOverridesDTO } from 'src/engine/metadata-modules/field-metadata/dtos/field-standard-overrides.dto'; import { IndexFieldMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-field-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'; @Entity('fieldMetadata') // max length of index is 63 characters @@ -148,4 +149,10 @@ export class FieldMetadataEntity< @UpdateDateColumn({ type: 'timestamptz' }) updatedAt: Date; + + @OneToMany( + () => FieldPermissionEntity, + (fieldPermission: FieldPermissionEntity) => fieldPermission.fieldMetadata, + ) + fieldPermissions: Relation; } diff --git a/packages/twenty-server/src/engine/metadata-modules/index-metadata/dtos/index-metadata.dto.ts b/packages/twenty-server/src/engine/metadata-modules/index-metadata/dtos/index-metadata.dto.ts index 46aca674f..f550328d6 100644 --- a/packages/twenty-server/src/engine/metadata-modules/index-metadata/dtos/index-metadata.dto.ts +++ b/packages/twenty-server/src/engine/metadata-modules/index-metadata/dtos/index-metadata.dto.ts @@ -25,7 +25,7 @@ import { import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; import { IsValidMetadataName } from 'src/engine/decorators/metadata/is-valid-metadata-name.decorator'; import { IndexFieldMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dtos/index-field-metadata.dto'; -import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; +import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types'; import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto'; registerEnumType(IndexType, { diff --git a/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.entity.ts b/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.entity.ts index 7a2f58b34..de41038f0 100644 --- a/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.entity.ts @@ -13,13 +13,9 @@ import { } from 'typeorm'; import { IndexFieldMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-field-metadata.entity'; +import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; -export enum IndexType { - BTREE = 'BTREE', - GIN = 'GIN', -} - @Unique('IDX_INDEX_METADATA_NAME_WORKSPACE_ID_OBJECT_METADATA_ID_UNIQUE', [ 'name', 'workspaceId', diff --git a/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.service.ts b/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.service.ts index 256166b0c..f3f2856e3 100644 --- a/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/index-metadata/index-metadata.service.ts @@ -6,10 +6,8 @@ import { isDefined } from 'twenty-shared/utils'; import { QueryRunner, Repository } from 'typeorm'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; -import { - IndexMetadataEntity, - IndexType, -} from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; +import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; +import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types'; import { generateDeterministicIndexName } from 'src/engine/metadata-modules/index-metadata/utils/generate-deterministic-index-name'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util'; diff --git a/packages/twenty-server/src/engine/metadata-modules/index-metadata/interfaces/index-metadata.interface.ts b/packages/twenty-server/src/engine/metadata-modules/index-metadata/interfaces/index-metadata.interface.ts index 01c3afef2..0a800b822 100644 --- a/packages/twenty-server/src/engine/metadata-modules/index-metadata/interfaces/index-metadata.interface.ts +++ b/packages/twenty-server/src/engine/metadata-modules/index-metadata/interfaces/index-metadata.interface.ts @@ -1,6 +1,6 @@ import { IndexFieldMetadataInterface } from 'src/engine/metadata-modules/index-metadata/interfaces/index-field-metadata.interface'; -import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; +import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types'; export interface IndexMetadataInterface { id: string; diff --git a/packages/twenty-server/src/engine/metadata-modules/index-metadata/types/indexType.types.ts b/packages/twenty-server/src/engine/metadata-modules/index-metadata/types/indexType.types.ts new file mode 100644 index 000000000..6a7493c9e --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/index-metadata/types/indexType.types.ts @@ -0,0 +1,4 @@ +export enum IndexType { + BTREE = 'BTREE', + GIN = 'GIN', +} diff --git a/packages/twenty-server/src/engine/metadata-modules/index-metadata/utils/__tests__/getUniqueConstraintsFields.util.spec.ts b/packages/twenty-server/src/engine/metadata-modules/index-metadata/utils/__tests__/getUniqueConstraintsFields.util.spec.ts index 454f36f92..ae79039f6 100644 --- a/packages/twenty-server/src/engine/metadata-modules/index-metadata/utils/__tests__/getUniqueConstraintsFields.util.spec.ts +++ b/packages/twenty-server/src/engine/metadata-modules/index-metadata/utils/__tests__/getUniqueConstraintsFields.util.spec.ts @@ -5,7 +5,7 @@ import { ObjectMetadataInterface } from 'src/engine/metadata-modules/field-metad import { IndexFieldMetadataInterface } from 'src/engine/metadata-modules/index-metadata/interfaces/index-field-metadata.interface'; import { IndexMetadataInterface } from 'src/engine/metadata-modules/index-metadata/interfaces/index-metadata.interface'; -import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; +import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types'; import { getUniqueConstraintsFields } from 'src/engine/metadata-modules/index-metadata/utils/getUniqueConstraintsFields.util'; describe('getUniqueConstraintsFields', () => { diff --git a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.entity.ts b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.entity.ts index a951cc7b5..5e8eb2c0e 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-metadata/object-metadata.entity.ts @@ -17,6 +17,7 @@ import { DataSourceEntity } from 'src/engine/metadata-modules/data-source/data-s import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; import { ObjectStandardOverridesDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-standard-overrides.dto'; +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'; @Entity('objectMetadata') @@ -134,4 +135,13 @@ export class ObjectMetadataEntity implements ObjectMetadataInterface { }, ) objectPermissions: Relation; + + @OneToMany( + () => FieldPermissionEntity, + (fieldPermission: FieldPermissionEntity) => fieldPermission.objectMetadata, + { + cascade: true, + }, + ) + fieldPermissions: Relation; } diff --git a/packages/twenty-server/src/engine/metadata-modules/object-permission/dtos/field-permission.dto.ts b/packages/twenty-server/src/engine/metadata-modules/object-permission/dtos/field-permission.dto.ts new file mode 100644 index 000000000..f12375978 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/object-permission/dtos/field-permission.dto.ts @@ -0,0 +1,22 @@ +import { Field, ObjectType } from '@nestjs/graphql'; + +@ObjectType('FieldPermission') +export class FieldPermissionDTO { + @Field({ nullable: false }) + id: string; + + @Field({ nullable: false }) + objectMetadataId: string; + + @Field({ nullable: false }) + fieldMetadataId: string; + + @Field({ nullable: false }) + roleId: string; + + @Field(() => Boolean, { nullable: true }) + canReadFieldValue?: boolean | null; + + @Field(() => Boolean, { nullable: true }) + canUpdateFieldValue?: boolean | null; +} diff --git a/packages/twenty-server/src/engine/metadata-modules/object-permission/dtos/upsert-field-permissions.input.ts b/packages/twenty-server/src/engine/metadata-modules/object-permission/dtos/upsert-field-permissions.input.ts new file mode 100644 index 000000000..b0459918a --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/object-permission/dtos/upsert-field-permissions.input.ts @@ -0,0 +1,47 @@ +import { Field, InputType } from '@nestjs/graphql'; + +import { + ArrayMinSize, + IsArray, + IsBoolean, + IsNotEmpty, + IsOptional, + IsUUID, +} from 'class-validator'; + +@InputType() +export class UpsertFieldPermissionsInput { + @IsUUID() + @IsNotEmpty() + @Field() + roleId: string; + + @IsArray() + @ArrayMinSize(1) + @IsNotEmpty() + @Field(() => [FieldPermissionInput]) + fieldPermissions: FieldPermissionInput[]; +} + +@InputType() +export class FieldPermissionInput { + @IsUUID() + @IsNotEmpty() + @Field() + objectMetadataId: string; + + @IsUUID() + @IsNotEmpty() + @Field() + fieldMetadataId: string; + + @IsBoolean() + @IsOptional() + @Field(() => Boolean, { nullable: true }) + canReadFieldValue?: boolean | null; + + @IsBoolean() + @IsOptional() + @Field(() => Boolean, { nullable: true }) + canUpdateFieldValue?: boolean | null; +} 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 new file mode 100644 index 000000000..89cd5aa15 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/object-permission/field-permission/__tests__/field-permissions.service.spec.ts @@ -0,0 +1,585 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; + +import { ObjectRecordsPermissionsByRoleId } from 'twenty-shared/types'; +import { In, Repository } from 'typeorm'; + +import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata.interface'; + +import { + fieldTextMock, + objectMetadataItemMock, +} from 'src/engine/api/__mocks__/object-metadata-item.mock'; +import { 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 { FieldPermissionService } from 'src/engine/metadata-modules/object-permission/field-permission/field-permission.service'; +import { + PermissionsException, + PermissionsExceptionCode, + PermissionsExceptionMessage, +} from 'src/engine/metadata-modules/permissions/permissions.exception'; +import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; +import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service'; +import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; + +describe('FieldPermissionService', () => { + let service: FieldPermissionService; + let fieldPermissionsRepository: jest.Mocked< + Repository + >; + let roleRepository: jest.Mocked>; + let workspacePermissionsCacheService: jest.Mocked; + let workspaceCacheStorageService: jest.Mocked; + + const testWorkspaceId = 'test-workspace-id'; + const testRoleId = 'test-role-id'; + const testObjectMetadataId = 'test-object-metadata-id'; + const testFieldMetadataId = fieldTextMock.id; + + const mockRole: RoleEntity = { + id: testRoleId, + label: 'Test Role', + description: 'Test role for unit tests', + canUpdateAllSettings: false, + canReadAllObjectRecords: true, + canUpdateAllObjectRecords: false, + canSoftDeleteAllObjectRecords: false, + canDestroyAllObjectRecords: false, + workspaceId: testWorkspaceId, + createdAt: new Date(), + updatedAt: new Date(), + isEditable: true, + } as RoleEntity; + + const mockRolesPermissions: ObjectRecordsPermissionsByRoleId = { + [testRoleId]: { + [testObjectMetadataId]: { + canRead: true, + canUpdate: true, + canSoftDelete: false, + canDestroy: false, + restrictedFields: {}, + }, + }, + }; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + FieldPermissionService, + { + provide: getRepositoryToken(RoleEntity, 'core'), + useValue: { + findOne: jest.fn(), + }, + }, + { + provide: getRepositoryToken(FieldPermissionEntity, 'core'), + useValue: { + find: jest.fn(), + upsert: jest.fn(), + delete: jest.fn(), + }, + }, + { + provide: WorkspacePermissionsCacheService, + useValue: { + getRolesPermissionsFromCache: jest.fn(), + recomputeRolesPermissionsCache: jest.fn(), + }, + }, + { + provide: WorkspaceCacheStorageService, + useValue: { + getObjectMetadataMapsOrThrow: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(FieldPermissionService); + fieldPermissionsRepository = module.get( + getRepositoryToken(FieldPermissionEntity, 'core'), + ); + roleRepository = module.get(getRepositoryToken(RoleEntity, 'core')); + workspacePermissionsCacheService = module.get( + WorkspacePermissionsCacheService, + ); + workspaceCacheStorageService = module.get(WorkspaceCacheStorageService); + + // Setup default mocks + roleRepository.findOne.mockResolvedValue(mockRole); + workspacePermissionsCacheService.getRolesPermissionsFromCache.mockResolvedValue( + { + version: '1', + data: mockRolesPermissions, + }, + ); + workspaceCacheStorageService.getObjectMetadataMapsOrThrow.mockResolvedValue( + { + byId: { + [testObjectMetadataId]: { + ...objectMetadataItemMock, + fieldsById: { + [fieldTextMock.id]: { + ...fieldTextMock, + label: 'Test Field', + objectMetadataId: testObjectMetadataId, + } as FieldMetadataInterface, + }, + fieldIdByJoinColumnName: {}, + fieldIdByName: {}, + indexMetadatas: [], + }, + }, + idByNameSingular: { + testObject: testObjectMetadataId, + }, + }, + ); + fieldPermissionsRepository.find.mockResolvedValue([]); + fieldPermissionsRepository.upsert.mockResolvedValue({} as any); + fieldPermissionsRepository.delete.mockResolvedValue({} as any); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('upsertFieldPermissions', () => { + const createUpsertInput = ( + fieldPermissions: { + canReadFieldValue?: boolean | null; + canUpdateFieldValue?: boolean | null; + objectMetadataId?: string; + fieldMetadataId?: string; + }[], + ): UpsertFieldPermissionsInput => ({ + roleId: testRoleId, + fieldPermissions: fieldPermissions.map((fieldPermission) => ({ + objectMetadataId: testObjectMetadataId, + fieldMetadataId: testFieldMetadataId, + ...fieldPermission, + })), + }); + + describe('successful cases', () => { + it('should successfully upsert field permissions', async () => { + const input = createUpsertInput([ + { + canReadFieldValue: false, + canUpdateFieldValue: false, + }, + ]); + + 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, + }), + ]), + { + conflictPaths: ['fieldMetadataId', 'roleId'], + }, + ); + + expect( + workspacePermissionsCacheService.recomputeRolesPermissionsCache, + ).toHaveBeenCalledWith({ + workspaceId: testWorkspaceId, + roleIds: [testRoleId], + }); + }); + + 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 input = createUpsertInput([ + { + canUpdateFieldValue: null, + }, + ]); + + await service.upsertFieldPermissions({ + workspaceId: testWorkspaceId, + input, + }); + + expect(fieldPermissionsRepository.delete).toHaveBeenCalledWith({ + id: In(['existing-field-permission-id']), + }); + }); + + 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 input = createUpsertInput([ + { + canUpdateFieldValue: false, + canReadFieldValue: null, + }, + ]); + + await service.upsertFieldPermissions({ + workspaceId: testWorkspaceId, + input, + }); + + expect(fieldPermissionsRepository.delete).not.toHaveBeenCalled(); + }); + }); + + describe('validation errors', () => { + it('should throw error when canReadFieldValue is true', async () => { + const input = createUpsertInput([ + { + canReadFieldValue: true, + canUpdateFieldValue: false, + }, + ]); + + await expect( + service.upsertFieldPermissions({ + workspaceId: testWorkspaceId, + input, + }), + ).rejects.toThrow( + new PermissionsException( + PermissionsExceptionMessage.ONLY_FIELD_RESTRICTION_ALLOWED, + PermissionsExceptionCode.ONLY_FIELD_RESTRICTION_ALLOWED, + ), + ); + }); + + it('should throw error when canUpdateFieldValue is true', async () => { + const input = createUpsertInput([ + { + canReadFieldValue: false, + canUpdateFieldValue: true, + }, + ]); + + await expect( + service.upsertFieldPermissions({ + workspaceId: testWorkspaceId, + input, + }), + ).rejects.toThrow( + new PermissionsException( + PermissionsExceptionMessage.ONLY_FIELD_RESTRICTION_ALLOWED, + PermissionsExceptionCode.ONLY_FIELD_RESTRICTION_ALLOWED, + ), + ); + }); + + it('should throw error when object metadata is not found', async () => { + workspaceCacheStorageService.getObjectMetadataMapsOrThrow.mockResolvedValue( + { + byId: {}, + idByNameSingular: {}, + }, + ); + + const input = createUpsertInput([ + { + objectMetadataId: 'non-existent-object', + canReadFieldValue: false, + canUpdateFieldValue: false, + }, + ]); + + await expect( + service.upsertFieldPermissions({ + workspaceId: testWorkspaceId, + input, + }), + ).rejects.toThrow( + new PermissionsException( + PermissionsExceptionMessage.OBJECT_METADATA_NOT_FOUND, + PermissionsExceptionCode.OBJECT_METADATA_NOT_FOUND, + ), + ); + }); + + it('should throw error when trying to add field permission on system object', async () => { + const systemObjectMetadata = { + ...objectMetadataItemMock, + isSystem: true, + }; + + workspaceCacheStorageService.getObjectMetadataMapsOrThrow.mockResolvedValue( + { + byId: { + [testObjectMetadataId]: { + ...systemObjectMetadata, + fieldsById: {}, + fieldIdByJoinColumnName: {}, + fieldIdByName: {}, + indexMetadatas: [], + }, + }, + idByNameSingular: { + testObject: testObjectMetadataId, + }, + }, + ); + + const input = createUpsertInput([ + { + canReadFieldValue: false, + canUpdateFieldValue: false, + }, + ]); + + await expect( + service.upsertFieldPermissions({ + workspaceId: testWorkspaceId, + input, + }), + ).rejects.toThrow( + new PermissionsException( + PermissionsExceptionMessage.CANNOT_ADD_FIELD_PERMISSION_ON_SYSTEM_OBJECT, + PermissionsExceptionCode.CANNOT_ADD_FIELD_PERMISSION_ON_SYSTEM_OBJECT, + ), + ); + }); + + it('should throw error when field metadata is not found', async () => { + const objectMetadataWithoutField = { + ...objectMetadataItemMock, + fieldsById: {}, + fieldIdByJoinColumnName: {}, + fieldIdByName: {}, + indexMetadatas: [], + }; + + workspaceCacheStorageService.getObjectMetadataMapsOrThrow.mockResolvedValue( + { + byId: { + [testObjectMetadataId]: objectMetadataWithoutField, + }, + idByNameSingular: { + testObject: testObjectMetadataId, + }, + }, + ); + + const input = createUpsertInput([ + { + fieldMetadataId: 'non-existent-field', + canReadFieldValue: false, + canUpdateFieldValue: false, + }, + ]); + + await expect( + service.upsertFieldPermissions({ + workspaceId: testWorkspaceId, + input, + }), + ).rejects.toThrow( + new PermissionsException( + PermissionsExceptionMessage.FIELD_METADATA_NOT_FOUND, + PermissionsExceptionCode.FIELD_METADATA_NOT_FOUND, + ), + ); + }); + + it('should throw error when object permission is not found', async () => { + workspacePermissionsCacheService.getRolesPermissionsFromCache.mockResolvedValue( + { + version: '1', + data: {}, + }, + ); + + const input = createUpsertInput([ + { + canReadFieldValue: false, + canUpdateFieldValue: false, + }, + ]); + + await expect( + service.upsertFieldPermissions({ + workspaceId: testWorkspaceId, + input, + }), + ).rejects.toThrow( + new PermissionsException( + PermissionsExceptionMessage.OBJECT_PERMISSION_NOT_FOUND, + PermissionsExceptionCode.OBJECT_PERMISSION_NOT_FOUND, + ), + ); + }); + + it('should throw error when object is not readable (permission wise)', async () => { + const nonReadableObjectPermissions: ObjectRecordsPermissionsByRoleId = { + [testRoleId]: { + [testObjectMetadataId]: { + canRead: false, + canUpdate: false, + canSoftDelete: false, + canDestroy: false, + restrictedFields: {}, + }, + }, + }; + + workspacePermissionsCacheService.getRolesPermissionsFromCache.mockResolvedValue( + { + version: '1', + data: nonReadableObjectPermissions, + }, + ); + + const input = createUpsertInput([ + { + canUpdateFieldValue: false, + }, + ]); + + await expect( + service.upsertFieldPermissions({ + workspaceId: testWorkspaceId, + input, + }), + ).rejects.toThrow( + new PermissionsException( + PermissionsExceptionMessage.FIELD_RESTRICTION_ONLY_ALLOWED_ON_READABLE_OBJECT, + PermissionsExceptionCode.FIELD_RESTRICTION_ONLY_ALLOWED_ON_READABLE_OBJECT, + ), + ); + }); + + it('should throw error when trying to restrict update on non-updatable object', async () => { + const nonUpdatableObjectPermissions: ObjectRecordsPermissionsByRoleId = + { + [testRoleId]: { + [testObjectMetadataId]: { + canRead: true, + canUpdate: false, + canSoftDelete: false, + canDestroy: false, + restrictedFields: {}, + }, + }, + }; + + workspacePermissionsCacheService.getRolesPermissionsFromCache.mockResolvedValue( + { + version: '1', + data: nonUpdatableObjectPermissions, + }, + ); + + const input = createUpsertInput([ + { + canUpdateFieldValue: false, + }, + ]); + + await expect( + service.upsertFieldPermissions({ + workspaceId: testWorkspaceId, + input, + }), + ).rejects.toThrow( + new PermissionsException( + PermissionsExceptionMessage.FIELD_RESTRICTION_ON_UPDATE_ONLY_ALLOWED_ON_UPDATABLE_OBJECT, + PermissionsExceptionCode.FIELD_RESTRICTION_ON_UPDATE_ONLY_ALLOWED_ON_UPDATABLE_OBJECT, + ), + ); + }); + + it('should throw error when both canReadFieldValue and canUpdateFieldValue are null', async () => { + const input = createUpsertInput([ + { + canReadFieldValue: null, + canUpdateFieldValue: null, + }, + ]); + + await expect( + service.upsertFieldPermissions({ + workspaceId: testWorkspaceId, + input, + }), + ).rejects.toThrow( + new PermissionsException( + PermissionsExceptionMessage.EMPTY_FIELD_PERMISSION_NOT_ALLOWED, + PermissionsExceptionCode.EMPTY_FIELD_PERMISSION_NOT_ALLOWED, + ), + ); + }); + }); + + describe('role validation errors', () => { + it('should throw error when role is not editable', async () => { + const nonEditableRole: RoleEntity = { + ...mockRole, + isEditable: false, + }; + + roleRepository.findOne.mockResolvedValue(nonEditableRole); + + const input = createUpsertInput([ + { + canReadFieldValue: false, + canUpdateFieldValue: false, + }, + ]); + + await expect( + service.upsertFieldPermissions({ + workspaceId: testWorkspaceId, + input, + }), + ).rejects.toThrow( + new PermissionsException( + PermissionsExceptionMessage.ROLE_NOT_EDITABLE, + PermissionsExceptionCode.ROLE_NOT_EDITABLE, + ), + ); + }); + }); + }); +}); 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 new file mode 100644 index 000000000..b72bd3322 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/object-permission/field-permission/field-permission.entity.ts @@ -0,0 +1,92 @@ +import { ValidateBy } from 'class-validator'; +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + Relation, + Unique, + UpdateDateColumn, +} from 'typeorm'; + +import { Workspace } from 'src/engine/core-modules/workspace/workspace.entity'; +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'; + +@Entity('fieldPermission') +@Unique('IDX_FIELD_PERMISSION_FIELD_METADATA_ID_ROLE_ID_UNIQUE', [ + 'fieldMetadataId', + 'roleId', +]) +@Index('IDX_FIELD_PERMISSION_WORKSPACE_ID_ROLE_ID', ['workspaceId', 'roleId']) +export class FieldPermissionEntity { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ nullable: false, type: 'uuid' }) + roleId: string; + + @ManyToOne(() => RoleEntity, (role) => role.fieldPermissions, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'roleId' }) + role: Relation; + + @Column({ nullable: false, type: 'uuid' }) + objectMetadataId: string; + + @Column({ nullable: false, type: 'uuid' }) + fieldMetadataId: string; + + @ManyToOne( + () => ObjectMetadataEntity, + (objectMetadata) => objectMetadata.fieldPermissions, + { + onDelete: 'CASCADE', + }, + ) + @JoinColumn({ name: 'objectMetadataId' }) + objectMetadata: Relation; + + @ManyToOne( + () => FieldMetadataEntity, + (fieldMetadata) => fieldMetadata.fieldPermissions, + { + onDelete: 'CASCADE', + }, + ) + @JoinColumn({ name: 'fieldMetadataId' }) + fieldMetadata: Relation; + + @Column({ nullable: true, type: 'boolean' }) + canReadFieldValue?: boolean | null; + + @ValidateBy({ + name: 'isFalseOrNull', + validator: { + validate: (value: boolean | null) => value === false || value === null, + defaultMessage: () => 'value must be either false or null', + }, + }) + @Column({ nullable: true, type: 'boolean' }) + canUpdateFieldValue?: boolean | null; + + @Column({ nullable: false, type: 'uuid' }) + workspaceId: string; + + @ManyToOne(() => Workspace, { + onDelete: 'CASCADE', + }) + @JoinColumn({ name: 'workspaceId' }) + workspace: Relation; + + @CreateDateColumn({ type: 'timestamptz' }) + createdAt: Date; + + @UpdateDateColumn({ type: 'timestamptz' }) + updatedAt: Date; +} 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 new file mode 100644 index 000000000..1f51fab49 --- /dev/null +++ b/packages/twenty-server/src/engine/metadata-modules/object-permission/field-permission/field-permission.service.ts @@ -0,0 +1,281 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; + +import { ObjectRecordsPermissionsByRoleId } from 'twenty-shared/types'; +import { isDefined } from 'twenty-shared/utils'; +import { In, Repository } from 'typeorm'; + +import { 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 { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps'; +import { WorkspacePermissionsCacheService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service'; +import { WorkspaceCacheStorageService } from 'src/engine/workspace-cache-storage/workspace-cache-storage.service'; + +@Injectable() +export class FieldPermissionService { + constructor( + @InjectRepository(RoleEntity, 'core') + private readonly roleRepository: Repository, + @InjectRepository(FieldPermissionEntity, 'core') + private readonly fieldPermissionsRepository: Repository, + private readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService, + private readonly workspaceCacheStorageService: WorkspaceCacheStorageService, + ) {} + + public async upsertFieldPermissions({ + workspaceId, + input, + }: { + workspaceId: string; + input: UpsertFieldPermissionsInput; + }): Promise { + const role = await this.getRoleOrThrow({ + roleId: input.roleId, + workspaceId, + }); + + const { data: rolesPermissions } = + await this.workspacePermissionsCacheService.getRolesPermissionsFromCache({ + workspaceId, + }); + + await this.validateRoleIsEditableOrThrow({ + role, + }); + + const { byId: objectMetadataMapsById } = + await this.workspaceCacheStorageService.getObjectMetadataMapsOrThrow( + workspaceId, + ); + + const existingFieldPermissions = await this.fieldPermissionsRepository.find( + { + where: { + roleId: input.roleId, + workspaceId, + }, + }, + ); + + const fieldPermissionsToDeleteIds: string[] = []; + + input.fieldPermissions.forEach((fieldPermission) => { + this.validateFieldPermission({ + fieldPermission, + objectMetadataMapsById, + rolesPermissions, + role, + }); + + if ( + fieldPermission.canReadFieldValue === null || + fieldPermission.canUpdateFieldValue === null + ) { + this.checkIfFieldPermissionShouldBeDeleted({ + fieldPermission, + existingFieldPermissions, + fieldPermissionsToDeleteIds, + }); + } + }); + + const fieldPermissions = input.fieldPermissions.map((fieldPermission) => ({ + ...fieldPermission, + roleId: input.roleId, + workspaceId, + })); + + await this.fieldPermissionsRepository.upsert(fieldPermissions, { + conflictPaths: ['fieldMetadataId', 'roleId'], + }); + + if (fieldPermissionsToDeleteIds.length > 0) { + await this.fieldPermissionsRepository.delete({ + id: In(fieldPermissionsToDeleteIds), + }); + } + + await this.workspacePermissionsCacheService.recomputeRolesPermissionsCache({ + workspaceId, + roleIds: [input.roleId], + }); + + return this.fieldPermissionsRepository.find({ + where: { + roleId: input.roleId, + objectMetadataId: In( + input.fieldPermissions.map( + (fieldPermission) => fieldPermission.objectMetadataId, + ), + ), + workspaceId, + }, + }); + } + + private validateFieldPermission({ + fieldPermission, + objectMetadataMapsById, + rolesPermissions, + role, + }: { + fieldPermission: UpsertFieldPermissionsInput['fieldPermissions'][0]; + objectMetadataMapsById: Record; + rolesPermissions: ObjectRecordsPermissionsByRoleId; + role: RoleEntity; + }) { + if ( + ('canUpdateFieldValue' in fieldPermission && + fieldPermission.canUpdateFieldValue !== null && + fieldPermission.canUpdateFieldValue !== false) || + ('canReadFieldValue' in fieldPermission && + fieldPermission.canReadFieldValue !== null && + fieldPermission.canReadFieldValue !== false) + ) { + throw new PermissionsException( + PermissionsExceptionMessage.ONLY_FIELD_RESTRICTION_ALLOWED, + PermissionsExceptionCode.ONLY_FIELD_RESTRICTION_ALLOWED, + ); + } + + const objectMetadataForFieldPermission = + objectMetadataMapsById[fieldPermission.objectMetadataId]; + + if (!isDefined(objectMetadataForFieldPermission)) { + throw new PermissionsException( + PermissionsExceptionMessage.OBJECT_METADATA_NOT_FOUND, + PermissionsExceptionCode.OBJECT_METADATA_NOT_FOUND, + ); + } + + if (objectMetadataForFieldPermission.isSystem === true) { + throw new PermissionsException( + PermissionsExceptionMessage.CANNOT_ADD_FIELD_PERMISSION_ON_SYSTEM_OBJECT, + PermissionsExceptionCode.CANNOT_ADD_FIELD_PERMISSION_ON_SYSTEM_OBJECT, + ); + } + + const fieldMetadataForFieldPermission = + objectMetadataForFieldPermission.fieldsById[ + fieldPermission.fieldMetadataId + ]; + + if (!isDefined(fieldMetadataForFieldPermission)) { + throw new PermissionsException( + PermissionsExceptionMessage.FIELD_METADATA_NOT_FOUND, + PermissionsExceptionCode.FIELD_METADATA_NOT_FOUND, + ); + } + + const rolePermissionOnObject = + rolesPermissions?.[role.id]?.[fieldPermission.objectMetadataId]; + + if (!isDefined(rolePermissionOnObject)) { + throw new PermissionsException( + PermissionsExceptionMessage.OBJECT_PERMISSION_NOT_FOUND, + PermissionsExceptionCode.OBJECT_PERMISSION_NOT_FOUND, + ); + } + + if (rolePermissionOnObject.canRead === false) { + throw new PermissionsException( + PermissionsExceptionMessage.FIELD_RESTRICTION_ONLY_ALLOWED_ON_READABLE_OBJECT, + PermissionsExceptionCode.FIELD_RESTRICTION_ONLY_ALLOWED_ON_READABLE_OBJECT, + ); + } + + if ( + rolePermissionOnObject.canUpdate === false && + fieldPermission.canUpdateFieldValue === false + ) { + throw new PermissionsException( + PermissionsExceptionMessage.FIELD_RESTRICTION_ON_UPDATE_ONLY_ALLOWED_ON_UPDATABLE_OBJECT, + PermissionsExceptionCode.FIELD_RESTRICTION_ON_UPDATE_ONLY_ALLOWED_ON_UPDATABLE_OBJECT, + ); + } + + if ( + fieldPermission.canUpdateFieldValue === null && + fieldPermission.canReadFieldValue === null + ) { + throw new PermissionsException( + PermissionsExceptionMessage.EMPTY_FIELD_PERMISSION_NOT_ALLOWED, + PermissionsExceptionCode.EMPTY_FIELD_PERMISSION_NOT_ALLOWED, + ); + } + } + + private async getRoleOrThrow({ + roleId, + workspaceId, + }: { + roleId: string; + workspaceId: string; + }) { + 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, + ); + } + + return role; + } + + private async validateRoleIsEditableOrThrow({ role }: { role: RoleEntity }) { + if (!role.isEditable) { + throw new PermissionsException( + PermissionsExceptionMessage.ROLE_NOT_EDITABLE, + PermissionsExceptionCode.ROLE_NOT_EDITABLE, + ); + } + } + + private checkIfFieldPermissionShouldBeDeleted({ + fieldPermission, + existingFieldPermissions, + fieldPermissionsToDeleteIds, + }: { + fieldPermission: UpsertFieldPermissionsInput['fieldPermissions'][0]; + existingFieldPermissions: FieldPermissionEntity[]; + fieldPermissionsToDeleteIds: string[]; + }) { + const existingFieldPermission = existingFieldPermissions.find( + (existingFieldPermission) => + existingFieldPermission.fieldMetadataId === + fieldPermission.fieldMetadataId, + ); + + if (existingFieldPermission) { + const finalCanReadFieldValue = + 'canReadFieldValue' in fieldPermission + ? fieldPermission.canReadFieldValue + : existingFieldPermission.canReadFieldValue; + const finalCanUpdateFieldValue = + 'canUpdateFieldValue' in fieldPermission + ? fieldPermission.canUpdateFieldValue + : existingFieldPermission.canUpdateFieldValue; + + if ( + finalCanReadFieldValue === null && + finalCanUpdateFieldValue === null + ) { + fieldPermissionsToDeleteIds.push(existingFieldPermission.id); + } + } + } +} diff --git a/packages/twenty-server/src/engine/metadata-modules/object-permission/object-permission.entity.ts b/packages/twenty-server/src/engine/metadata-modules/object-permission/object-permission.entity.ts index dedc42e12..5422bcd7e 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-permission/object-permission.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-permission/object-permission.entity.ts @@ -2,6 +2,7 @@ import { Column, CreateDateColumn, Entity, + Index, JoinColumn, ManyToOne, PrimaryGeneratedColumn, @@ -18,6 +19,7 @@ import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; 'objectMetadataId', 'roleId', ]) +@Index('IDX_OBJECT_PERMISSION_WORKSPACE_ID_ROLE_ID', ['workspaceId', 'roleId']) export class ObjectPermissionEntity { @PrimaryGeneratedColumn('uuid') id: string; diff --git a/packages/twenty-server/src/engine/metadata-modules/object-permission/object-permission.module.ts b/packages/twenty-server/src/engine/metadata-modules/object-permission/object-permission.module.ts index d46652608..919e555f3 100644 --- a/packages/twenty-server/src/engine/metadata-modules/object-permission/object-permission.module.ts +++ b/packages/twenty-server/src/engine/metadata-modules/object-permission/object-permission.module.ts @@ -1,7 +1,10 @@ import { Module } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; +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 { FieldPermissionEntity } from 'src/engine/metadata-modules/object-permission/field-permission/field-permission.entity'; +import { FieldPermissionService } from 'src/engine/metadata-modules/object-permission/field-permission/field-permission.service'; import { ObjectPermissionEntity } from 'src/engine/metadata-modules/object-permission/object-permission.entity'; import { ObjectPermissionService } from 'src/engine/metadata-modules/object-permission/object-permission.service'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; @@ -11,13 +14,19 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/ @Module({ imports: [ TypeOrmModule.forFeature( - [ObjectPermissionEntity, RoleEntity, ObjectMetadataEntity], + [ + ObjectPermissionEntity, + RoleEntity, + ObjectMetadataEntity, + FieldPermissionEntity, + FieldMetadataEntity, + ], 'core', ), WorkspaceCacheStorageModule, WorkspacePermissionsCacheModule, ], - providers: [ObjectPermissionService], - exports: [ObjectPermissionService], + providers: [ObjectPermissionService, FieldPermissionService], + exports: [ObjectPermissionService, FieldPermissionService], }) export class ObjectPermissionModule {} 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 fa2cf01ac..318c0efb4 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 @@ -31,10 +31,19 @@ export enum PermissionsExceptionCode { DEFAULT_ROLE_CANNOT_BE_DELETED = 'DEFAULT_ROLE_CANNOT_BE_DELETED', NO_PERMISSIONS_FOUND_IN_DATASOURCE = 'NO_PERMISSIONS_FOUND_IN_DATASOURCE', CANNOT_ADD_OBJECT_PERMISSION_ON_SYSTEM_OBJECT = 'CANNOT_ADD_OBJECT_PERMISSION_ON_SYSTEM_OBJECT', + CANNOT_ADD_FIELD_PERMISSION_ON_SYSTEM_OBJECT = 'CANNOT_ADD_FIELD_PERMISSION_ON_SYSTEM_OBJECT', METHOD_NOT_ALLOWED = 'METHOD_NOT_ALLOWED', RAW_SQL_NOT_ALLOWED = 'RAW_SQL_NOT_ALLOWED', CANNOT_GIVE_WRITING_PERMISSION_ON_NON_READABLE_OBJECT = 'CANNOT_GIVE_WRITING_PERMISSION_ON_NON_READABLE_OBJECT', CANNOT_GIVE_WRITING_PERMISSION_WITHOUT_READING_PERMISSION = 'CANNOT_GIVE_WRITING_PERMISSION_WITHOUT_READING_PERMISSION', + FIELD_METADATA_NOT_FOUND = 'FIELD_METADATA_NOT_FOUND', + ONLY_FIELD_RESTRICTION_ALLOWED = 'ONLY_FIELD_RESTRICTION_ALLOWED', + FIELD_RESTRICTION_ONLY_ALLOWED_ON_READABLE_OBJECT = 'FIELD_RESTRICTION_ONLY_ALLOWED_ON_READABLE_OBJECT', + FIELD_RESTRICTION_ON_UPDATE_ONLY_ALLOWED_ON_UPDATABLE_OBJECT = 'FIELD_RESTRICTION_ON_UPDATE_ONLY_ALLOWED_ON_UPDATABLE_OBJECT', + UPSERT_FIELD_PERMISSION_FAILED = 'UPSERT_FIELD_PERMISSION_FAILED', + PERMISSION_NOT_FOUND = 'PERMISSION_NOT_FOUND', + OBJECT_PERMISSION_NOT_FOUND = 'OBJECT_PERMISSION_NOT_FOUND', + EMPTY_FIELD_PERMISSION_NOT_ALLOWED = 'EMPTY_FIELD_PERMISSION_NOT_ALLOWED', } export enum PermissionsExceptionMessage { @@ -60,6 +69,15 @@ export enum PermissionsExceptionMessage { DEFAULT_ROLE_CANNOT_BE_DELETED = 'Default role cannot be deleted', NO_PERMISSIONS_FOUND_IN_DATASOURCE = 'No permissions found in datasource', CANNOT_ADD_OBJECT_PERMISSION_ON_SYSTEM_OBJECT = 'Cannot add object permission on system object', + CANNOT_ADD_FIELD_PERMISSION_ON_SYSTEM_OBJECT = 'Cannot add field permission on system object', CANNOT_GIVE_WRITING_PERMISSION_ON_NON_READABLE_OBJECT = 'Cannot give update permission to non-readable object', CANNOT_GIVE_WRITING_PERMISSION_WITHOUT_READING_PERMISSION = 'Cannot give writing permission without reading permission', + FIELD_METADATA_NOT_FOUND = 'Field metadata not found', + ONLY_FIELD_RESTRICTION_ALLOWED = 'Field permission can only introduce a restriction', + 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', + UPSERT_FIELD_PERMISSION_FAILED = 'Failed to upsert field permission', + PERMISSION_NOT_FOUND = 'Permission not found', + OBJECT_PERMISSION_NOT_FOUND = 'Object permission not found', + EMPTY_FIELD_PERMISSION_NOT_ALLOWED = 'Empty field permission not allowed', } 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 4bf42c72b..ea5afd68f 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 @@ -27,16 +27,24 @@ export const permissionGraphqlApiExceptionHandler = ( case PermissionsExceptionCode.CANNOT_DELETE_LAST_ADMIN_USER: case PermissionsExceptionCode.ROLE_NOT_EDITABLE: case PermissionsExceptionCode.CANNOT_ADD_OBJECT_PERMISSION_ON_SYSTEM_OBJECT: + case PermissionsExceptionCode.CANNOT_ADD_FIELD_PERMISSION_ON_SYSTEM_OBJECT: throw new ForbiddenError(error.message); case PermissionsExceptionCode.INVALID_ARG: case PermissionsExceptionCode.INVALID_SETTING: case PermissionsExceptionCode.CANNOT_GIVE_WRITING_PERMISSION_ON_NON_READABLE_OBJECT: case PermissionsExceptionCode.CANNOT_GIVE_WRITING_PERMISSION_WITHOUT_READING_PERMISSION: + case PermissionsExceptionCode.ONLY_FIELD_RESTRICTION_ALLOWED: + case PermissionsExceptionCode.FIELD_RESTRICTION_ONLY_ALLOWED_ON_READABLE_OBJECT: + case PermissionsExceptionCode.FIELD_RESTRICTION_ON_UPDATE_ONLY_ALLOWED_ON_UPDATABLE_OBJECT: + case PermissionsExceptionCode.EMPTY_FIELD_PERMISSION_NOT_ALLOWED: throw new UserInputError(error.message); case PermissionsExceptionCode.ROLE_NOT_FOUND: case PermissionsExceptionCode.USER_WORKSPACE_NOT_FOUND: case PermissionsExceptionCode.OBJECT_METADATA_NOT_FOUND: + case PermissionsExceptionCode.FIELD_METADATA_NOT_FOUND: + case PermissionsExceptionCode.PERMISSION_NOT_FOUND: throw new NotFoundError(error.message); + case PermissionsExceptionCode.UPSERT_FIELD_PERMISSION_FAILED: case PermissionsExceptionCode.DEFAULT_ROLE_NOT_FOUND: case PermissionsExceptionCode.WORKSPACE_ID_ROLE_USER_WORKSPACE_MISMATCH: case PermissionsExceptionCode.TOO_MANY_ADMIN_CANDIDATES: @@ -50,6 +58,7 @@ export const permissionGraphqlApiExceptionHandler = ( case PermissionsExceptionCode.NO_PERMISSIONS_FOUND_IN_DATASOURCE: case PermissionsExceptionCode.METHOD_NOT_ALLOWED: case PermissionsExceptionCode.RAW_SQL_NOT_ALLOWED: + case PermissionsExceptionCode.OBJECT_PERMISSION_NOT_FOUND: throw error; default: { const _exhaustiveCheck: never = error.code; diff --git a/packages/twenty-server/src/engine/metadata-modules/role/role.entity.ts b/packages/twenty-server/src/engine/metadata-modules/role/role.entity.ts index c58077a21..18951be97 100644 --- a/packages/twenty-server/src/engine/metadata-modules/role/role.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/role/role.entity.ts @@ -9,6 +9,7 @@ import { UpdateDateColumn, } from 'typeorm'; +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 { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity'; import { SettingPermissionEntity } from 'src/engine/metadata-modules/setting-permission/setting-permission.entity'; @@ -72,4 +73,10 @@ export class RoleEntity { (settingPermission: SettingPermissionEntity) => settingPermission.role, ) settingPermissions: Relation; + + @OneToMany( + () => FieldPermissionEntity, + (fieldPermission: FieldPermissionEntity) => fieldPermission.role, + ) + fieldPermissions: Relation; } 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 f135ef442..23f9fb39f 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 @@ -22,8 +22,11 @@ import { SettingsPermissionsGuard } from 'src/engine/guards/settings-permissions import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { AgentRoleService } from 'src/engine/metadata-modules/agent-role/agent-role.service'; +import { FieldPermissionDTO } from 'src/engine/metadata-modules/object-permission/dtos/field-permission.dto'; import { ObjectPermissionDTO } from 'src/engine/metadata-modules/object-permission/dtos/object-permission.dto'; +import { UpsertFieldPermissionsInput } from 'src/engine/metadata-modules/object-permission/dtos/upsert-field-permissions.input'; 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 { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants'; import { @@ -60,6 +63,7 @@ export class RoleResolver { private readonly objectPermissionService: ObjectPermissionService, private readonly settingPermissionService: SettingPermissionService, private readonly agentRoleService: AgentRoleService, + private readonly fieldPermissionService: FieldPermissionService, ) {} @Query(() => [RoleDTO]) @@ -179,6 +183,18 @@ export class RoleResolver { }); } + @Mutation(() => [FieldPermissionDTO]) + async upsertFieldPermissions( + @AuthWorkspace() workspace: Workspace, + @Args('upsertFieldPermissionsInput') + upsertFieldPermissionsInput: UpsertFieldPermissionsInput, + ): Promise { + return this.fieldPermissionService.upsertFieldPermissions({ + workspaceId: workspace.id, + input: upsertFieldPermissionsInput, + }); + } + @Mutation(() => Boolean) @RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED) async assignRoleToAgent( diff --git a/packages/twenty-server/src/engine/metadata-modules/search-vector/search-vector.service.ts b/packages/twenty-server/src/engine/metadata-modules/search-vector/search-vector.service.ts index e61fab5c2..db6c7609d 100644 --- a/packages/twenty-server/src/engine/metadata-modules/search-vector/search-vector.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/search-vector/search-vector.service.ts @@ -9,8 +9,8 @@ import { FieldMetadataInterface } from 'src/engine/metadata-modules/field-metada import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/search-vector-field.constants'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; -import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; import { IndexMetadataService } from 'src/engine/metadata-modules/index-metadata/index-metadata.service'; +import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types'; import { CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input'; import { DEFAULT_LABEL_IDENTIFIER_FIELD_NAME } from 'src/engine/metadata-modules/object-metadata/object-metadata.constants'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; diff --git a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/workspace-migration.entity.ts b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/workspace-migration.entity.ts index 13d30f302..2a666a744 100644 --- a/packages/twenty-server/src/engine/metadata-modules/workspace-migration/workspace-migration.entity.ts +++ b/packages/twenty-server/src/engine/metadata-modules/workspace-migration/workspace-migration.entity.ts @@ -5,7 +5,7 @@ import { PrimaryGeneratedColumn, } from 'typeorm'; -import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; +import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types'; import { RelationOnDeleteAction } from 'src/engine/metadata-modules/relation-metadata/relation-on-delete-action.type'; export enum WorkspaceMigrationColumnActionType { diff --git a/packages/twenty-server/src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service.ts b/packages/twenty-server/src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service.ts index 336c1e810..a1a0c3145 100644 --- a/packages/twenty-server/src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service.ts +++ b/packages/twenty-server/src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.service.ts @@ -8,10 +8,12 @@ import { import { isDefined } from 'twenty-shared/utils'; import { In, Repository } from 'typeorm'; +import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants'; import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; +import { WorkspaceFeatureFlagsMapCacheService } from 'src/engine/metadata-modules/workspace-feature-flags-map-cache/workspace-feature-flags-map-cache.service'; import { UserWorkspaceRoleMap } from 'src/engine/metadata-modules/workspace-permissions-cache/types/user-workspace-role-map.type'; import { WorkspacePermissionsCacheStorageService } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache-storage.service'; import { TwentyORMExceptionCode } from 'src/engine/twenty-orm/exceptions/twenty-orm.exception'; @@ -38,6 +40,7 @@ export class WorkspacePermissionsCacheService { @InjectRepository(RoleTargetsEntity, 'core') private readonly roleTargetsRepository: Repository, private readonly workspacePermissionsCacheStorageService: WorkspacePermissionsCacheStorageService, + private readonly workspaceFeatureFlagsMapCacheService: WorkspaceFeatureFlagsMapCacheService, ) {} async recomputeRolesPermissionsCache({ @@ -156,7 +159,7 @@ export class WorkspacePermissionsCacheService { return userWorkspaceRoleMap[userWorkspaceId]; } - private async getObjectRecordPermissionsForRoles({ + async getObjectRecordPermissionsForRoles({ workspaceId, roleIds, }: { @@ -165,12 +168,24 @@ export class WorkspacePermissionsCacheService { }): Promise { let roles: RoleEntity[] = []; + const workspaceFeatureFlagsMap = + await this.workspaceFeatureFlagsMapCacheService.getWorkspaceFeatureFlagsMap( + { workspaceId }, + ); + + const isFieldPermissionsEnabled = + workspaceFeatureFlagsMap[FeatureFlagKey.IS_FIELDS_PERMISSIONS_ENABLED]; + roles = await this.roleRepository.find({ where: { workspaceId, ...(roleIds ? { id: In(roleIds) } : {}), }, - relations: ['objectPermissions', 'settingPermissions'], + relations: [ + 'objectPermissions', + 'settingPermissions', + ...(isFieldPermissionsEnabled ? ['fieldPermissions'] : []), + ], }); const workspaceObjectMetadataCollection = @@ -188,6 +203,10 @@ export class WorkspacePermissionsCacheService { let canUpdate = role.canUpdateAllObjectRecords; let canSoftDelete = role.canSoftDeleteAllObjectRecords; let canDestroy = role.canDestroyAllObjectRecords; + const restrictedFields: Record< + string, + { canRead?: boolean | null; canUpdate?: boolean | null } + > = {}; if ( standardId && @@ -230,6 +249,20 @@ export class WorkspacePermissionsCacheService { objectRecordPermissionsOverride?.canDestroyObjectRecords, canDestroy, ); + + if (isFieldPermissionsEnabled) { + const fieldPermissions = role.fieldPermissions.filter( + (fieldPermission) => + fieldPermission.objectMetadataId === objectMetadataId, + ); + + for (const fieldPermission of fieldPermissions) { + restrictedFields[fieldPermission.fieldMetadataId] = { + canRead: fieldPermission.canReadFieldValue, + canUpdate: fieldPermission.canUpdateFieldValue, + }; + } + } } objectRecordsPermissions[objectMetadataId] = { @@ -237,6 +270,7 @@ export class WorkspacePermissionsCacheService { canUpdate, canSoftDelete, canDestroy, + restrictedFields, }; permissionsByRoleId[role.id] = objectRecordsPermissions; diff --git a/packages/twenty-server/src/engine/twenty-orm/custom.workspace-entity.ts b/packages/twenty-server/src/engine/twenty-orm/custom.workspace-entity.ts index d67e50cfe..4adb8cf6f 100644 --- a/packages/twenty-server/src/engine/twenty-orm/custom.workspace-entity.ts +++ b/packages/twenty-server/src/engine/twenty-orm/custom.workspace-entity.ts @@ -6,7 +6,7 @@ import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfa import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/search-vector-field.constants'; import { ActorMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type'; -import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; +import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types'; import { DEFAULT_LABEL_IDENTIFIER_FIELD_NAME } from 'src/engine/metadata-modules/object-metadata/object-metadata.constants'; import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity'; import { WorkspaceCustomEntity } from 'src/engine/twenty-orm/decorators/workspace-custom-entity.decorator'; diff --git a/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-index.decorator.ts b/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-index.decorator.ts index fb827f5a2..c3a2b9614 100644 --- a/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-index.decorator.ts +++ b/packages/twenty-server/src/engine/twenty-orm/decorators/workspace-index.decorator.ts @@ -1,4 +1,4 @@ -import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; +import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types'; import { generateDeterministicIndexName } from 'src/engine/metadata-modules/index-metadata/utils/generate-deterministic-index-name'; import { metadataArgsStorage } from 'src/engine/twenty-orm/storage/metadata-args.storage'; import { convertClassNameToObjectMetadataName } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/convert-class-to-object-metadata-name.util'; 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 dab3a77e7..f6f06df7f 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 @@ -72,6 +72,7 @@ describe('WorkspaceEntityManager', () => { canUpdate: false, canSoftDelete: false, canDestroy: false, + restrictedFields: {}, }, }, }; diff --git a/packages/twenty-server/src/engine/twenty-orm/interfaces/workspace-index-metadata-args.interface.ts b/packages/twenty-server/src/engine/twenty-orm/interfaces/workspace-index-metadata-args.interface.ts index df2fc1e83..65436a7f1 100644 --- a/packages/twenty-server/src/engine/twenty-orm/interfaces/workspace-index-metadata-args.interface.ts +++ b/packages/twenty-server/src/engine/twenty-orm/interfaces/workspace-index-metadata-args.interface.ts @@ -1,6 +1,6 @@ import { Gate } from 'src/engine/twenty-orm/interfaces/gate.interface'; -import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; +import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types'; export interface WorkspaceIndexMetadataArgs { /** diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/workspace.repository.spec.ts b/packages/twenty-server/src/engine/twenty-orm/repository/workspace.repository.spec.ts index 14f984a30..7d9f5c565 100644 --- a/packages/twenty-server/src/engine/twenty-orm/repository/workspace.repository.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/repository/workspace.repository.spec.ts @@ -75,6 +75,7 @@ describe('WorkspaceRepository', () => { canUpdate: false, canSoftDelete: false, canDestroy: false, + restrictedFields: {}, }, }; mockQueryRunner = {} as QueryRunner; diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/get-default-columns-for-index.util.spec.ts b/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/get-default-columns-for-index.util.spec.ts index fad8a9cd1..9526ba5a3 100644 --- a/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/get-default-columns-for-index.util.spec.ts +++ b/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/get-default-columns-for-index.util.spec.ts @@ -1,4 +1,4 @@ -import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; +import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types'; import { getColumnsForIndex } from 'src/engine/twenty-orm/utils/get-default-columns-for-index.util'; describe('getColumnsForIndex', () => { diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/get-default-columns-for-index.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/get-default-columns-for-index.util.ts index b97d086a6..a2644c074 100644 --- a/packages/twenty-server/src/engine/twenty-orm/utils/get-default-columns-for-index.util.ts +++ b/packages/twenty-server/src/engine/twenty-orm/utils/get-default-columns-for-index.util.ts @@ -1,4 +1,4 @@ -import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; +import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types'; export const getColumnsForIndex = (indexType?: IndexType) => { switch (indexType) { diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service.ts index dc364886f..9284d9d35 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-migration-runner/workspace-migration-runner.service.ts @@ -3,7 +3,7 @@ import { Injectable, Logger } from '@nestjs/common'; import { isDefined } from 'twenty-shared/utils'; import { QueryRunner, Table, TableColumn } from 'typeorm'; -import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; +import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types'; import { WorkspaceMigrationColumnAction, WorkspaceMigrationColumnActionType, diff --git a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/factories/standard-index.factory.ts b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/factories/standard-index.factory.ts index 6de124a3c..26474be53 100644 --- a/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/factories/standard-index.factory.ts +++ b/packages/twenty-server/src/engine/workspace-manager/workspace-sync-metadata/factories/standard-index.factory.ts @@ -3,10 +3,8 @@ import { Injectable, Logger } from '@nestjs/common'; import { PartialIndexMetadata } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/partial-index-metadata.interface'; import { WorkspaceSyncContext } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/workspace-sync-context.interface'; -import { - IndexMetadataEntity, - IndexType, -} from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; +import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; +import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types'; import { generateDeterministicIndexName } from 'src/engine/metadata-modules/index-metadata/utils/generate-deterministic-index-name'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity'; import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity'; diff --git a/packages/twenty-server/src/modules/company/standard-objects/company.workspace-entity.ts b/packages/twenty-server/src/modules/company/standard-objects/company.workspace-entity.ts index ead4cbc5e..cf475e679 100644 --- a/packages/twenty-server/src/modules/company/standard-objects/company.workspace-entity.ts +++ b/packages/twenty-server/src/modules/company/standard-objects/company.workspace-entity.ts @@ -10,7 +10,7 @@ import { ActorMetadata } from 'src/engine/metadata-modules/field-metadata/compos import { AddressMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/address.composite-type'; import { CurrencyMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/currency.composite-type'; import { LinksMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/links.composite-type'; -import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; +import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types'; import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity'; import { WorkspaceDuplicateCriteria } from 'src/engine/twenty-orm/decorators/workspace-duplicate-criteria.decorator'; import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator'; diff --git a/packages/twenty-server/src/modules/note/standard-objects/note.workspace-entity.ts b/packages/twenty-server/src/modules/note/standard-objects/note.workspace-entity.ts index 7c24eeeea..249e88d26 100644 --- a/packages/twenty-server/src/modules/note/standard-objects/note.workspace-entity.ts +++ b/packages/twenty-server/src/modules/note/standard-objects/note.workspace-entity.ts @@ -8,7 +8,7 @@ import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/i import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/search-vector-field.constants'; import { ActorMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type'; import { RichTextV2Metadata } from 'src/engine/metadata-modules/field-metadata/composite-types/rich-text-v2.composite-type'; -import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; +import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types'; import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity'; import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator'; import { WorkspaceFieldIndex } from 'src/engine/twenty-orm/decorators/workspace-field-index.decorator'; diff --git a/packages/twenty-server/src/modules/opportunity/standard-objects/opportunity.workspace-entity.ts b/packages/twenty-server/src/modules/opportunity/standard-objects/opportunity.workspace-entity.ts index 77b1350d8..889457c77 100644 --- a/packages/twenty-server/src/modules/opportunity/standard-objects/opportunity.workspace-entity.ts +++ b/packages/twenty-server/src/modules/opportunity/standard-objects/opportunity.workspace-entity.ts @@ -8,7 +8,7 @@ import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/i import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/search-vector-field.constants'; import { ActorMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type'; import { CurrencyMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/currency.composite-type'; -import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; +import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types'; import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity'; import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator'; import { WorkspaceFieldIndex } from 'src/engine/twenty-orm/decorators/workspace-field-index.decorator'; diff --git a/packages/twenty-server/src/modules/person/standard-objects/person.workspace-entity.ts b/packages/twenty-server/src/modules/person/standard-objects/person.workspace-entity.ts index 1a056d9bd..019a11219 100644 --- a/packages/twenty-server/src/modules/person/standard-objects/person.workspace-entity.ts +++ b/packages/twenty-server/src/modules/person/standard-objects/person.workspace-entity.ts @@ -11,7 +11,7 @@ import { EmailsMetadata } from 'src/engine/metadata-modules/field-metadata/compo import { FullNameMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/full-name.composite-type'; import { LinksMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/links.composite-type'; import { PhonesMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/phones.composite-type'; -import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; +import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types'; import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity'; import { WorkspaceDuplicateCriteria } from 'src/engine/twenty-orm/decorators/workspace-duplicate-criteria.decorator'; import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator'; diff --git a/packages/twenty-server/src/modules/task/standard-objects/task.workspace-entity.ts b/packages/twenty-server/src/modules/task/standard-objects/task.workspace-entity.ts index e79a44234..e4c5192d9 100644 --- a/packages/twenty-server/src/modules/task/standard-objects/task.workspace-entity.ts +++ b/packages/twenty-server/src/modules/task/standard-objects/task.workspace-entity.ts @@ -8,7 +8,7 @@ import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/i import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/search-vector-field.constants'; import { ActorMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type'; import { RichTextV2Metadata } from 'src/engine/metadata-modules/field-metadata/composite-types/rich-text-v2.composite-type'; -import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; +import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types'; import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity'; import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator'; import { WorkspaceFieldIndex } from 'src/engine/twenty-orm/decorators/workspace-field-index.decorator'; diff --git a/packages/twenty-server/src/modules/workflow/common/standard-objects/workflow-run.workspace-entity.ts b/packages/twenty-server/src/modules/workflow/common/standard-objects/workflow-run.workspace-entity.ts index c85a55c3d..d0011e582 100644 --- a/packages/twenty-server/src/modules/workflow/common/standard-objects/workflow-run.workspace-entity.ts +++ b/packages/twenty-server/src/modules/workflow/common/standard-objects/workflow-run.workspace-entity.ts @@ -7,7 +7,7 @@ import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/i import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/search-vector-field.constants'; import { ActorMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type'; -import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; +import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types'; import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity'; import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator'; import { WorkspaceFieldIndex } from 'src/engine/twenty-orm/decorators/workspace-field-index.decorator'; @@ -29,9 +29,9 @@ import { TimelineActivityWorkspaceEntity } from 'src/modules/timeline/standard-o import { WorkflowVersionWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity'; import { WorkflowWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow.workspace-entity'; import { WorkflowActionOutput } from 'src/modules/workflow/workflow-executor/types/workflow-action-output.type'; +import { WorkflowRunStepInfo } from 'src/modules/workflow/workflow-executor/types/workflow-run-step-info.type'; import { WorkflowAction } from 'src/modules/workflow/workflow-executor/workflow-actions/types/workflow-action.type'; import { WorkflowTrigger } from 'src/modules/workflow/workflow-trigger/types/workflow-trigger.type'; -import { WorkflowRunStepInfo } from 'src/modules/workflow/workflow-executor/types/workflow-run-step-info.type'; export enum WorkflowRunStatus { NOT_STARTED = 'NOT_STARTED', diff --git a/packages/twenty-server/src/modules/workflow/common/standard-objects/workflow-version.workspace-entity.ts b/packages/twenty-server/src/modules/workflow/common/standard-objects/workflow-version.workspace-entity.ts index 58bca2e1a..96371d7e4 100644 --- a/packages/twenty-server/src/modules/workflow/common/standard-objects/workflow-version.workspace-entity.ts +++ b/packages/twenty-server/src/modules/workflow/common/standard-objects/workflow-version.workspace-entity.ts @@ -7,7 +7,7 @@ import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/i import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/search-vector-field.constants'; import { FieldMetadataComplexOption } from 'src/engine/metadata-modules/field-metadata/dtos/options.input'; -import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; +import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types'; import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity'; import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator'; import { WorkspaceFieldIndex } from 'src/engine/twenty-orm/decorators/workspace-field-index.decorator'; diff --git a/packages/twenty-server/src/modules/workflow/common/standard-objects/workflow.workspace-entity.ts b/packages/twenty-server/src/modules/workflow/common/standard-objects/workflow.workspace-entity.ts index 025cb1140..9a589a0a3 100644 --- a/packages/twenty-server/src/modules/workflow/common/standard-objects/workflow.workspace-entity.ts +++ b/packages/twenty-server/src/modules/workflow/common/standard-objects/workflow.workspace-entity.ts @@ -5,29 +5,29 @@ import { RelationOnDeleteAction } from 'src/engine/metadata-modules/field-metada import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface'; import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/relation.interface'; +import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/search-vector-field.constants'; import { ActorMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type'; import { FieldMetadataComplexOption } from 'src/engine/metadata-modules/field-metadata/dtos/options.input'; +import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types'; import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity'; -import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator'; +import { WorkspaceFieldIndex } from 'src/engine/twenty-orm/decorators/workspace-field-index.decorator'; import { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator'; import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.decorator'; import { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator'; import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-relation.decorator'; -import { WorkspaceFieldIndex } from 'src/engine/twenty-orm/decorators/workspace-field-index.decorator'; import { WORKFLOW_STANDARD_FIELD_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-field-ids'; import { STANDARD_OBJECT_ICONS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-icons'; import { STANDARD_OBJECT_IDS } from 'src/engine/workspace-manager/workspace-sync-metadata/constants/standard-object-ids'; +import { + FieldTypeAndNameMetadata, + getTsVectorColumnExpressionFromFields, +} from 'src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util'; import { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity'; import { TimelineActivityWorkspaceEntity } from 'src/modules/timeline/standard-objects/timeline-activity.workspace-entity'; import { WorkflowAutomatedTriggerWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-automated-trigger.workspace-entity'; import { WorkflowRunWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-run.workspace-entity'; import { WorkflowVersionWorkspaceEntity } from 'src/modules/workflow/common/standard-objects/workflow-version.workspace-entity'; -import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/search-vector-field.constants'; -import { - FieldTypeAndNameMetadata, - getTsVectorColumnExpressionFromFields, -} from 'src/engine/workspace-manager/workspace-sync-metadata/utils/get-ts-vector-column-expression.util'; export enum WorkflowStatus { DRAFT = 'DRAFT', diff --git a/packages/twenty-server/src/modules/workspace-member/standard-objects/workspace-member.workspace-entity.ts b/packages/twenty-server/src/modules/workspace-member/standard-objects/workspace-member.workspace-entity.ts index 29075a405..678d55a21 100644 --- a/packages/twenty-server/src/modules/workspace-member/standard-objects/workspace-member.workspace-entity.ts +++ b/packages/twenty-server/src/modules/workspace-member/standard-objects/workspace-member.workspace-entity.ts @@ -10,7 +10,7 @@ import { Relation } from 'src/engine/workspace-manager/workspace-sync-metadata/i import { SEARCH_VECTOR_FIELD } from 'src/engine/metadata-modules/constants/search-vector-field.constants'; import { FullNameMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/full-name.composite-type'; -import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity'; +import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types'; import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity'; import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator'; import { WorkspaceFieldIndex } from 'src/engine/twenty-orm/decorators/workspace-field-index.decorator'; diff --git a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/roles.integration-spec.ts b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/roles.integration-spec.ts index 198cecfff..02a727553 100644 --- a/packages/twenty-server/test/integration/graphql/suites/settings-permissions/roles.integration-spec.ts +++ b/packages/twenty-server/test/integration/graphql/suites/settings-permissions/roles.integration-spec.ts @@ -3,6 +3,7 @@ import { deleteOneRoleOperationFactory } from 'test/integration/graphql/utils/de 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 { fieldTextMock } from 'src/engine/api/__mocks__/object-metadata-item.mock'; import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util'; import { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants'; import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception'; @@ -537,6 +538,27 @@ describe('roles permissions', () => { ); }); }); + + describe('upsertFieldPermissions', () => { + it('should throw a permission error when user does not have permission to upsert field permission (member role)', async () => { + const query = { + query: ` + mutation UpsertFieldPermissions { + upsertFieldPermissions(upsertFieldPermissionsInput: {roleId: "${guestRoleId}", fieldPermissions: [{objectMetadataId: "${listingObjectId}", fieldMetadataId: "${fieldTextMock.id}", canReadFieldValue: false, canUpdateFieldValue: false}]}) { + id + roleId + objectMetadataId + fieldMetadataId + canReadFieldValue + canUpdateFieldValue + } + } + `, + }; + + await assertPermissionDeniedForMemberWithMemberRole({ query }); + }); + }); }); describe('upsertSettingPermissions', () => { diff --git a/packages/twenty-server/test/integration/metadata/suites/agent/agent-tool.service.integration-spec.ts b/packages/twenty-server/test/integration/metadata/suites/agent/agent-tool.service.integration-spec.ts index 136bddc4b..d7469ebdc 100644 --- a/packages/twenty-server/test/integration/metadata/suites/agent/agent-tool.service.integration-spec.ts +++ b/packages/twenty-server/test/integration/metadata/suites/agent/agent-tool.service.integration-spec.ts @@ -47,6 +47,7 @@ describe('AgentToolService Integration', () => { canUpdate: true, canSoftDelete: true, canDestroy: true, + restrictedFields: {}, }, }, }, @@ -91,6 +92,7 @@ describe('AgentToolService Integration', () => { canUpdate: false, canSoftDelete: false, canDestroy: false, + restrictedFields: {}, }, }, }, @@ -168,6 +170,7 @@ describe('AgentToolService Integration', () => { canUpdate: true, canSoftDelete: true, canDestroy: false, + restrictedFields: {}, }, }, }, @@ -767,12 +770,14 @@ describe('AgentToolService Integration', () => { canUpdate: true, canSoftDelete: false, canDestroy: false, + restrictedFields: {}, }, [secondObjectMetadata.id]: { canRead: true, canUpdate: false, canSoftDelete: true, canDestroy: false, + restrictedFields: {}, }, }, }, diff --git a/packages/twenty-server/test/integration/metadata/suites/agent/utils/agent-tool-test-utils.ts b/packages/twenty-server/test/integration/metadata/suites/agent/utils/agent-tool-test-utils.ts index 3c8c6748a..afd0761db 100644 --- a/packages/twenty-server/test/integration/metadata/suites/agent/utils/agent-tool-test-utils.ts +++ b/packages/twenty-server/test/integration/metadata/suites/agent/utils/agent-tool-test-utils.ts @@ -158,6 +158,7 @@ export const createAgentToolTestModule = targetRelationFields: [], dataSource: {} as any, objectPermissions: [], + fieldPermissions: [], }; return { @@ -207,6 +208,7 @@ export const setupBasicPermissions = (context: AgentToolTestContext) => { canUpdate: true, canSoftDelete: true, canDestroy: false, + restrictedFields: {}, }, }, }, diff --git a/packages/twenty-shared/src/types/ObjectRecordsPermissions.ts b/packages/twenty-shared/src/types/ObjectRecordsPermissions.ts index 1311bb781..85ebdc551 100644 --- a/packages/twenty-shared/src/types/ObjectRecordsPermissions.ts +++ b/packages/twenty-shared/src/types/ObjectRecordsPermissions.ts @@ -1,7 +1,14 @@ type ObjectMetadataId = string; -export type ObjectRecordsPermissions = Record; + restrictedFields: Record< + string, + { canRead?: boolean | null; canUpdate?: boolean | null } + >; + } +>;