[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 <charlesBochet@users.noreply.github.com>
This commit is contained in:
Marie
2025-07-09 10:47:59 +02:00
committed by GitHub
parent 6ba6860e1c
commit 1cb60f943e
49 changed files with 1343 additions and 47 deletions

View File

@ -678,6 +678,7 @@ export type FeatureFlagDto = {
export enum FeatureFlagKey { export enum FeatureFlagKey {
IS_AIRTABLE_INTEGRATION_ENABLED = 'IS_AIRTABLE_INTEGRATION_ENABLED', IS_AIRTABLE_INTEGRATION_ENABLED = 'IS_AIRTABLE_INTEGRATION_ENABLED',
IS_AI_ENABLED = 'IS_AI_ENABLED', IS_AI_ENABLED = 'IS_AI_ENABLED',
IS_FIELDS_PERMISSIONS_ENABLED = 'IS_FIELDS_PERMISSIONS_ENABLED',
IS_IMAP_ENABLED = 'IS_IMAP_ENABLED', IS_IMAP_ENABLED = 'IS_IMAP_ENABLED',
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED', IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED', IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED',
@ -766,6 +767,23 @@ export enum FieldMetadataType {
UUID = 'UUID' UUID = 'UUID'
} }
export type FieldPermission = {
__typename?: 'FieldPermission';
canReadFieldValue?: Maybe<Scalars['Boolean']>;
canUpdateFieldValue?: Maybe<Scalars['Boolean']>;
fieldMetadataId: Scalars['String'];
id: Scalars['String'];
objectMetadataId: Scalars['String'];
roleId: Scalars['String'];
};
export type FieldPermissionInput = {
canReadFieldValue?: InputMaybe<Scalars['Boolean']>;
canUpdateFieldValue?: InputMaybe<Scalars['Boolean']>;
fieldMetadataId: Scalars['String'];
objectMetadataId: Scalars['String'];
};
export enum FileFolder { export enum FileFolder {
Attachment = 'Attachment', Attachment = 'Attachment',
PersonPicture = 'PersonPicture', PersonPicture = 'PersonPicture',
@ -1076,6 +1094,7 @@ export type Mutation = {
uploadImage: SignedFileDto; uploadImage: SignedFileDto;
uploadProfilePicture: SignedFileDto; uploadProfilePicture: SignedFileDto;
uploadWorkspaceLogo: SignedFileDto; uploadWorkspaceLogo: SignedFileDto;
upsertFieldPermissions: Array<FieldPermission>;
upsertObjectPermissions: Array<ObjectPermission>; upsertObjectPermissions: Array<ObjectPermission>;
upsertSettingPermissions: Array<SettingPermission>; upsertSettingPermissions: Array<SettingPermission>;
userLookupAdminPanel: UserLookup; userLookupAdminPanel: UserLookup;
@ -1494,6 +1513,11 @@ export type MutationUploadWorkspaceLogoArgs = {
}; };
export type MutationUpsertFieldPermissionsArgs = {
upsertFieldPermissionsInput: UpsertFieldPermissionsInput;
};
export type MutationUpsertObjectPermissionsArgs = { export type MutationUpsertObjectPermissionsArgs = {
upsertObjectPermissionsInput: UpsertObjectPermissionsInput; upsertObjectPermissionsInput: UpsertObjectPermissionsInput;
}; };
@ -2484,6 +2508,11 @@ export type UpdateWorkspaceInput = {
subdomain?: InputMaybe<Scalars['String']>; subdomain?: InputMaybe<Scalars['String']>;
}; };
export type UpsertFieldPermissionsInput = {
fieldPermissions: Array<FieldPermissionInput>;
roleId: Scalars['String'];
};
export type UpsertObjectPermissionsInput = { export type UpsertObjectPermissionsInput = {
objectPermissions: Array<ObjectPermissionInput>; objectPermissions: Array<ObjectPermissionInput>;
roleId: Scalars['String']; roleId: Scalars['String'];

View File

@ -642,6 +642,7 @@ export type FeatureFlagDto = {
export enum FeatureFlagKey { export enum FeatureFlagKey {
IS_AIRTABLE_INTEGRATION_ENABLED = 'IS_AIRTABLE_INTEGRATION_ENABLED', IS_AIRTABLE_INTEGRATION_ENABLED = 'IS_AIRTABLE_INTEGRATION_ENABLED',
IS_AI_ENABLED = 'IS_AI_ENABLED', IS_AI_ENABLED = 'IS_AI_ENABLED',
IS_FIELDS_PERMISSIONS_ENABLED = 'IS_FIELDS_PERMISSIONS_ENABLED',
IS_IMAP_ENABLED = 'IS_IMAP_ENABLED', IS_IMAP_ENABLED = 'IS_IMAP_ENABLED',
IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED', IS_JSON_FILTER_ENABLED = 'IS_JSON_FILTER_ENABLED',
IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED', IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED',
@ -730,6 +731,23 @@ export enum FieldMetadataType {
UUID = 'UUID' UUID = 'UUID'
} }
export type FieldPermission = {
__typename?: 'FieldPermission';
canReadFieldValue?: Maybe<Scalars['Boolean']>;
canUpdateFieldValue?: Maybe<Scalars['Boolean']>;
fieldMetadataId: Scalars['String'];
id: Scalars['String'];
objectMetadataId: Scalars['String'];
roleId: Scalars['String'];
};
export type FieldPermissionInput = {
canReadFieldValue?: InputMaybe<Scalars['Boolean']>;
canUpdateFieldValue?: InputMaybe<Scalars['Boolean']>;
fieldMetadataId: Scalars['String'];
objectMetadataId: Scalars['String'];
};
export enum FileFolder { export enum FileFolder {
Attachment = 'Attachment', Attachment = 'Attachment',
PersonPicture = 'PersonPicture', PersonPicture = 'PersonPicture',
@ -1027,6 +1045,7 @@ export type Mutation = {
uploadImage: SignedFileDto; uploadImage: SignedFileDto;
uploadProfilePicture: SignedFileDto; uploadProfilePicture: SignedFileDto;
uploadWorkspaceLogo: SignedFileDto; uploadWorkspaceLogo: SignedFileDto;
upsertFieldPermissions: Array<FieldPermission>;
upsertObjectPermissions: Array<ObjectPermission>; upsertObjectPermissions: Array<ObjectPermission>;
upsertSettingPermissions: Array<SettingPermission>; upsertSettingPermissions: Array<SettingPermission>;
userLookupAdminPanel: UserLookup; userLookupAdminPanel: UserLookup;
@ -1405,6 +1424,11 @@ export type MutationUploadWorkspaceLogoArgs = {
}; };
export type MutationUpsertFieldPermissionsArgs = {
upsertFieldPermissionsInput: UpsertFieldPermissionsInput;
};
export type MutationUpsertObjectPermissionsArgs = { export type MutationUpsertObjectPermissionsArgs = {
upsertObjectPermissionsInput: UpsertObjectPermissionsInput; upsertObjectPermissionsInput: UpsertObjectPermissionsInput;
}; };
@ -2322,6 +2346,11 @@ export type UpdateWorkspaceInput = {
subdomain?: InputMaybe<Scalars['String']>; subdomain?: InputMaybe<Scalars['String']>;
}; };
export type UpsertFieldPermissionsInput = {
fieldPermissions: Array<FieldPermissionInput>;
roleId: Scalars['String'];
};
export type UpsertObjectPermissionsInput = { export type UpsertObjectPermissionsInput = {
objectPermissions: Array<ObjectPermissionInput>; objectPermissions: Array<ObjectPermissionInput>;
roleId: Scalars['String']; roleId: Scalars['String'];

View File

@ -0,0 +1,19 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddIndexOnObjectPermission1751890088507
implements MigrationInterface
{
name = 'AddIndexOnObjectPermission1751890088507';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`CREATE INDEX "IDX_OBJECT_PERMISSION_WORKSPACE_ID_ROLE_ID" ON "core"."objectPermission" ("workspaceId", "roleId") `,
);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(
`DROP INDEX "core"."IDX_OBJECT_PERMISSION_WORKSPACE_ID_ROLE_ID"`,
);
}
}

View File

@ -0,0 +1,45 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddFieldPermission1751993324990 implements MigrationInterface {
name = 'AddFieldPermission1751993324990';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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"`);
}
}

View File

@ -9,4 +9,5 @@ export enum FeatureFlagKey {
IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED', IS_MORPH_RELATION_ENABLED = 'IS_MORPH_RELATION_ENABLED',
IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED', IS_WORKFLOW_FILTERING_ENABLED = 'IS_WORKFLOW_FILTERING_ENABLED',
IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED', IS_RELATION_CONNECT_ENABLED = 'IS_RELATION_CONNECT_ENABLED',
IS_FIELDS_PERMISSIONS_ENABLED = 'IS_FIELDS_PERMISSIONS_ENABLED',
} }

View File

@ -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 { 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 { 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 { 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') @Entity('fieldMetadata')
// max length of index is 63 characters // max length of index is 63 characters
@ -148,4 +149,10 @@ export class FieldMetadataEntity<
@UpdateDateColumn({ type: 'timestamptz' }) @UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date; updatedAt: Date;
@OneToMany(
() => FieldPermissionEntity,
(fieldPermission: FieldPermissionEntity) => fieldPermission.fieldMetadata,
)
fieldPermissions: Relation<FieldPermissionEntity[]>;
} }

View File

@ -25,7 +25,7 @@ import {
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars'; 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 { 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 { 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'; import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
registerEnumType(IndexType, { registerEnumType(IndexType, {

View File

@ -13,13 +13,9 @@ import {
} from 'typeorm'; } from 'typeorm';
import { IndexFieldMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-field-metadata.entity'; 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'; 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', [ @Unique('IDX_INDEX_METADATA_NAME_WORKSPACE_ID_OBJECT_METADATA_ID_UNIQUE', [
'name', 'name',
'workspaceId', 'workspaceId',

View File

@ -6,10 +6,8 @@ import { isDefined } from 'twenty-shared/utils';
import { QueryRunner, Repository } from 'typeorm'; import { QueryRunner, Repository } from 'typeorm';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity'; import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
IndexMetadataEntity, import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types';
IndexType,
} from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { generateDeterministicIndexName } from 'src/engine/metadata-modules/index-metadata/utils/generate-deterministic-index-name'; 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 { 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'; import { generateMigrationName } from 'src/engine/metadata-modules/workspace-migration/utils/generate-migration-name.util';

View File

@ -1,6 +1,6 @@
import { IndexFieldMetadataInterface } from 'src/engine/metadata-modules/index-metadata/interfaces/index-field-metadata.interface'; 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 { export interface IndexMetadataInterface {
id: string; id: string;

View File

@ -0,0 +1,4 @@
export enum IndexType {
BTREE = 'BTREE',
GIN = 'GIN',
}

View File

@ -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 { 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 { 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'; import { getUniqueConstraintsFields } from 'src/engine/metadata-modules/index-metadata/utils/getUniqueConstraintsFields.util';
describe('getUniqueConstraintsFields', () => { describe('getUniqueConstraintsFields', () => {

View File

@ -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 { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-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 { 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'; import { ObjectPermissionEntity } from 'src/engine/metadata-modules/object-permission/object-permission.entity';
@Entity('objectMetadata') @Entity('objectMetadata')
@ -134,4 +135,13 @@ export class ObjectMetadataEntity implements ObjectMetadataInterface {
}, },
) )
objectPermissions: Relation<ObjectPermissionEntity[]>; objectPermissions: Relation<ObjectPermissionEntity[]>;
@OneToMany(
() => FieldPermissionEntity,
(fieldPermission: FieldPermissionEntity) => fieldPermission.objectMetadata,
{
cascade: true,
},
)
fieldPermissions: Relation<FieldPermissionEntity[]>;
} }

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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<FieldPermissionEntity>
>;
let roleRepository: jest.Mocked<Repository<RoleEntity>>;
let workspacePermissionsCacheService: jest.Mocked<WorkspacePermissionsCacheService>;
let workspaceCacheStorageService: jest.Mocked<WorkspaceCacheStorageService>;
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>(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,
),
);
});
});
});
});

View File

@ -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<RoleEntity>;
@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<ObjectMetadataEntity>;
@ManyToOne(
() => FieldMetadataEntity,
(fieldMetadata) => fieldMetadata.fieldPermissions,
{
onDelete: 'CASCADE',
},
)
@JoinColumn({ name: 'fieldMetadataId' })
fieldMetadata: Relation<FieldMetadataEntity>;
@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<Workspace>;
@CreateDateColumn({ type: 'timestamptz' })
createdAt: Date;
@UpdateDateColumn({ type: 'timestamptz' })
updatedAt: Date;
}

View File

@ -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<RoleEntity>,
@InjectRepository(FieldPermissionEntity, 'core')
private readonly fieldPermissionsRepository: Repository<FieldPermissionEntity>,
private readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService,
private readonly workspaceCacheStorageService: WorkspaceCacheStorageService,
) {}
public async upsertFieldPermissions({
workspaceId,
input,
}: {
workspaceId: string;
input: UpsertFieldPermissionsInput;
}): Promise<FieldPermissionEntity[]> {
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<string, ObjectMetadataItemWithFieldMaps>;
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);
}
}
}
}

View File

@ -2,6 +2,7 @@ import {
Column, Column,
CreateDateColumn, CreateDateColumn,
Entity, Entity,
Index,
JoinColumn, JoinColumn,
ManyToOne, ManyToOne,
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
@ -18,6 +19,7 @@ import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
'objectMetadataId', 'objectMetadataId',
'roleId', 'roleId',
]) ])
@Index('IDX_OBJECT_PERMISSION_WORKSPACE_ID_ROLE_ID', ['workspaceId', 'roleId'])
export class ObjectPermissionEntity { export class ObjectPermissionEntity {
@PrimaryGeneratedColumn('uuid') @PrimaryGeneratedColumn('uuid')
id: string; id: string;

View File

@ -1,7 +1,10 @@
import { Module } from '@nestjs/common'; import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm'; 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 { 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 { ObjectPermissionEntity } from 'src/engine/metadata-modules/object-permission/object-permission.entity';
import { ObjectPermissionService } from 'src/engine/metadata-modules/object-permission/object-permission.service'; import { ObjectPermissionService } from 'src/engine/metadata-modules/object-permission/object-permission.service';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity'; import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
@ -11,13 +14,19 @@ import { WorkspaceCacheStorageModule } from 'src/engine/workspace-cache-storage/
@Module({ @Module({
imports: [ imports: [
TypeOrmModule.forFeature( TypeOrmModule.forFeature(
[ObjectPermissionEntity, RoleEntity, ObjectMetadataEntity], [
ObjectPermissionEntity,
RoleEntity,
ObjectMetadataEntity,
FieldPermissionEntity,
FieldMetadataEntity,
],
'core', 'core',
), ),
WorkspaceCacheStorageModule, WorkspaceCacheStorageModule,
WorkspacePermissionsCacheModule, WorkspacePermissionsCacheModule,
], ],
providers: [ObjectPermissionService], providers: [ObjectPermissionService, FieldPermissionService],
exports: [ObjectPermissionService], exports: [ObjectPermissionService, FieldPermissionService],
}) })
export class ObjectPermissionModule {} export class ObjectPermissionModule {}

View File

@ -31,10 +31,19 @@ export enum PermissionsExceptionCode {
DEFAULT_ROLE_CANNOT_BE_DELETED = 'DEFAULT_ROLE_CANNOT_BE_DELETED', DEFAULT_ROLE_CANNOT_BE_DELETED = 'DEFAULT_ROLE_CANNOT_BE_DELETED',
NO_PERMISSIONS_FOUND_IN_DATASOURCE = 'NO_PERMISSIONS_FOUND_IN_DATASOURCE', 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_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', METHOD_NOT_ALLOWED = 'METHOD_NOT_ALLOWED',
RAW_SQL_NOT_ALLOWED = 'RAW_SQL_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_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', 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 { export enum PermissionsExceptionMessage {
@ -60,6 +69,15 @@ export enum PermissionsExceptionMessage {
DEFAULT_ROLE_CANNOT_BE_DELETED = 'Default role cannot be deleted', DEFAULT_ROLE_CANNOT_BE_DELETED = 'Default role cannot be deleted',
NO_PERMISSIONS_FOUND_IN_DATASOURCE = 'No permissions found in datasource', 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_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_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', 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',
} }

View File

@ -27,16 +27,24 @@ export const permissionGraphqlApiExceptionHandler = (
case PermissionsExceptionCode.CANNOT_DELETE_LAST_ADMIN_USER: case PermissionsExceptionCode.CANNOT_DELETE_LAST_ADMIN_USER:
case PermissionsExceptionCode.ROLE_NOT_EDITABLE: case PermissionsExceptionCode.ROLE_NOT_EDITABLE:
case PermissionsExceptionCode.CANNOT_ADD_OBJECT_PERMISSION_ON_SYSTEM_OBJECT: case PermissionsExceptionCode.CANNOT_ADD_OBJECT_PERMISSION_ON_SYSTEM_OBJECT:
case PermissionsExceptionCode.CANNOT_ADD_FIELD_PERMISSION_ON_SYSTEM_OBJECT:
throw new ForbiddenError(error.message); throw new ForbiddenError(error.message);
case PermissionsExceptionCode.INVALID_ARG: case PermissionsExceptionCode.INVALID_ARG:
case PermissionsExceptionCode.INVALID_SETTING: case PermissionsExceptionCode.INVALID_SETTING:
case PermissionsExceptionCode.CANNOT_GIVE_WRITING_PERMISSION_ON_NON_READABLE_OBJECT: case PermissionsExceptionCode.CANNOT_GIVE_WRITING_PERMISSION_ON_NON_READABLE_OBJECT:
case PermissionsExceptionCode.CANNOT_GIVE_WRITING_PERMISSION_WITHOUT_READING_PERMISSION: 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); throw new UserInputError(error.message);
case PermissionsExceptionCode.ROLE_NOT_FOUND: case PermissionsExceptionCode.ROLE_NOT_FOUND:
case PermissionsExceptionCode.USER_WORKSPACE_NOT_FOUND: case PermissionsExceptionCode.USER_WORKSPACE_NOT_FOUND:
case PermissionsExceptionCode.OBJECT_METADATA_NOT_FOUND: case PermissionsExceptionCode.OBJECT_METADATA_NOT_FOUND:
case PermissionsExceptionCode.FIELD_METADATA_NOT_FOUND:
case PermissionsExceptionCode.PERMISSION_NOT_FOUND:
throw new NotFoundError(error.message); throw new NotFoundError(error.message);
case PermissionsExceptionCode.UPSERT_FIELD_PERMISSION_FAILED:
case PermissionsExceptionCode.DEFAULT_ROLE_NOT_FOUND: case PermissionsExceptionCode.DEFAULT_ROLE_NOT_FOUND:
case PermissionsExceptionCode.WORKSPACE_ID_ROLE_USER_WORKSPACE_MISMATCH: case PermissionsExceptionCode.WORKSPACE_ID_ROLE_USER_WORKSPACE_MISMATCH:
case PermissionsExceptionCode.TOO_MANY_ADMIN_CANDIDATES: case PermissionsExceptionCode.TOO_MANY_ADMIN_CANDIDATES:
@ -50,6 +58,7 @@ export const permissionGraphqlApiExceptionHandler = (
case PermissionsExceptionCode.NO_PERMISSIONS_FOUND_IN_DATASOURCE: case PermissionsExceptionCode.NO_PERMISSIONS_FOUND_IN_DATASOURCE:
case PermissionsExceptionCode.METHOD_NOT_ALLOWED: case PermissionsExceptionCode.METHOD_NOT_ALLOWED:
case PermissionsExceptionCode.RAW_SQL_NOT_ALLOWED: case PermissionsExceptionCode.RAW_SQL_NOT_ALLOWED:
case PermissionsExceptionCode.OBJECT_PERMISSION_NOT_FOUND:
throw error; throw error;
default: { default: {
const _exhaustiveCheck: never = error.code; const _exhaustiveCheck: never = error.code;

View File

@ -9,6 +9,7 @@ import {
UpdateDateColumn, UpdateDateColumn,
} from 'typeorm'; } 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 { ObjectPermissionEntity } from 'src/engine/metadata-modules/object-permission/object-permission.entity';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity'; import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { SettingPermissionEntity } from 'src/engine/metadata-modules/setting-permission/setting-permission.entity'; import { SettingPermissionEntity } from 'src/engine/metadata-modules/setting-permission/setting-permission.entity';
@ -72,4 +73,10 @@ export class RoleEntity {
(settingPermission: SettingPermissionEntity) => settingPermission.role, (settingPermission: SettingPermissionEntity) => settingPermission.role,
) )
settingPermissions: Relation<SettingPermissionEntity[]>; settingPermissions: Relation<SettingPermissionEntity[]>;
@OneToMany(
() => FieldPermissionEntity,
(fieldPermission: FieldPermissionEntity) => fieldPermission.role,
)
fieldPermissions: Relation<FieldPermissionEntity[]>;
} }

View File

@ -22,8 +22,11 @@ import { SettingsPermissionsGuard } from 'src/engine/guards/settings-permissions
import { UserAuthGuard } from 'src/engine/guards/user-auth.guard'; import { UserAuthGuard } from 'src/engine/guards/user-auth.guard';
import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard'; import { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { AgentRoleService } from 'src/engine/metadata-modules/agent-role/agent-role.service'; 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 { 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 { 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 { 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 { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants';
import { import {
@ -60,6 +63,7 @@ export class RoleResolver {
private readonly objectPermissionService: ObjectPermissionService, private readonly objectPermissionService: ObjectPermissionService,
private readonly settingPermissionService: SettingPermissionService, private readonly settingPermissionService: SettingPermissionService,
private readonly agentRoleService: AgentRoleService, private readonly agentRoleService: AgentRoleService,
private readonly fieldPermissionService: FieldPermissionService,
) {} ) {}
@Query(() => [RoleDTO]) @Query(() => [RoleDTO])
@ -179,6 +183,18 @@ export class RoleResolver {
}); });
} }
@Mutation(() => [FieldPermissionDTO])
async upsertFieldPermissions(
@AuthWorkspace() workspace: Workspace,
@Args('upsertFieldPermissionsInput')
upsertFieldPermissionsInput: UpsertFieldPermissionsInput,
): Promise<FieldPermissionDTO[]> {
return this.fieldPermissionService.upsertFieldPermissions({
workspaceId: workspace.id,
input: upsertFieldPermissionsInput,
});
}
@Mutation(() => Boolean) @Mutation(() => Boolean)
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED) @RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
async assignRoleToAgent( async assignRoleToAgent(

View File

@ -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 { 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 { 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 { 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 { 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 { 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'; import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';

View File

@ -5,7 +5,7 @@ import {
PrimaryGeneratedColumn, PrimaryGeneratedColumn,
} from 'typeorm'; } 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'; import { RelationOnDeleteAction } from 'src/engine/metadata-modules/relation-metadata/relation-on-delete-action.type';
export enum WorkspaceMigrationColumnActionType { export enum WorkspaceMigrationColumnActionType {

View File

@ -8,10 +8,12 @@ import {
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { In, Repository } from 'typeorm'; 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 { 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 { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity'; import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.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 { 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 { 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'; import { TwentyORMExceptionCode } from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
@ -38,6 +40,7 @@ export class WorkspacePermissionsCacheService {
@InjectRepository(RoleTargetsEntity, 'core') @InjectRepository(RoleTargetsEntity, 'core')
private readonly roleTargetsRepository: Repository<RoleTargetsEntity>, private readonly roleTargetsRepository: Repository<RoleTargetsEntity>,
private readonly workspacePermissionsCacheStorageService: WorkspacePermissionsCacheStorageService, private readonly workspacePermissionsCacheStorageService: WorkspacePermissionsCacheStorageService,
private readonly workspaceFeatureFlagsMapCacheService: WorkspaceFeatureFlagsMapCacheService,
) {} ) {}
async recomputeRolesPermissionsCache({ async recomputeRolesPermissionsCache({
@ -156,7 +159,7 @@ export class WorkspacePermissionsCacheService {
return userWorkspaceRoleMap[userWorkspaceId]; return userWorkspaceRoleMap[userWorkspaceId];
} }
private async getObjectRecordPermissionsForRoles({ async getObjectRecordPermissionsForRoles({
workspaceId, workspaceId,
roleIds, roleIds,
}: { }: {
@ -165,12 +168,24 @@ export class WorkspacePermissionsCacheService {
}): Promise<ObjectRecordsPermissionsByRoleId> { }): Promise<ObjectRecordsPermissionsByRoleId> {
let roles: RoleEntity[] = []; let roles: RoleEntity[] = [];
const workspaceFeatureFlagsMap =
await this.workspaceFeatureFlagsMapCacheService.getWorkspaceFeatureFlagsMap(
{ workspaceId },
);
const isFieldPermissionsEnabled =
workspaceFeatureFlagsMap[FeatureFlagKey.IS_FIELDS_PERMISSIONS_ENABLED];
roles = await this.roleRepository.find({ roles = await this.roleRepository.find({
where: { where: {
workspaceId, workspaceId,
...(roleIds ? { id: In(roleIds) } : {}), ...(roleIds ? { id: In(roleIds) } : {}),
}, },
relations: ['objectPermissions', 'settingPermissions'], relations: [
'objectPermissions',
'settingPermissions',
...(isFieldPermissionsEnabled ? ['fieldPermissions'] : []),
],
}); });
const workspaceObjectMetadataCollection = const workspaceObjectMetadataCollection =
@ -188,6 +203,10 @@ export class WorkspacePermissionsCacheService {
let canUpdate = role.canUpdateAllObjectRecords; let canUpdate = role.canUpdateAllObjectRecords;
let canSoftDelete = role.canSoftDeleteAllObjectRecords; let canSoftDelete = role.canSoftDeleteAllObjectRecords;
let canDestroy = role.canDestroyAllObjectRecords; let canDestroy = role.canDestroyAllObjectRecords;
const restrictedFields: Record<
string,
{ canRead?: boolean | null; canUpdate?: boolean | null }
> = {};
if ( if (
standardId && standardId &&
@ -230,6 +249,20 @@ export class WorkspacePermissionsCacheService {
objectRecordPermissionsOverride?.canDestroyObjectRecords, objectRecordPermissionsOverride?.canDestroyObjectRecords,
canDestroy, 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] = { objectRecordsPermissions[objectMetadataId] = {
@ -237,6 +270,7 @@ export class WorkspacePermissionsCacheService {
canUpdate, canUpdate,
canSoftDelete, canSoftDelete,
canDestroy, canDestroy,
restrictedFields,
}; };
permissionsByRoleId[role.id] = objectRecordsPermissions; permissionsByRoleId[role.id] = objectRecordsPermissions;

View File

@ -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 { 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 { 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 { 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 { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { WorkspaceCustomEntity } from 'src/engine/twenty-orm/decorators/workspace-custom-entity.decorator'; import { WorkspaceCustomEntity } from 'src/engine/twenty-orm/decorators/workspace-custom-entity.decorator';

View File

@ -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 { 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 { 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'; import { convertClassNameToObjectMetadataName } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/convert-class-to-object-metadata-name.util';

View File

@ -72,6 +72,7 @@ describe('WorkspaceEntityManager', () => {
canUpdate: false, canUpdate: false,
canSoftDelete: false, canSoftDelete: false,
canDestroy: false, canDestroy: false,
restrictedFields: {},
}, },
}, },
}; };

View File

@ -1,6 +1,6 @@
import { Gate } from 'src/engine/twenty-orm/interfaces/gate.interface'; 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 { export interface WorkspaceIndexMetadataArgs {
/** /**

View File

@ -75,6 +75,7 @@ describe('WorkspaceRepository', () => {
canUpdate: false, canUpdate: false,
canSoftDelete: false, canSoftDelete: false,
canDestroy: false, canDestroy: false,
restrictedFields: {},
}, },
}; };
mockQueryRunner = {} as QueryRunner; mockQueryRunner = {} as QueryRunner;

View File

@ -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'; import { getColumnsForIndex } from 'src/engine/twenty-orm/utils/get-default-columns-for-index.util';
describe('getColumnsForIndex', () => { describe('getColumnsForIndex', () => {

View File

@ -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) => { export const getColumnsForIndex = (indexType?: IndexType) => {
switch (indexType) { switch (indexType) {

View File

@ -3,7 +3,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils'; import { isDefined } from 'twenty-shared/utils';
import { QueryRunner, Table, TableColumn } from 'typeorm'; 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 { import {
WorkspaceMigrationColumnAction, WorkspaceMigrationColumnAction,
WorkspaceMigrationColumnActionType, WorkspaceMigrationColumnActionType,

View File

@ -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 { 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 { WorkspaceSyncContext } from 'src/engine/workspace-manager/workspace-sync-metadata/interfaces/workspace-sync-context.interface';
import { import { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
IndexMetadataEntity, import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types';
IndexType,
} from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { generateDeterministicIndexName } from 'src/engine/metadata-modules/index-metadata/utils/generate-deterministic-index-name'; 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 { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity'; import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';

View File

@ -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 { 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 { 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 { 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 { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { WorkspaceDuplicateCriteria } from 'src/engine/twenty-orm/decorators/workspace-duplicate-criteria.decorator'; import { WorkspaceDuplicateCriteria } from 'src/engine/twenty-orm/decorators/workspace-duplicate-criteria.decorator';
import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator'; import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator';

View File

@ -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 { 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 { 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 { 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 { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator'; import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator';
import { WorkspaceFieldIndex } from 'src/engine/twenty-orm/decorators/workspace-field-index.decorator'; import { WorkspaceFieldIndex } from 'src/engine/twenty-orm/decorators/workspace-field-index.decorator';

View File

@ -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 { 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 { 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 { 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 { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator'; import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator';
import { WorkspaceFieldIndex } from 'src/engine/twenty-orm/decorators/workspace-field-index.decorator'; import { WorkspaceFieldIndex } from 'src/engine/twenty-orm/decorators/workspace-field-index.decorator';

View File

@ -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 { 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 { 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 { 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 { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { WorkspaceDuplicateCriteria } from 'src/engine/twenty-orm/decorators/workspace-duplicate-criteria.decorator'; import { WorkspaceDuplicateCriteria } from 'src/engine/twenty-orm/decorators/workspace-duplicate-criteria.decorator';
import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator'; import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator';

View File

@ -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 { 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 { 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 { 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 { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator'; import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator';
import { WorkspaceFieldIndex } from 'src/engine/twenty-orm/decorators/workspace-field-index.decorator'; import { WorkspaceFieldIndex } from 'src/engine/twenty-orm/decorators/workspace-field-index.decorator';

View File

@ -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 { 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 { 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 { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator'; import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator';
import { WorkspaceFieldIndex } from 'src/engine/twenty-orm/decorators/workspace-field-index.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 { 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 { 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 { 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 { 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 { 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 { export enum WorkflowRunStatus {
NOT_STARTED = 'NOT_STARTED', NOT_STARTED = 'NOT_STARTED',

View File

@ -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 { 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 { 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 { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator'; import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator';
import { WorkspaceFieldIndex } from 'src/engine/twenty-orm/decorators/workspace-field-index.decorator'; import { WorkspaceFieldIndex } from 'src/engine/twenty-orm/decorators/workspace-field-index.decorator';

View File

@ -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 { 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 { 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 { 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 { 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 { 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 { 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 { WorkspaceField } from 'src/engine/twenty-orm/decorators/workspace-field.decorator';
import { WorkspaceIsNullable } from 'src/engine/twenty-orm/decorators/workspace-is-nullable.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 { WorkspaceIsSystem } from 'src/engine/twenty-orm/decorators/workspace-is-system.decorator';
import { WorkspaceRelation } from 'src/engine/twenty-orm/decorators/workspace-relation.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 { 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_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 { 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 { FavoriteWorkspaceEntity } from 'src/modules/favorite/standard-objects/favorite.workspace-entity';
import { TimelineActivityWorkspaceEntity } from 'src/modules/timeline/standard-objects/timeline-activity.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 { 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 { 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 { 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 { export enum WorkflowStatus {
DRAFT = 'DRAFT', DRAFT = 'DRAFT',

View File

@ -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 { 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 { 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 { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator'; import { WorkspaceEntity } from 'src/engine/twenty-orm/decorators/workspace-entity.decorator';
import { WorkspaceFieldIndex } from 'src/engine/twenty-orm/decorators/workspace-field-index.decorator'; import { WorkspaceFieldIndex } from 'src/engine/twenty-orm/decorators/workspace-field-index.decorator';

View File

@ -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 { 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 { 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 { 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 { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants';
import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception'; 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', () => { describe('upsertSettingPermissions', () => {

View File

@ -47,6 +47,7 @@ describe('AgentToolService Integration', () => {
canUpdate: true, canUpdate: true,
canSoftDelete: true, canSoftDelete: true,
canDestroy: true, canDestroy: true,
restrictedFields: {},
}, },
}, },
}, },
@ -91,6 +92,7 @@ describe('AgentToolService Integration', () => {
canUpdate: false, canUpdate: false,
canSoftDelete: false, canSoftDelete: false,
canDestroy: false, canDestroy: false,
restrictedFields: {},
}, },
}, },
}, },
@ -168,6 +170,7 @@ describe('AgentToolService Integration', () => {
canUpdate: true, canUpdate: true,
canSoftDelete: true, canSoftDelete: true,
canDestroy: false, canDestroy: false,
restrictedFields: {},
}, },
}, },
}, },
@ -767,12 +770,14 @@ describe('AgentToolService Integration', () => {
canUpdate: true, canUpdate: true,
canSoftDelete: false, canSoftDelete: false,
canDestroy: false, canDestroy: false,
restrictedFields: {},
}, },
[secondObjectMetadata.id]: { [secondObjectMetadata.id]: {
canRead: true, canRead: true,
canUpdate: false, canUpdate: false,
canSoftDelete: true, canSoftDelete: true,
canDestroy: false, canDestroy: false,
restrictedFields: {},
}, },
}, },
}, },

View File

@ -158,6 +158,7 @@ export const createAgentToolTestModule =
targetRelationFields: [], targetRelationFields: [],
dataSource: {} as any, dataSource: {} as any,
objectPermissions: [], objectPermissions: [],
fieldPermissions: [],
}; };
return { return {
@ -207,6 +208,7 @@ export const setupBasicPermissions = (context: AgentToolTestContext) => {
canUpdate: true, canUpdate: true,
canSoftDelete: true, canSoftDelete: true,
canDestroy: false, canDestroy: false,
restrictedFields: {},
}, },
}, },
}, },

View File

@ -1,7 +1,14 @@
type ObjectMetadataId = string; type ObjectMetadataId = string;
export type ObjectRecordsPermissions = Record<ObjectMetadataId, { export type ObjectRecordsPermissions = Record<
ObjectMetadataId,
{
canRead: boolean; canRead: boolean;
canUpdate: boolean; canUpdate: boolean;
canSoftDelete: boolean; canSoftDelete: boolean;
canDestroy: boolean; canDestroy: boolean;
}>; restrictedFields: Record<
string,
{ canRead?: boolean | null; canUpdate?: boolean | null }
>;
}
>;