[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

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