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 6b9d9e5b04..415dfdcea7 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 b13566479e..8de31c917e 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 0000000000..3647c8130a --- /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 0000000000..2745c7e6cf --- /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 3c2bc881ab..4560196acb 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 510b606f2d..73e088479e 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 46aca674f1..f550328d62 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 7a2f58b34c..de41038f08 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 256166b0c3..f3f2856e30 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 01c3afef2b..0a800b822f 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 0000000000..6a7493c9ea --- /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 454f36f929..ae79039f64 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 a951cc7b50..5e8eb2c0e7 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 0000000000..f123759784 --- /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 0000000000..b0459918ad --- /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 0000000000..89cd5aa155 --- /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 0000000000..b72bd33226 --- /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 0000000000..1f51fab491 --- /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 dedc42e125..5422bcd7ed 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 d46652608b..919e555f3c 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 fa2cf01acc..318c0efb48 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 4bf42c72bb..ea5afd68f6 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 c58077a215..18951be977 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 f135ef442c..23f9fb39f6 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 e61fab5c27..db6c7609d9 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 13d30f302c..2a666a744d 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 336c1e8106..a1a0c31459 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 d67e50cfe4..4adb8cf6f4 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 fb827f5a24..c3a2b96142 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 dab3a77e7b..f6f06df7fa 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 df2fc1e836..65436a7f16 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 14f984a30f..7d9f5c5650 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 fad8a9cd18..9526ba5a3a 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 b97d086a62..a2644c0743 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 dc364886fe..9284d9d351 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 6de124a3c4..26474be53a 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 ead4cbc5eb..cf475e679f 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 7c24eeeea4..249e88d266 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 77b1350d87..889457c774 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 1a056d9bd9..019a112194 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 e79a44234c..e4c5192d90 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 c85a55c3d9..d0011e582e 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 58bca2e1a9..96371d7e44 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 025cb11405..9a589a0a3d 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 29075a4050..678d55a21e 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 198cecfffa..02a7275533 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 136bddc4bf..d7469ebdc2 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 3c8c6748ab..afd0761dbd 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 1311bb7812..85ebdc5516 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 } + >; + } +>;