[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:
@ -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[]>;
|
||||
}
|
||||
|
||||
@ -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, {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -0,0 +1,4 @@
|
||||
export enum IndexType {
|
||||
BTREE = 'BTREE',
|
||||
GIN = 'GIN',
|
||||
}
|
||||
@ -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', () => {
|
||||
|
||||
@ -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[]>;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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;
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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',
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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[]>;
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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;
|
||||
|
||||
Reference in New Issue
Block a user