[permissions] Handle fieldPermissions configuration for 1N relations (#13630)

When upserting a fieldPermission on a 1 <-> N relation field, we should
upsert the same fieldPermission on the relationTargetFieldMetadata to
avoid inconsistencies.
This commit is contained in:
Marie
2025-08-05 15:48:39 +02:00
committed by GitHub
parent 287edd44ad
commit 46e43a62c4
3 changed files with 212 additions and 4 deletions
@@ -141,10 +141,12 @@ export const fieldRelationMock = getMockFieldMetadataEntity({
nameSingular: 'relationTargetObject',
namePlural: 'relationTargetObjects',
} as ObjectMetadataEntity,
relationTargetObjectMetadataId: 'relationTargetObjectId',
relationTargetFieldMetadata: {
id: 'relationTargetFieldId',
name: 'relationTargetField',
} as FieldMetadataEntity,
relationTargetFieldMetadataId: 'relationTargetFieldId',
isLabelSyncedWithName: true,
createdAt: new Date(),
updatedAt: new Date(),
@@ -5,9 +5,11 @@ import { ObjectRecordsPermissionsByRoleId } from 'twenty-shared/types';
import { In, Repository } from 'typeorm';
import {
fieldRelationMock,
fieldTextMock,
objectMetadataItemMock,
} from 'src/engine/api/__mocks__/object-metadata-item.mock';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
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';
@@ -27,6 +29,7 @@ describe('FieldPermissionService', () => {
Repository<FieldPermissionEntity>
>;
let roleRepository: jest.Mocked<Repository<RoleEntity>>;
let fieldMetadataRepository: jest.Mocked<Repository<FieldMetadataEntity>>;
let workspacePermissionsCacheService: jest.Mocked<WorkspacePermissionsCacheService>;
let workspaceCacheStorageService: jest.Mocked<WorkspaceCacheStorageService>;
@@ -59,6 +62,13 @@ describe('FieldPermissionService', () => {
canDestroy: false,
restrictedFields: {},
},
[fieldRelationMock.objectMetadataId]: {
canRead: true,
canUpdate: true,
canSoftDelete: false,
canDestroy: false,
restrictedFields: {},
},
},
};
@@ -93,6 +103,12 @@ describe('FieldPermissionService', () => {
getObjectMetadataMapsOrThrow: jest.fn(),
},
},
{
provide: getRepositoryToken(FieldMetadataEntity, 'core'),
useValue: {
find: jest.fn(),
},
},
],
}).compile();
@@ -101,6 +117,9 @@ describe('FieldPermissionService', () => {
getRepositoryToken(FieldPermissionEntity, 'core'),
);
roleRepository = module.get(getRepositoryToken(RoleEntity, 'core'));
fieldMetadataRepository = module.get(
getRepositoryToken(FieldMetadataEntity, 'core'),
);
workspacePermissionsCacheService = module.get(
WorkspacePermissionsCacheService,
);
@@ -108,6 +127,10 @@ describe('FieldPermissionService', () => {
// Setup default mocks
roleRepository.findOne.mockResolvedValue(mockRole);
fieldMetadataRepository.find.mockResolvedValue([
fieldTextMock,
fieldRelationMock,
]);
workspacePermissionsCacheService.getRolesPermissionsFromCache.mockResolvedValue(
{
version: '1',
@@ -132,6 +155,16 @@ describe('FieldPermissionService', () => {
fieldIdByName: {},
indexMetadatas: [],
},
[fieldRelationMock.objectMetadataId]: {
...objectMetadataItemMock,
id: fieldRelationMock.objectMetadataId,
fieldsById: {
[fieldRelationMock.id]: fieldRelationMock,
},
fieldIdByJoinColumnName: {},
fieldIdByName: {},
indexMetadatas: [],
},
},
idByNameSingular: {
testObject: testObjectMetadataId,
@@ -268,6 +301,47 @@ describe('FieldPermissionService', () => {
});
});
describe('relation cases', () => {
it('should create two field permissions when a relation field permission is created', async () => {
const input = createUpsertInput([
{
canReadFieldValue: false,
canUpdateFieldValue: false,
fieldMetadataId: fieldRelationMock.id,
objectMetadataId: fieldRelationMock.objectMetadataId,
},
]);
await service.upsertFieldPermissions({
workspaceId: testWorkspaceId,
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'] },
);
});
});
describe('validation errors', () => {
it('should throw error when canReadFieldValue is true', async () => {
const input = createUpsertInput([
@@ -5,6 +5,11 @@ import { ObjectRecordsPermissionsByRoleId } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { In, Repository } from 'typeorm';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { InternalServerError } 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 { 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 {
@@ -22,6 +27,8 @@ export class FieldPermissionService {
constructor(
@InjectRepository(RoleEntity, 'core')
private readonly roleRepository: Repository<RoleEntity>,
@InjectRepository(FieldMetadataEntity, 'core')
private readonly fieldMetadataRepository: Repository<FieldMetadataEntity>,
@InjectRepository(FieldPermissionEntity, 'core')
private readonly fieldPermissionsRepository: Repository<FieldPermissionEntity>,
private readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService,
@@ -107,13 +114,39 @@ export class FieldPermissionService {
),
);
await this.fieldPermissionsRepository.upsert(fieldPermissionsToUpsert, {
conflictPaths: ['fieldMetadataId', 'roleId'],
});
const fieldMetadatasForFieldPermissions =
await this.fieldMetadataRepository.find({
where: {
id: In(fieldPermissions.map((fp) => fp.fieldMetadataId)),
},
});
const relatedFieldPermissionsToUpsert =
this.computeFieldPermissionForRelationTargetFieldMetadata({
fieldPermissions: fieldPermissionsToUpsert,
fieldMetadatasForFieldPermissions,
});
await this.fieldPermissionsRepository.upsert(
[...fieldPermissionsToUpsert, ...relatedFieldPermissionsToUpsert],
{
conflictPaths: ['fieldMetadataId', 'roleId'],
},
);
if (fieldPermissionsToDeleteIds.length > 0) {
const relatedFieldPermissionToDeleteIds =
this.getRelatedFieldPermissionsToDeleteIds({
allFieldPermissions: existingFieldPermissions,
fieldPermissionsToDelete: existingFieldPermissionsToDelete,
fieldMetadatas: fieldMetadatasForFieldPermissions,
});
await this.fieldPermissionsRepository.delete({
id: In(fieldPermissionsToDeleteIds),
id: In([
...fieldPermissionsToDeleteIds,
...relatedFieldPermissionToDeleteIds,
]),
});
}
@@ -267,4 +300,103 @@ export class FieldPermissionService {
}
}
}
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) {
if (
isFieldMetadataTypeRelation(fieldMetadataForFieldPermissionToDelete)
) {
if (
fieldMetadataForFieldPermissionToDelete.settings?.relationType ===
RelationType.ONE_TO_MANY ||
fieldMetadataForFieldPermissionToDelete.settings?.relationType ===
RelationType.MANY_TO_ONE
) {
relationTargetFieldMetadataIds.push(
fieldMetadataForFieldPermissionToDelete.relationTargetFieldMetadataId,
);
}
}
}
const fieldPermissionsForRelationTargetFieldMetadataIds =
allFieldPermissions
.filter((fieldPermission) =>
relationTargetFieldMetadataIds.includes(
fieldPermission.fieldMetadataId,
),
)
.map((fieldPermission) => fieldPermission.id);
return fieldPermissionsForRelationTargetFieldMetadataIds;
}
private computeFieldPermissionForRelationTargetFieldMetadata({
fieldPermissions,
fieldMetadatasForFieldPermissions,
}: {
fieldPermissions: UpsertFieldPermissionsInput['fieldPermissions'];
fieldMetadatasForFieldPermissions: FieldMetadataEntity[];
}) {
return fieldPermissions
.map((fieldPermission) => {
const fieldMetadata = fieldMetadatasForFieldPermissions.find(
(fm) => fm.id === fieldPermission.fieldMetadataId,
);
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 fieldPermissionInputHasFieldPermissionOnRelationTargetFieldMetadata =
fieldPermissions.filter(
(fieldPermissionInput) =>
fieldPermissionInput.fieldMetadataId ===
fieldMetadata.relationTargetFieldMetadataId,
).length > 0;
if (
fieldPermissionInputHasFieldPermissionOnRelationTargetFieldMetadata
) {
return;
}
return {
...fieldPermission,
objectMetadataId: fieldMetadata.relationTargetObjectMetadataId,
fieldMetadataId: fieldMetadata.relationTargetFieldMetadataId,
};
}
}
return null;
})
.filter(isDefined);
}
}