Migrate field permission to syncable entity (#18751)

Co-authored-by: Copilot Autofix powered by AI <223894421+github-code-quality[bot]@users.noreply.github.com>
Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Abdul Rahman
2026-03-27 19:17:27 +05:30
committed by GitHub
parent c2b058a6a7
commit 5efe69f8d3
105 changed files with 2956 additions and 490 deletions
@@ -2,13 +2,14 @@ import { Test, type TestingModule } from '@nestjs/testing';
import { getRepositoryToken } from '@nestjs/typeorm';
import { type ObjectsPermissionsByRoleId } from 'twenty-shared/types';
import { In, type Repository } from 'typeorm';
import { type Repository } from 'typeorm';
import {
fieldRelationMock,
fieldTextMock,
objectMetadataItemMock,
} from 'src/engine/api/__mocks__/object-metadata-item.mock';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { type UpsertFieldPermissionsInput } from 'src/engine/metadata-modules/object-permission/dtos/upsert-field-permissions.input';
@@ -21,8 +22,18 @@ import {
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
import { getMockFieldMetadataEntity } from 'src/utils/__test__/get-field-metadata-entity.mock';
const emptyFlatFieldPermissionMaps = {
byUniversalIdentifier: {},
universalIdentifierById: {},
byId: {},
idByUniversalIdentifier: {},
universalIdentifiersByApplicationId: {},
};
describe('FieldPermissionService', () => {
let service: FieldPermissionService;
let fieldPermissionsRepository: jest.Mocked<
@@ -32,6 +43,17 @@ describe('FieldPermissionService', () => {
let fieldMetadataRepository: jest.Mocked<Repository<FieldMetadataEntity>>;
let workspaceCacheService: jest.Mocked<WorkspaceCacheService>;
let workspaceManyOrAllFlatEntityMapsCacheService: jest.Mocked<WorkspaceManyOrAllFlatEntityMapsCacheService>;
let workspaceMigrationValidateBuildAndRunService: jest.Mocked<WorkspaceMigrationValidateBuildAndRunService>;
let flatRoleMaps: {
byUniversalIdentifier: Record<
string,
{ id: string; isEditable: boolean; universalIdentifier: string }
>;
universalIdentifierById: Record<string, string>;
byId: Record<string, unknown>;
idByUniversalIdentifier: Record<string, string>;
universalIdentifiersByApplicationId: Record<string, string[]>;
};
const testWorkspaceId = '20202020-0000-0000-0000-000000000000';
const testRoleId = '20202020-0000-0000-0000-000000000001';
@@ -114,10 +136,32 @@ describe('FieldPermissionService', () => {
find: jest.fn(),
},
},
{
provide: ApplicationService,
useValue: {
findWorkspaceTwentyStandardAndCustomApplicationOrThrow: jest
.fn()
.mockResolvedValue({
workspaceCustomFlatApplication: {
id: 'app-id',
universalIdentifier: 'app-universal-id',
},
}),
},
},
{
provide: WorkspaceMigrationValidateBuildAndRunService,
useValue: {
validateBuildAndRunWorkspaceMigration: jest.fn(),
},
},
],
}).compile();
service = module.get<FieldPermissionService>(FieldPermissionService);
workspaceMigrationValidateBuildAndRunService = module.get(
WorkspaceMigrationValidateBuildAndRunService,
);
fieldPermissionsRepository = module.get(
getRepositoryToken(FieldPermissionEntity),
);
@@ -136,9 +180,19 @@ describe('FieldPermissionService', () => {
fieldTextMock,
fieldRelationMock,
]);
(workspaceCacheService.getOrRecompute as jest.Mock).mockResolvedValue({
rolesPermissions: mockRolesPermissions,
} as any);
(workspaceCacheService.getOrRecompute as jest.Mock).mockImplementation(
(_workspaceId: string, keys: string[]) => {
if (keys?.includes('flatFieldPermissionMaps')) {
return Promise.resolve({
flatFieldPermissionMaps: emptyFlatFieldPermissionMaps,
});
}
if (keys?.includes('rolesPermissions')) {
return Promise.resolve({ rolesPermissions: mockRolesPermissions });
}
return Promise.resolve({});
},
);
const testFieldMetadata = getMockFieldMetadataEntity({
...fieldTextMock,
label: 'Test Field',
@@ -147,8 +201,22 @@ describe('FieldPermissionService', () => {
id: testFieldMetadataId,
});
flatRoleMaps = {
byUniversalIdentifier: {
[testRoleId]: {
id: testRoleId,
isEditable: true,
universalIdentifier: testRoleId,
},
},
universalIdentifierById: { [testRoleId]: testRoleId },
byId: {},
idByUniversalIdentifier: {},
universalIdentifiersByApplicationId: {},
};
workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps.mockResolvedValue(
{
flatRoleMaps,
flatObjectMetadataMaps: {
byUniversalIdentifier: {
[testObjectMetadataId]: {
@@ -169,11 +237,23 @@ describe('FieldPermissionService', () => {
universalIdentifier: fieldRelationMock.objectMetadataId,
applicationId: null,
} as any,
[fieldRelationMock.relationTargetObjectMetadataId!]: {
...objectMetadataItemMock,
id: fieldRelationMock.relationTargetObjectMetadataId,
fieldIds: [fieldRelationMock.relationTargetFieldMetadataId!],
indexMetadataIds: [],
viewIds: [],
universalIdentifier:
fieldRelationMock.relationTargetObjectMetadataId,
applicationId: null,
} as any,
},
universalIdentifierById: {
[testObjectMetadataId]: testObjectMetadataId,
[fieldRelationMock.objectMetadataId]:
fieldRelationMock.objectMetadataId,
[fieldRelationMock.relationTargetObjectMetadataId!]:
fieldRelationMock.relationTargetObjectMetadataId!,
},
universalIdentifiersByApplicationId: {},
},
@@ -181,10 +261,18 @@ describe('FieldPermissionService', () => {
byUniversalIdentifier: {
[testFieldMetadata.universalIdentifier]: testFieldMetadata as any,
[fieldRelationMock.universalIdentifier]: fieldRelationMock as any,
[fieldRelationMock.relationTargetFieldMetadataId!]: {
...fieldRelationMock,
id: fieldRelationMock.relationTargetFieldMetadataId,
universalIdentifier:
fieldRelationMock.relationTargetFieldMetadataId,
} as any,
},
universalIdentifierById: {
[testFieldMetadataId]: testFieldMetadata.universalIdentifier,
[fieldRelationMock.id]: fieldRelationMock.universalIdentifier,
[fieldRelationMock.relationTargetFieldMetadataId!]:
fieldRelationMock.relationTargetFieldMetadataId!,
},
universalIdentifiersByApplicationId: {},
},
@@ -218,6 +306,10 @@ describe('FieldPermissionService', () => {
describe('successful cases', () => {
it('should successfully upsert field permissions', async () => {
workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration.mockResolvedValue(
{ status: 'success' } as any,
);
const input = createUpsertInput([
{
canReadFieldValue: false,
@@ -225,51 +317,81 @@ describe('FieldPermissionService', () => {
},
]);
await service.upsertFieldPermissions({
const result = 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,
expect(
workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration,
).toHaveBeenCalledWith(
expect.objectContaining({
workspaceId: testWorkspaceId,
isSystemBuild: false,
allFlatEntityOperationByMetadataName: expect.objectContaining({
fieldPermission: expect.objectContaining({
flatEntityToCreate: expect.any(Array),
flatEntityToUpdate: expect.any(Array),
flatEntityToDelete: expect.any(Array),
}),
}),
]),
{
conflictPaths: ['fieldMetadataId', 'roleId'],
},
}),
);
expect(result).toEqual(expect.any(Array));
expect(
workspaceCacheService.invalidateAndRecompute,
).toHaveBeenCalledWith(testWorkspaceId, ['rolesPermissions']);
});
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 existingUniversalId = 'existing-fp-universal-id';
const mapsWithOneCurrentPermission = {
...emptyFlatFieldPermissionMaps,
byUniversalIdentifier: {
[existingUniversalId]: {
id: 'existing-fp-id',
universalIdentifier: existingUniversalId,
roleUniversalIdentifier: testRoleId,
objectMetadataId: testObjectMetadataId,
fieldMetadataId: testFieldMetadataId,
canReadFieldValue: undefined,
canUpdateFieldValue: false,
applicationUniversalIdentifier: 'app-ui',
objectMetadataUniversalIdentifier: testObjectMetadataId,
fieldMetadataUniversalIdentifier: testFieldMetadataId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
},
universalIdentifierById: { 'existing-fp-id': existingUniversalId },
};
let flatFieldPermissionMapsCallCount = 0;
(workspaceCacheService.getOrRecompute as jest.Mock).mockImplementation(
(_w: string, keys: string[]) => {
if (keys?.includes('flatFieldPermissionMaps')) {
flatFieldPermissionMapsCallCount += 1;
return Promise.resolve({
flatFieldPermissionMaps:
flatFieldPermissionMapsCallCount === 1
? mapsWithOneCurrentPermission
: emptyFlatFieldPermissionMaps,
});
}
if (keys?.includes('rolesPermissions')) {
return Promise.resolve({
rolesPermissions: mockRolesPermissions,
});
}
return Promise.resolve({});
},
);
workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration.mockResolvedValue(
{ status: 'success' } as any,
);
const input = createUpsertInput([
{
canReadFieldValue: null,
canUpdateFieldValue: null,
},
]);
@@ -279,27 +401,68 @@ describe('FieldPermissionService', () => {
input,
});
expect(fieldPermissionsRepository.delete).toHaveBeenCalledWith({
id: In(['existing-field-permission-id']),
});
expect(
workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration,
).toHaveBeenCalledWith(
expect.objectContaining({
allFlatEntityOperationByMetadataName: expect.objectContaining({
fieldPermission: expect.objectContaining({
flatEntityToDelete: expect.arrayContaining([
expect.objectContaining({
universalIdentifier: existingUniversalId,
}),
]),
}),
}),
}),
);
});
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 existingUniversalId = 'existing-fp-ui-2';
const mapsWithOneCurrent = {
...emptyFlatFieldPermissionMaps,
byUniversalIdentifier: {
[existingUniversalId]: {
id: 'existing-fp-id-2',
universalIdentifier: existingUniversalId,
roleUniversalIdentifier: testRoleId,
objectMetadataId: testObjectMetadataId,
fieldMetadataId: testFieldMetadataId,
canReadFieldValue: false,
canUpdateFieldValue: undefined,
applicationUniversalIdentifier: 'app-ui',
objectMetadataUniversalIdentifier: testObjectMetadataId,
fieldMetadataUniversalIdentifier: testFieldMetadataId,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
},
},
universalIdentifierById: { 'existing-fp-id-2': existingUniversalId },
};
let flatMapsCallCount = 0;
(workspaceCacheService.getOrRecompute as jest.Mock).mockImplementation(
(_w: string, keys: string[]) => {
if (keys?.includes('flatFieldPermissionMaps')) {
flatMapsCallCount += 1;
return Promise.resolve({
flatFieldPermissionMaps:
flatMapsCallCount === 1
? mapsWithOneCurrent
: emptyFlatFieldPermissionMaps,
});
}
if (keys?.includes('rolesPermissions')) {
return Promise.resolve({
rolesPermissions: mockRolesPermissions,
});
}
return Promise.resolve({});
},
);
workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration.mockResolvedValue(
{ status: 'success' } as any,
);
const input = createUpsertInput([
{
@@ -313,12 +476,26 @@ describe('FieldPermissionService', () => {
input,
});
expect(fieldPermissionsRepository.delete).not.toHaveBeenCalled();
const callArg = workspaceMigrationValidateBuildAndRunService
.validateBuildAndRunWorkspaceMigration.mock
.calls[0][0] as unknown as {
allFlatEntityOperationByMetadataName: {
fieldPermission: { flatEntityToDelete: unknown[] };
};
};
expect(
callArg.allFlatEntityOperationByMetadataName.fieldPermission
.flatEntityToDelete,
).toHaveLength(0);
});
});
describe('relation cases', () => {
it('should create two field permissions when a relation field permission is created', async () => {
workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration.mockResolvedValue(
{ status: 'success' } as any,
);
const input = createUpsertInput([
{
canReadFieldValue: false,
@@ -333,28 +510,17 @@ describe('FieldPermissionService', () => {
input,
});
expect(fieldPermissionsRepository.upsert).toHaveBeenCalledWith(
[
{
fieldMetadataId: fieldRelationMock.id,
objectMetadataId: fieldRelationMock.objectMetadataId,
canReadFieldValue: false,
canUpdateFieldValue: false,
roleId: testRoleId,
workspaceId: testWorkspaceId,
},
{
fieldMetadataId: fieldRelationMock.relationTargetFieldMetadataId,
objectMetadataId:
fieldRelationMock.relationTargetObjectMetadataId,
canReadFieldValue: false,
canUpdateFieldValue: false,
roleId: testRoleId,
workspaceId: testWorkspaceId,
},
],
{ conflictPaths: ['fieldMetadataId', 'roleId'] },
);
const callArg = workspaceMigrationValidateBuildAndRunService
.validateBuildAndRunWorkspaceMigration.mock
.calls[0][0] as unknown as {
allFlatEntityOperationByMetadataName: {
fieldPermission: { flatEntityToCreate: unknown[] };
};
};
expect(
callArg.allFlatEntityOperationByMetadataName.fieldPermission
.flatEntityToCreate,
).toHaveLength(2);
});
});
@@ -404,6 +570,7 @@ describe('FieldPermissionService', () => {
it('should throw error when object metadata is not found', async () => {
workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps.mockResolvedValue(
{
flatRoleMaps,
flatObjectMetadataMaps: {
byUniversalIdentifier: {},
universalIdentifierById: {},
@@ -446,6 +613,7 @@ describe('FieldPermissionService', () => {
workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps.mockResolvedValue(
{
flatRoleMaps,
flatObjectMetadataMaps: {
byUniversalIdentifier: {
[testObjectMetadataId]: {
@@ -494,6 +662,7 @@ describe('FieldPermissionService', () => {
it('should throw error when field metadata is not found', async () => {
workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps.mockResolvedValue(
{
flatRoleMaps,
flatObjectMetadataMaps: {
byUniversalIdentifier: {
[testObjectMetadataId]: {
@@ -541,9 +710,19 @@ describe('FieldPermissionService', () => {
});
it('should throw error when object permission is not found', async () => {
(workspaceCacheService.getOrRecompute as jest.Mock).mockResolvedValue({
rolesPermissions: {},
} as any);
(workspaceCacheService.getOrRecompute as jest.Mock).mockImplementation(
(_w: string, keys: string[]) => {
if (keys?.includes('flatFieldPermissionMaps')) {
return Promise.resolve({
flatFieldPermissionMaps: emptyFlatFieldPermissionMaps,
});
}
if (keys?.includes('rolesPermissions')) {
return Promise.resolve({ rolesPermissions: {} });
}
return Promise.resolve({});
},
);
const input = createUpsertInput([
{
@@ -568,12 +747,20 @@ describe('FieldPermissionService', () => {
describe('role validation errors', () => {
it('should throw error when role is not editable', async () => {
const nonEditableRole: RoleEntity = {
...mockRole,
isEditable: false,
};
roleRepository.findOne.mockResolvedValue(nonEditableRole);
workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration.mockResolvedValue(
{
status: 'fail',
report: {
fieldPermission: [
{
code: PermissionsExceptionCode.ROLE_NOT_EDITABLE,
message: 'Role is not editable',
userFriendlyMessage: 'This role cannot be modified.',
},
],
},
} as any,
);
const input = createUpsertInput([
{
@@ -587,12 +774,7 @@ describe('FieldPermissionService', () => {
workspaceId: testWorkspaceId,
input,
}),
).rejects.toThrow(
new PermissionsException(
PermissionsExceptionMessage.ROLE_NOT_EDITABLE,
PermissionsExceptionCode.ROLE_NOT_EDITABLE,
),
);
).rejects.toThrow(WorkspaceMigrationBuilderException);
});
});
});
@@ -15,7 +15,7 @@ import {
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';
import { WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/workspace-related-entity';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
@Entity('fieldPermission')
@Unique('IDX_FIELD_PERMISSION_FIELD_METADATA_ID_ROLE_ID_UNIQUE', [
@@ -23,7 +23,7 @@ import { WorkspaceRelatedEntity } from 'src/engine/workspace-manager/types/works
'roleId',
])
@Index('IDX_FIELD_PERMISSION_WORKSPACE_ID_ROLE_ID', ['workspaceId', 'roleId'])
export class FieldPermissionEntity extends WorkspaceRelatedEntity {
export class FieldPermissionEntity extends SyncableEntity {
@PrimaryGeneratedColumn('uuid')
id: string;
@@ -1,45 +1,49 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { msg } from '@lingui/core/macro';
import { type ObjectsPermissionsByRoleId } from 'twenty-shared/types';
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { In, Repository } from 'typeorm';
import { ApplicationService } from 'src/engine/core-modules/application/application.service';
import { UserInputError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import {
InternalServerError,
UserInputError,
} from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { isFieldMetadataTypeRelation } from 'src/engine/metadata-modules/field-metadata/utils/is-field-metadata-type-relation.util';
import { WorkspaceManyOrAllFlatEntityMapsCacheService } from 'src/engine/metadata-modules/flat-entity/services/workspace-many-or-all-flat-entity-maps-cache.service';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type';
import { findFlatEntityByIdInFlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/utils/find-flat-entity-by-id-in-flat-entity-maps.util';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { type FlatFieldPermissionMaps } from 'src/engine/metadata-modules/flat-field-permission/types/flat-field-permission-maps.type';
import { type FlatFieldPermission } from 'src/engine/metadata-modules/flat-field-permission/types/flat-field-permission.type';
import { fromCreateFieldPermissionInputToUniversalFlatFieldPermission } from 'src/engine/metadata-modules/flat-field-permission/utils/from-create-field-permission-input-to-universal-flat-field-permission.util';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { type FlatRole } from 'src/engine/metadata-modules/flat-role/types/flat-role.type';
import { type 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 { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
import { type UniversalFlatFieldPermission } from 'src/engine/workspace-manager/workspace-migration/universal-flat-entity/types/universal-flat-field-permission.type';
type DesiredFieldPermission = {
objectMetadataId: string;
fieldMetadataId: string;
canReadFieldValue?: boolean | null;
canUpdateFieldValue?: boolean | null;
};
const keyFrom = (objectMetadataId: string, fieldMetadataId: string) =>
`${objectMetadataId}:${fieldMetadataId}`;
@Injectable()
export class FieldPermissionService {
constructor(
@InjectRepository(RoleEntity)
private readonly roleRepository: Repository<RoleEntity>,
@InjectRepository(FieldMetadataEntity)
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
@InjectRepository(FieldPermissionEntity)
private readonly fieldPermissionsRepository: Repository<FieldPermissionEntity>,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly workspaceManyOrAllFlatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly workspaceCacheService: WorkspaceCacheService,
private readonly applicationService: ApplicationService,
) {}
public async upsertFieldPermissions({
@@ -48,135 +52,249 @@ export class FieldPermissionService {
}: {
workspaceId: string;
input: UpsertFieldPermissionsInput;
}): Promise<FieldPermissionEntity[]> {
const role = await this.getRoleOrThrow({
roleId: input.roleId,
workspaceId,
});
}): Promise<FlatFieldPermission[]> {
const [flatMapsForRoleObjectField, flatFieldPermissionMapsResult] =
await Promise.all([
this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: [
'flatRoleMaps',
'flatObjectMetadataMaps',
'flatFieldMetadataMaps',
],
},
),
this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatFieldPermissionMaps',
] as unknown as Parameters<WorkspaceCacheService['getOrRecompute']>[1]),
]);
const flatRoleMaps = flatMapsForRoleObjectField.flatRoleMaps;
const flatObjectMetadataMaps =
flatMapsForRoleObjectField.flatObjectMetadataMaps;
const flatFieldMetadataMaps =
flatMapsForRoleObjectField.flatFieldMetadataMaps;
const flatFieldPermissionMapsResolved: FlatFieldPermissionMaps = (
flatFieldPermissionMapsResult as unknown as {
flatFieldPermissionMaps: FlatFieldPermissionMaps;
}
).flatFieldPermissionMaps;
const { rolesPermissions } =
await this.workspaceCacheService.getOrRecompute(workspaceId, [
'rolesPermissions',
]);
await this.validateRoleIsEditableOrThrow({
role,
});
const roleUniversalIdentifier =
flatRoleMaps.universalIdentifierById[input.roleId];
const flatRole: FlatRole | undefined = isDefined(roleUniversalIdentifier)
? (flatRoleMaps.byUniversalIdentifier[roleUniversalIdentifier] as
| FlatRole
| undefined)
: undefined;
const { flatObjectMetadataMaps, flatFieldMetadataMaps } =
await this.workspaceManyOrAllFlatEntityMapsCacheService.getOrRecomputeManyOrAllFlatEntityMaps(
{
workspaceId,
flatMapsKeys: ['flatObjectMetadataMaps', 'flatFieldMetadataMaps'],
},
);
const existingFieldPermissions = await this.fieldPermissionsRepository.find(
{
where: {
roleId: input.roleId,
workspaceId,
},
},
const currentFieldPermissionsForRole = Object.values(
flatFieldPermissionMapsResolved.byUniversalIdentifier,
).filter(
(fp): fp is FlatFieldPermission =>
isDefined(fp) && fp.roleUniversalIdentifier === roleUniversalIdentifier,
);
const fieldPermissionsToDeleteIds: string[] = [];
const desiredMap = new Map<string, DesiredFieldPermission>();
input.fieldPermissions.forEach((fieldPermission) => {
for (const fieldPermission of input.fieldPermissions) {
this.validateFieldPermission({
allFieldPermissions: input.fieldPermissions,
fieldPermission,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
rolesPermissions,
role,
flatRole,
});
if (
fieldPermission.canReadFieldValue === null ||
fieldPermission.canUpdateFieldValue === null
) {
this.checkIfFieldPermissionShouldBeDeleted({
fieldPermission,
existingFieldPermissions,
fieldPermissionsToDeleteIds,
});
const bothNull =
(fieldPermission.canReadFieldValue === null ||
fieldPermission.canReadFieldValue === undefined) &&
(fieldPermission.canUpdateFieldValue === null ||
fieldPermission.canUpdateFieldValue === undefined);
if (bothNull) {
continue;
}
desiredMap.set(
keyFrom(
fieldPermission.objectMetadataId,
fieldPermission.fieldMetadataId,
),
{
objectMetadataId: fieldPermission.objectMetadataId,
fieldMetadataId: fieldPermission.fieldMetadataId,
canReadFieldValue: fieldPermission.canReadFieldValue ?? undefined,
canUpdateFieldValue: fieldPermission.canUpdateFieldValue ?? undefined,
},
);
}
this.addRelatedFieldPermissionsToDesired({
desiredMap,
inputFieldPermissions: input.fieldPermissions,
flatFieldMetadataMaps,
});
const fieldPermissions = input.fieldPermissions.map((fieldPermission) => ({
...fieldPermission,
roleId: input.roleId,
workspaceId,
}));
const { workspaceCustomFlatApplication } =
await this.applicationService.findWorkspaceTwentyStandardAndCustomApplicationOrThrow(
{ workspaceId },
);
const existingFieldPermissionsToDelete = existingFieldPermissions.filter(
(existingFieldPermissionToFilter) =>
fieldPermissionsToDeleteIds.includes(
existingFieldPermissionToFilter.id,
),
const flatEntityToCreate: (UniversalFlatFieldPermission & {
id: string;
})[] = [];
const flatEntityToUpdate: UniversalFlatFieldPermission[] = [];
const flatEntityToDelete: UniversalFlatFieldPermission[] = [];
const currentByKey = new Map(
currentFieldPermissionsForRole.map((fp) => [
keyFrom(fp.objectMetadataId, fp.fieldMetadataId),
fp,
]),
);
const fieldPermissionsToUpsert = fieldPermissions.filter(
(fieldPermissionToUpsert) =>
!existingFieldPermissionsToDelete.some(
(existingFieldPermissionToDelete) =>
existingFieldPermissionToDelete.fieldMetadataId ===
fieldPermissionToUpsert.fieldMetadataId,
),
);
for (const [, desired] of desiredMap) {
const current = currentByKey.get(
keyFrom(desired.objectMetadataId, desired.fieldMetadataId),
);
const fieldMetadatasForFieldPermissions =
await this.fieldMetadataRepository.find({
where: {
id: In(fieldPermissions.map((fp) => fp.fieldMetadataId)),
},
});
if (!isDefined(current)) {
flatEntityToCreate.push(
fromCreateFieldPermissionInputToUniversalFlatFieldPermission({
fieldPermissionInput: {
objectMetadataId: desired.objectMetadataId,
fieldMetadataId: desired.fieldMetadataId,
canReadFieldValue: desired.canReadFieldValue ?? null,
canUpdateFieldValue: desired.canUpdateFieldValue ?? null,
},
roleId: input.roleId,
flatApplication: workspaceCustomFlatApplication,
flatRoleMaps,
flatObjectMetadataMaps,
flatFieldMetadataMaps,
}),
);
} else {
const effectiveCanRead =
desired.canReadFieldValue ?? current.canReadFieldValue;
const effectiveCanUpdate =
desired.canUpdateFieldValue ?? current.canUpdateFieldValue;
const changed =
effectiveCanRead !== current.canReadFieldValue ||
effectiveCanUpdate !== current.canUpdateFieldValue;
const relatedFieldPermissionsToUpsert =
this.computeFieldPermissionForRelationTargetFieldMetadata({
fieldPermissions: fieldPermissionsToUpsert,
fieldMetadatasForFieldPermissions,
});
if (changed) {
const now = new Date().toISOString();
flatEntityToUpdate.push({
universalIdentifier: current.universalIdentifier,
applicationUniversalIdentifier:
current.applicationUniversalIdentifier,
roleUniversalIdentifier: current.roleUniversalIdentifier,
objectMetadataUniversalIdentifier:
current.objectMetadataUniversalIdentifier,
fieldMetadataUniversalIdentifier:
current.fieldMetadataUniversalIdentifier,
canReadFieldValue: effectiveCanRead ?? undefined,
canUpdateFieldValue: effectiveCanUpdate ?? undefined,
createdAt: current.createdAt,
updatedAt: now,
});
}
}
}
await this.fieldPermissionsRepository.upsert(
[...fieldPermissionsToUpsert, ...relatedFieldPermissionsToUpsert],
{
conflictPaths: ['fieldMetadataId', 'roleId'],
},
);
if (fieldPermissionsToDeleteIds.length > 0) {
const relatedFieldPermissionToDeleteIds =
this.getRelatedFieldPermissionsToDeleteIds({
allFieldPermissions: existingFieldPermissions,
fieldPermissionsToDelete: existingFieldPermissionsToDelete,
fieldMetadatas: fieldMetadatasForFieldPermissions,
for (const current of currentFieldPermissionsForRole) {
const key = keyFrom(current.objectMetadataId, current.fieldMetadataId);
if (!desiredMap.has(key)) {
flatEntityToDelete.push({
universalIdentifier: current.universalIdentifier,
applicationUniversalIdentifier:
current.applicationUniversalIdentifier,
roleUniversalIdentifier: current.roleUniversalIdentifier,
objectMetadataUniversalIdentifier:
current.objectMetadataUniversalIdentifier,
fieldMetadataUniversalIdentifier:
current.fieldMetadataUniversalIdentifier,
canReadFieldValue: current.canReadFieldValue ?? undefined,
canUpdateFieldValue: current.canUpdateFieldValue ?? undefined,
createdAt: current.createdAt,
updatedAt: current.updatedAt,
});
}
}
await this.fieldPermissionsRepository.delete({
id: In([
...fieldPermissionsToDeleteIds,
...relatedFieldPermissionToDeleteIds,
]),
});
if (
flatEntityToCreate.length === 0 &&
flatEntityToUpdate.length === 0 &&
flatEntityToDelete.length === 0
) {
const desiredObjectMetadataIds = new Set(
input.fieldPermissions.map((fp) => fp.objectMetadataId),
);
return currentFieldPermissionsForRole.filter((fp) =>
desiredObjectMetadataIds.has(fp.objectMetadataId),
);
}
const buildAndRunResult =
await this.workspaceMigrationValidateBuildAndRunService.validateBuildAndRunWorkspaceMigration(
{
allFlatEntityOperationByMetadataName: {
fieldPermission: {
flatEntityToCreate,
flatEntityToUpdate,
flatEntityToDelete,
},
},
workspaceId,
isSystemBuild: false,
applicationUniversalIdentifier:
workspaceCustomFlatApplication.universalIdentifier,
} as Parameters<
WorkspaceMigrationValidateBuildAndRunService['validateBuildAndRunWorkspaceMigration']
>[0],
);
if (buildAndRunResult.status === 'fail') {
throw new WorkspaceMigrationBuilderException(
buildAndRunResult,
'Validation errors occurred while upserting field permissions',
);
}
await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [
'rolesPermissions',
]);
return this.fieldPermissionsRepository.find({
where: {
roleId: input.roleId,
objectMetadataId: In(
input.fieldPermissions.map(
(fieldPermission) => fieldPermission.objectMetadataId,
),
),
workspaceId,
},
});
const freshFlatFieldPermissionMaps: FlatFieldPermissionMaps = (
(await this.workspaceCacheService.getOrRecompute(workspaceId, [
'flatFieldPermissionMaps',
] as unknown as Parameters<
WorkspaceCacheService['getOrRecompute']
>[1])) as unknown as { flatFieldPermissionMaps: FlatFieldPermissionMaps }
).flatFieldPermissionMaps;
const resultFieldPermissions = Object.values(
freshFlatFieldPermissionMaps.byUniversalIdentifier,
).filter(
(fp): fp is FlatFieldPermission =>
isDefined(fp) && fp.roleUniversalIdentifier === roleUniversalIdentifier,
);
const desiredObjectMetadataIds = new Set(
input.fieldPermissions.map((fp) => fp.objectMetadataId),
);
const filtered = resultFieldPermissions.filter((fp) =>
desiredObjectMetadataIds.has(fp.objectMetadataId),
);
return filtered;
}
private validateFieldPermission({
@@ -185,14 +303,14 @@ export class FieldPermissionService {
flatObjectMetadataMaps,
flatFieldMetadataMaps,
rolesPermissions,
role,
flatRole,
}: {
allFieldPermissions: UpsertFieldPermissionsInput['fieldPermissions'];
fieldPermission: UpsertFieldPermissionsInput['fieldPermissions'][0];
flatObjectMetadataMaps: FlatEntityMaps<FlatObjectMetadata>;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
rolesPermissions: ObjectsPermissionsByRoleId;
role: RoleEntity;
rolesPermissions: Record<string, Record<string, unknown>> | undefined;
flatRole: { id: string } | undefined;
}) {
const duplicateFieldPermissions = allFieldPermissions.filter(
(permission) =>
@@ -204,12 +322,11 @@ export class FieldPermissionService {
`Cannot accept more than one fieldPermission for field ${fieldPermission.fieldMetadataId} in input.`,
);
}
if (
('canUpdateFieldValue' in fieldPermission &&
fieldPermission.canUpdateFieldValue !== null &&
(fieldPermission.canUpdateFieldValue !== null &&
fieldPermission.canUpdateFieldValue !== false) ||
('canReadFieldValue' in fieldPermission &&
fieldPermission.canReadFieldValue !== null &&
(fieldPermission.canReadFieldValue !== null &&
fieldPermission.canReadFieldValue !== false)
) {
throw new PermissionsException(
@@ -261,10 +378,11 @@ export class FieldPermissionService {
);
}
const rolePermissionOnObject =
rolesPermissions?.[role.id]?.[fieldPermission.objectMetadataId];
const rolePermissionOnObject = isDefined(flatRole)
? rolesPermissions?.[flatRole.id]?.[fieldPermission.objectMetadataId]
: undefined;
if (!isDefined(rolePermissionOnObject)) {
if (isDefined(flatRole) && !isDefined(rolePermissionOnObject)) {
throw new PermissionsException(
PermissionsExceptionMessage.OBJECT_PERMISSION_NOT_FOUND,
PermissionsExceptionCode.OBJECT_PERMISSION_NOT_FOUND,
@@ -275,194 +393,84 @@ export class FieldPermissionService {
}
}
private async getRoleOrThrow({
roleId,
workspaceId,
private addRelatedFieldPermissionsToDesired({
desiredMap,
inputFieldPermissions,
flatFieldMetadataMaps,
}: {
roleId: string;
workspaceId: string;
desiredMap: Map<string, DesiredFieldPermission>;
inputFieldPermissions: UpsertFieldPermissionsInput['fieldPermissions'];
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
}) {
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,
{
userFriendlyMessage: msg`The role you are trying to modify could not be found. It may have been deleted or you may not have access to it.`,
},
);
}
return role;
}
private async validateRoleIsEditableOrThrow({ role }: { role: RoleEntity }) {
if (!role.isEditable) {
throw new PermissionsException(
PermissionsExceptionMessage.ROLE_NOT_EDITABLE,
PermissionsExceptionCode.ROLE_NOT_EDITABLE,
{
userFriendlyMessage: msg`This role cannot be modified because it is a system role. Only custom roles can be edited.`,
},
);
}
}
private checkIfFieldPermissionShouldBeDeleted({
fieldPermission,
existingFieldPermissions,
fieldPermissionsToDeleteIds,
}: {
fieldPermission: UpsertFieldPermissionsInput['fieldPermissions'][0];
existingFieldPermissions: FieldPermissionEntity[];
fieldPermissionsToDeleteIds: string[];
}) {
const existingFieldPermission = existingFieldPermissions.find(
(existingFieldPermission) =>
existingFieldPermission.fieldMetadataId ===
fieldPermission.fieldMetadataId,
const inputKeys = new Set(
inputFieldPermissions.map((fp) =>
keyFrom(fp.objectMetadataId, fp.fieldMetadataId),
),
);
if (existingFieldPermission) {
const finalCanReadFieldValue =
'canReadFieldValue' in fieldPermission
? fieldPermission.canReadFieldValue
: existingFieldPermission.canReadFieldValue;
const finalCanUpdateFieldValue =
'canUpdateFieldValue' in fieldPermission
? fieldPermission.canUpdateFieldValue
: existingFieldPermission.canUpdateFieldValue;
for (const fieldPermission of inputFieldPermissions) {
const flatFieldMetadata = findFlatEntityByIdInFlatEntityMaps({
flatEntityId: fieldPermission.fieldMetadataId,
flatEntityMaps: flatFieldMetadataMaps,
});
if (
finalCanReadFieldValue === null &&
finalCanUpdateFieldValue === null
!isDefined(flatFieldMetadata) ||
flatFieldMetadata.type !== FieldMetadataType.RELATION
) {
fieldPermissionsToDeleteIds.push(existingFieldPermission.id);
continue;
}
}
}
private getRelatedFieldPermissionsToDeleteIds({
allFieldPermissions,
fieldPermissionsToDelete,
fieldMetadatas,
}: {
allFieldPermissions: FieldPermissionEntity[];
fieldPermissionsToDelete: FieldPermissionEntity[];
fieldMetadatas: FieldMetadataEntity[];
}) {
const fieldMetadatasForFieldPermissionsToDelete = fieldMetadatas.filter(
(fieldMetadata) =>
fieldPermissionsToDelete.some(
(existingFieldPermissionToDelete) =>
existingFieldPermissionToDelete.fieldMetadataId ===
fieldMetadata.id,
),
);
const relationTargetFieldMetadataIds: string[] = [];
for (const fieldMetadataForFieldPermissionToDelete of fieldMetadatasForFieldPermissionsToDelete) {
const relationType = (
flatFieldMetadata.settings as { relationType?: string } | undefined
)?.relationType;
if (
isFieldMetadataTypeRelation(fieldMetadataForFieldPermissionToDelete)
relationType !== RelationType.ONE_TO_MANY &&
relationType !== RelationType.MANY_TO_ONE
) {
if (
fieldMetadataForFieldPermissionToDelete.settings?.relationType ===
RelationType.ONE_TO_MANY ||
fieldMetadataForFieldPermissionToDelete.settings?.relationType ===
RelationType.MANY_TO_ONE
) {
relationTargetFieldMetadataIds.push(
fieldMetadataForFieldPermissionToDelete.relationTargetFieldMetadataId,
);
}
continue;
}
}
const fieldPermissionsForRelationTargetFieldMetadataIds =
allFieldPermissions
.filter((fieldPermission) =>
relationTargetFieldMetadataIds.includes(
fieldPermission.fieldMetadataId,
),
)
.map((fieldPermission) => fieldPermission.id);
const targetObjectId =
flatFieldMetadata.relationTargetObjectMetadataId ?? undefined;
const targetFieldId =
flatFieldMetadata.relationTargetFieldMetadataId ?? undefined;
return fieldPermissionsForRelationTargetFieldMetadataIds;
}
if (!targetObjectId || !targetFieldId) {
continue;
}
private computeFieldPermissionForRelationTargetFieldMetadata({
fieldPermissions,
fieldMetadatasForFieldPermissions,
}: {
fieldPermissions: UpsertFieldPermissionsInput['fieldPermissions'];
fieldMetadatasForFieldPermissions: FieldMetadataEntity[];
}) {
return fieldPermissions
.map((fieldPermission) => {
const fieldMetadata = fieldMetadatasForFieldPermissions.find(
(fm) => fm.id === fieldPermission.fieldMetadataId,
const targetKey = keyFrom(targetObjectId, targetFieldId);
if (inputKeys.has(targetKey)) {
const targetInInput = inputFieldPermissions.find(
(fp) =>
fp.objectMetadataId === targetObjectId &&
fp.fieldMetadataId === targetFieldId,
);
if (!isDefined(fieldMetadata)) {
throw new InternalServerError(
'Field metadata not found for field permission',
);
}
if (isFieldMetadataTypeRelation(fieldMetadata)) {
if (
fieldMetadata.settings?.relationType === RelationType.ONE_TO_MANY ||
fieldMetadata.settings?.relationType === RelationType.MANY_TO_ONE
) {
const fieldPermissionsOnRelationTargetField =
fieldPermissions.filter(
(fieldPermissionInput) =>
fieldPermissionInput.fieldMetadataId ===
fieldMetadata.relationTargetFieldMetadataId,
);
if (fieldPermissionsOnRelationTargetField.length > 0) {
const firstFieldPermission =
fieldPermissionsOnRelationTargetField[0]; // validation rules guarantee there can only be one
const hasConflictingPermissions =
fieldPermission.canReadFieldValue !==
firstFieldPermission.canReadFieldValue ||
fieldPermission.canUpdateFieldValue !==
firstFieldPermission.canUpdateFieldValue;
if (hasConflictingPermissions) {
const fieldName = fieldMetadata.name;
throw new UserInputError(
'Conflicting field permissions found for relation target field',
{
userFriendlyMessage: msg`Contradicting field permissions have been detected on a relation field (${fieldName}).`,
},
);
}
return;
}
return {
...fieldPermission,
objectMetadataId: fieldMetadata.relationTargetObjectMetadataId,
fieldMetadataId: fieldMetadata.relationTargetFieldMetadataId,
};
if (isDefined(targetInInput)) {
const hasConflict =
fieldPermission.canReadFieldValue !==
targetInInput.canReadFieldValue ||
fieldPermission.canUpdateFieldValue !==
targetInInput.canUpdateFieldValue;
if (hasConflict) {
throw new UserInputError(
'Conflicting field permissions found for relation target field',
{
userFriendlyMessage: msg`Contradicting field permissions have been detected on a relation field.`,
},
);
}
}
continue;
}
return null;
})
.filter(isDefined);
desiredMap.set(targetKey, {
objectMetadataId: targetObjectId,
fieldMetadataId: targetFieldId,
canReadFieldValue: fieldPermission.canReadFieldValue ?? undefined,
canUpdateFieldValue: fieldPermission.canUpdateFieldValue ?? undefined,
});
}
}
}
@@ -0,0 +1,13 @@
import { type FlatFieldPermission } from 'src/engine/metadata-modules/flat-field-permission/types/flat-field-permission.type';
import { type FieldPermissionDTO } from 'src/engine/metadata-modules/object-permission/dtos/field-permission.dto';
export const fromFlatFieldPermissionToFieldPermissionDto = (
flatFieldPermission: FlatFieldPermission,
): FieldPermissionDTO => ({
id: flatFieldPermission.id,
objectMetadataId: flatFieldPermission.objectMetadataId,
fieldMetadataId: flatFieldPermission.fieldMetadataId,
roleId: flatFieldPermission.roleId,
canReadFieldValue: flatFieldPermission.canReadFieldValue ?? null,
canUpdateFieldValue: flatFieldPermission.canUpdateFieldValue ?? null,
});