[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

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

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

View File

@ -25,7 +25,7 @@ import {
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { IsValidMetadataName } from 'src/engine/decorators/metadata/is-valid-metadata-name.decorator';
import { IndexFieldMetadataDTO } from 'src/engine/metadata-modules/index-metadata/dtos/index-field-metadata.dto';
import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types';
import { ObjectMetadataDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-metadata.dto';
registerEnumType(IndexType, {

View File

@ -13,13 +13,9 @@ import {
} from 'typeorm';
import { IndexFieldMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-field-metadata.entity';
import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
export enum IndexType {
BTREE = 'BTREE',
GIN = 'GIN',
}
@Unique('IDX_INDEX_METADATA_NAME_WORKSPACE_ID_OBJECT_METADATA_ID_UNIQUE', [
'name',
'workspaceId',

View File

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

View File

@ -1,6 +1,6 @@
import { IndexFieldMetadataInterface } from 'src/engine/metadata-modules/index-metadata/interfaces/index-field-metadata.interface';
import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types';
export interface IndexMetadataInterface {
id: string;

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 { IndexMetadataInterface } from 'src/engine/metadata-modules/index-metadata/interfaces/index-metadata.interface';
import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types';
import { getUniqueConstraintsFields } from 'src/engine/metadata-modules/index-metadata/utils/getUniqueConstraintsFields.util';
describe('getUniqueConstraintsFields', () => {

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 { IndexMetadataEntity } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { ObjectStandardOverridesDTO } from 'src/engine/metadata-modules/object-metadata/dtos/object-standard-overrides.dto';
import { FieldPermissionEntity } from 'src/engine/metadata-modules/object-permission/field-permission/field-permission.entity';
import { ObjectPermissionEntity } from 'src/engine/metadata-modules/object-permission/object-permission.entity';
@Entity('objectMetadata')
@ -134,4 +135,13 @@ export class ObjectMetadataEntity implements ObjectMetadataInterface {
},
)
objectPermissions: Relation<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,
CreateDateColumn,
Entity,
Index,
JoinColumn,
ManyToOne,
PrimaryGeneratedColumn,
@ -18,6 +19,7 @@ import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
'objectMetadataId',
'roleId',
])
@Index('IDX_OBJECT_PERMISSION_WORKSPACE_ID_ROLE_ID', ['workspaceId', 'roleId'])
export class ObjectPermissionEntity {
@PrimaryGeneratedColumn('uuid')
id: string;

View File

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

View File

@ -31,10 +31,19 @@ export enum PermissionsExceptionCode {
DEFAULT_ROLE_CANNOT_BE_DELETED = 'DEFAULT_ROLE_CANNOT_BE_DELETED',
NO_PERMISSIONS_FOUND_IN_DATASOURCE = 'NO_PERMISSIONS_FOUND_IN_DATASOURCE',
CANNOT_ADD_OBJECT_PERMISSION_ON_SYSTEM_OBJECT = 'CANNOT_ADD_OBJECT_PERMISSION_ON_SYSTEM_OBJECT',
CANNOT_ADD_FIELD_PERMISSION_ON_SYSTEM_OBJECT = 'CANNOT_ADD_FIELD_PERMISSION_ON_SYSTEM_OBJECT',
METHOD_NOT_ALLOWED = 'METHOD_NOT_ALLOWED',
RAW_SQL_NOT_ALLOWED = 'RAW_SQL_NOT_ALLOWED',
CANNOT_GIVE_WRITING_PERMISSION_ON_NON_READABLE_OBJECT = 'CANNOT_GIVE_WRITING_PERMISSION_ON_NON_READABLE_OBJECT',
CANNOT_GIVE_WRITING_PERMISSION_WITHOUT_READING_PERMISSION = 'CANNOT_GIVE_WRITING_PERMISSION_WITHOUT_READING_PERMISSION',
FIELD_METADATA_NOT_FOUND = 'FIELD_METADATA_NOT_FOUND',
ONLY_FIELD_RESTRICTION_ALLOWED = 'ONLY_FIELD_RESTRICTION_ALLOWED',
FIELD_RESTRICTION_ONLY_ALLOWED_ON_READABLE_OBJECT = 'FIELD_RESTRICTION_ONLY_ALLOWED_ON_READABLE_OBJECT',
FIELD_RESTRICTION_ON_UPDATE_ONLY_ALLOWED_ON_UPDATABLE_OBJECT = 'FIELD_RESTRICTION_ON_UPDATE_ONLY_ALLOWED_ON_UPDATABLE_OBJECT',
UPSERT_FIELD_PERMISSION_FAILED = 'UPSERT_FIELD_PERMISSION_FAILED',
PERMISSION_NOT_FOUND = 'PERMISSION_NOT_FOUND',
OBJECT_PERMISSION_NOT_FOUND = 'OBJECT_PERMISSION_NOT_FOUND',
EMPTY_FIELD_PERMISSION_NOT_ALLOWED = 'EMPTY_FIELD_PERMISSION_NOT_ALLOWED',
}
export enum PermissionsExceptionMessage {
@ -60,6 +69,15 @@ export enum PermissionsExceptionMessage {
DEFAULT_ROLE_CANNOT_BE_DELETED = 'Default role cannot be deleted',
NO_PERMISSIONS_FOUND_IN_DATASOURCE = 'No permissions found in datasource',
CANNOT_ADD_OBJECT_PERMISSION_ON_SYSTEM_OBJECT = 'Cannot add object permission on system object',
CANNOT_ADD_FIELD_PERMISSION_ON_SYSTEM_OBJECT = 'Cannot add field permission on system object',
CANNOT_GIVE_WRITING_PERMISSION_ON_NON_READABLE_OBJECT = 'Cannot give update permission to non-readable object',
CANNOT_GIVE_WRITING_PERMISSION_WITHOUT_READING_PERMISSION = 'Cannot give writing permission without reading permission',
FIELD_METADATA_NOT_FOUND = 'Field metadata not found',
ONLY_FIELD_RESTRICTION_ALLOWED = 'Field permission can only introduce a restriction',
FIELD_RESTRICTION_ONLY_ALLOWED_ON_READABLE_OBJECT = 'Field restriction only makes sense on readable object',
FIELD_RESTRICTION_ON_UPDATE_ONLY_ALLOWED_ON_UPDATABLE_OBJECT = 'Field restriction on update only makes sense on updatable object',
UPSERT_FIELD_PERMISSION_FAILED = 'Failed to upsert field permission',
PERMISSION_NOT_FOUND = 'Permission not found',
OBJECT_PERMISSION_NOT_FOUND = 'Object permission not found',
EMPTY_FIELD_PERMISSION_NOT_ALLOWED = 'Empty field permission not allowed',
}

View File

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

View File

@ -9,6 +9,7 @@ import {
UpdateDateColumn,
} from 'typeorm';
import { FieldPermissionEntity } from 'src/engine/metadata-modules/object-permission/field-permission/field-permission.entity';
import { ObjectPermissionEntity } from 'src/engine/metadata-modules/object-permission/object-permission.entity';
import { RoleTargetsEntity } from 'src/engine/metadata-modules/role/role-targets.entity';
import { SettingPermissionEntity } from 'src/engine/metadata-modules/setting-permission/setting-permission.entity';
@ -72,4 +73,10 @@ export class RoleEntity {
(settingPermission: SettingPermissionEntity) => settingPermission.role,
)
settingPermissions: Relation<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 { WorkspaceAuthGuard } from 'src/engine/guards/workspace-auth.guard';
import { AgentRoleService } from 'src/engine/metadata-modules/agent-role/agent-role.service';
import { FieldPermissionDTO } from 'src/engine/metadata-modules/object-permission/dtos/field-permission.dto';
import { ObjectPermissionDTO } from 'src/engine/metadata-modules/object-permission/dtos/object-permission.dto';
import { UpsertFieldPermissionsInput } from 'src/engine/metadata-modules/object-permission/dtos/upsert-field-permissions.input';
import { UpsertObjectPermissionsInput } from 'src/engine/metadata-modules/object-permission/dtos/upsert-object-permissions.input';
import { FieldPermissionService } from 'src/engine/metadata-modules/object-permission/field-permission/field-permission.service';
import { ObjectPermissionService } from 'src/engine/metadata-modules/object-permission/object-permission.service';
import { SettingPermissionType } from 'src/engine/metadata-modules/permissions/constants/setting-permission-type.constants';
import {
@ -60,6 +63,7 @@ export class RoleResolver {
private readonly objectPermissionService: ObjectPermissionService,
private readonly settingPermissionService: SettingPermissionService,
private readonly agentRoleService: AgentRoleService,
private readonly fieldPermissionService: FieldPermissionService,
) {}
@Query(() => [RoleDTO])
@ -179,6 +183,18 @@ export class RoleResolver {
});
}
@Mutation(() => [FieldPermissionDTO])
async upsertFieldPermissions(
@AuthWorkspace() workspace: Workspace,
@Args('upsertFieldPermissionsInput')
upsertFieldPermissionsInput: UpsertFieldPermissionsInput,
): Promise<FieldPermissionDTO[]> {
return this.fieldPermissionService.upsertFieldPermissions({
workspaceId: workspace.id,
input: upsertFieldPermissionsInput,
});
}
@Mutation(() => Boolean)
@RequireFeatureFlag(FeatureFlagKey.IS_AI_ENABLED)
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 { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { IndexMetadataService } from 'src/engine/metadata-modules/index-metadata/index-metadata.service';
import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types';
import { CreateObjectInput } from 'src/engine/metadata-modules/object-metadata/dtos/create-object.input';
import { DEFAULT_LABEL_IDENTIFIER_FIELD_NAME } from 'src/engine/metadata-modules/object-metadata/object-metadata.constants';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';

View File

@ -5,7 +5,7 @@ import {
PrimaryGeneratedColumn,
} from 'typeorm';
import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types';
import { RelationOnDeleteAction } from 'src/engine/metadata-modules/relation-metadata/relation-on-delete-action.type';
export enum WorkspaceMigrationColumnActionType {

View File

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

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 { ActorMetadata } from 'src/engine/metadata-modules/field-metadata/composite-types/actor.composite-type';
import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types';
import { DEFAULT_LABEL_IDENTIFIER_FIELD_NAME } from 'src/engine/metadata-modules/object-metadata/object-metadata.constants';
import { BaseWorkspaceEntity } from 'src/engine/twenty-orm/base.workspace-entity';
import { WorkspaceCustomEntity } from 'src/engine/twenty-orm/decorators/workspace-custom-entity.decorator';

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 { 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';

View File

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

View File

@ -1,6 +1,6 @@
import { Gate } from 'src/engine/twenty-orm/interfaces/gate.interface';
import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types';
export interface WorkspaceIndexMetadataArgs {
/**

View File

@ -75,6 +75,7 @@ describe('WorkspaceRepository', () => {
canUpdate: false,
canSoftDelete: false,
canDestroy: false,
restrictedFields: {},
},
};
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';
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) => {
switch (indexType) {

View File

@ -3,7 +3,7 @@ import { Injectable, Logger } from '@nestjs/common';
import { isDefined } from 'twenty-shared/utils';
import { QueryRunner, Table, TableColumn } from 'typeorm';
import { IndexType } from 'src/engine/metadata-modules/index-metadata/index-metadata.entity';
import { IndexType } from 'src/engine/metadata-modules/index-metadata/types/indexType.types';
import {
WorkspaceMigrationColumnAction,
WorkspaceMigrationColumnActionType,

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