[permissions] Update permission check layer (#13485)

Fixes https://github.com/twentyhq/core-team-issues/issues/1262

In this PR we add the update permission check layer by 
- for the graphql api: extracting columns to update from the
expressionMap
- for rest api: .save() is used so we need to add the permission layer
to .save directly. We also take advantage of this PR to filter out
non-readable fields from save response (other save returns the whole
entity) - this was planned in
https://github.com/twentyhq/core-team-issues/issues/1216

The current solution does not work with rest api depth 2 queries, but
this seem to already not work on main (for timeout reasons though, so
different). I offer to create a ticket to fix it altogether later.
This commit is contained in:
Marie
2025-07-31 18:37:01 +02:00
committed by GitHub
parent 3e9b642f7f
commit b41502a4b8
20 changed files with 1483 additions and 124 deletions
@@ -5,7 +5,7 @@ import { GraphQLInputObjectType } from 'graphql';
import { WorkspaceBuildSchemaOptions } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-build-schema-options.interface';
import { TypeMapperService } from 'src/engine/api/graphql/workspace-schema-builder/services/type-mapper.service';
import { generateFields } from 'src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.utils';
import { generateFields } from 'src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.util';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { pascalCase } from 'src/utils/pascal-case';
@@ -4,7 +4,7 @@ import { GraphQLObjectType } from 'graphql';
import { WorkspaceBuildSchemaOptions } from 'src/engine/api/graphql/workspace-schema-builder/interfaces/workspace-build-schema-options.interface';
import { generateFields } from 'src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.utils';
import { generateFields } from 'src/engine/api/graphql/workspace-schema-builder/utils/generate-fields.util';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { pascalCase } from 'src/utils/pascal-case';
@@ -13,10 +13,10 @@ import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfa
import { InputTypeDefinitionKind } from 'src/engine/api/graphql/workspace-schema-builder/factories/input-type-definition.factory';
import { ObjectTypeDefinitionKind } from 'src/engine/api/graphql/workspace-schema-builder/factories/object-type-definition.factory';
import { formatRelationConnectInputTarget } from 'src/engine/api/graphql/workspace-schema-builder/factories/relation-connect-input-type-definition.factory';
import { isFieldMetadataRelationOrMorphRelation } from 'src/engine/api/graphql/workspace-schema-builder/utils/is-field-metadata-relation-or-morph-relation.utils';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
type TypeFactory<T extends InputTypeDefinitionKind | ObjectTypeDefinitionKind> =
{
@@ -55,23 +55,19 @@ export const generateFields = <
}): T extends InputTypeDefinitionKind
? GraphQLInputFieldConfigMap
: // eslint-disable-next-line @typescript-eslint/no-explicit-any
// eslint-disable-next-line @typescript-eslint/no-explicit-any
GraphQLFieldConfigMap<any, any> => {
const allGeneratedFields = {};
for (const fieldMetadata of objectMetadata.fields) {
let generatedField;
const isRelation =
isFieldMetadataEntityOfType(fieldMetadata, FieldMetadataType.RELATION) ||
isFieldMetadataEntityOfType(
fieldMetadata,
FieldMetadataType.MORPH_RELATION,
);
const isRelation = isFieldMetadataRelationOrMorphRelation(fieldMetadata);
if (isRelation) {
generatedField = generateRelationField({
fieldMetadata,
fieldMetadata: fieldMetadata as FieldMetadataEntity<
FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION
>,
kind,
options,
typeFactory,
@@ -0,0 +1,13 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
export const isFieldMetadataRelationOrMorphRelation = (
fieldMetadata: FieldMetadataEntity<FieldMetadataType>,
) => {
return (
isFieldMetadataEntityOfType(fieldMetadata, FieldMetadataType.RELATION) ||
isFieldMetadataEntityOfType(fieldMetadata, FieldMetadataType.MORPH_RELATION)
);
};
@@ -18,7 +18,7 @@ export class RestApiDeleteOneHandler extends RestApiBaseHandler {
const { objectMetadata, repository, restrictedFields } =
await this.getRepositoryAndMetadataOrFail(request);
const selectOptions = this.getSelectOptionsFromRestrictedFields({
const selectOptions = this.getAllSelectableFields({
restrictedFields,
objectMetadata,
});
@@ -23,7 +23,9 @@ export class RestApiUpdateOneHandler extends RestApiBaseHandler {
const { objectMetadata, repository, restrictedFields } =
await this.getRepositoryAndMetadataOrFail(request);
const recordToUpdate = await repository.findOneOrFail({
// assert the record exists
await repository.findOneOrFail({
select: { id: true },
where: { id: recordId },
});
@@ -33,7 +35,7 @@ export class RestApiUpdateOneHandler extends RestApiBaseHandler {
});
const updatedRecord = await repository.save({
...recordToUpdate,
id: recordId,
...overriddenBody,
});
@@ -266,7 +266,7 @@ export abstract class RestApiBaseHandler {
let selectOptions = undefined;
if (!isEmpty(restrictedFields)) {
selectOptions = this.getSelectOptionsFromRestrictedFields({
selectOptions = this.getAllSelectableFields({
restrictedFields,
objectMetadata,
});
@@ -311,7 +311,7 @@ export abstract class RestApiBaseHandler {
};
}
public getSelectOptionsFromRestrictedFields({
public getAllSelectableFields({
restrictedFields,
objectMetadata,
}: {
@@ -406,7 +406,9 @@ export abstract class RestApiBaseHandler {
const objectMetadataNameSingular =
objectMetadata.objectMetadataMapItem.nameSingular;
const qb = repository.createQueryBuilder(objectMetadataNameSingular);
const qb = repository
.createQueryBuilder(objectMetadataNameSingular)
.select('id');
const inputs = this.getVariablesFactory.create(
recordId,
@@ -48,6 +48,8 @@ export enum PermissionsExceptionCode {
PERMISSION_NOT_FOUND = 'PERMISSION_NOT_FOUND',
OBJECT_PERMISSION_NOT_FOUND = 'OBJECT_PERMISSION_NOT_FOUND',
EMPTY_FIELD_PERMISSION_NOT_ALLOWED = 'EMPTY_FIELD_PERMISSION_NOT_ALLOWED',
JOIN_COLUMN_NAME_REQUIRED = 'JOIN_COLUMN_NAME_REQUIRED',
COMPOSITE_TYPE_NOT_FOUND = 'COMPOSITE_TYPE_NOT_FOUND',
}
export enum PermissionsExceptionMessage {
@@ -56,6 +56,8 @@ export const permissionGraphqlApiExceptionHandler = (
case PermissionsExceptionCode.METHOD_NOT_ALLOWED:
case PermissionsExceptionCode.RAW_SQL_NOT_ALLOWED:
case PermissionsExceptionCode.OBJECT_PERMISSION_NOT_FOUND:
case PermissionsExceptionCode.JOIN_COLUMN_NAME_REQUIRED:
case PermissionsExceptionCode.COMPOSITE_TYPE_NOT_FOUND:
throw error;
default: {
const _exhaustiveCheck: never = error.code;
@@ -15,6 +15,30 @@ jest.mock('src/engine/twenty-orm/repository/permissions.utils', () => ({
validateOperationIsPermittedOrThrow: jest.fn(),
}));
jest.mock(
'src/engine/twenty-orm/utils/get-object-metadata-from-entity-target.util',
() => ({
getObjectMetadataFromEntityTarget: jest.fn().mockReturnValue({}),
}),
);
jest.mock('src/engine/twenty-orm/utils/format-data.util', () => ({
formatData: jest.fn().mockReturnValue([]),
}));
jest.mock('src/engine/twenty-orm/utils/format-result.util', () => ({
formatResult: jest.fn().mockReturnValue([]),
}));
jest.mock(
'src/engine/twenty-orm/entity-manager/workspace-entity-manager',
() => ({
...jest.requireActual(
'src/engine/twenty-orm/entity-manager/workspace-entity-manager',
),
}),
);
const mockedWorkspaceUpdateQueryBuilder = {
set: jest.fn().mockImplementation(() => ({
where: jest.fn().mockReturnThis(),
@@ -122,7 +146,22 @@ describe('WorkspaceEntityManager', () => {
} as WorkspaceInternalContext;
mockDataSource = {
featureFlagMap: {},
featureFlagMap: {
IS_AIRTABLE_INTEGRATION_ENABLED: false,
IS_POSTGRESQL_INTEGRATION_ENABLED: false,
IS_STRIPE_INTEGRATION_ENABLED: false,
IS_UNIQUE_INDEXES_ENABLED: false,
IS_JSON_FILTER_ENABLED: false,
IS_AI_ENABLED: false,
IS_IMAP_SMTP_CALDAV_ENABLED: false,
IS_MORPH_RELATION_ENABLED: false,
IS_WORKFLOW_FILTERING_ENABLED: false,
IS_RELATION_CONNECT_ENABLED: false,
IS_WORKSPACE_API_KEY_WEBHOOK_GRAPHQL_ENABLED: false,
IS_FIELDS_PERMISSIONS_ENABLED: true,
IS_CORE_VIEW_SYNCING_ENABLED: false,
IS_TWO_FACTOR_AUTHENTICATION_ENABLED: false,
},
permissionsPerRoleId: {},
} as WorkspaceDataSource;
@@ -191,6 +230,15 @@ describe('WorkspaceEntityManager', () => {
jest.spyOn(entityManager as any, 'validatePermissions');
jest.spyOn(entityManager as any, 'createQueryBuilder');
jest
.spyOn(entityManager as any, 'getFormattedResultWithoutNonReadableFields')
.mockImplementation(
({ formattedResult }: { formattedResult: string[] }) => formattedResult,
);
jest.spyOn(entityManager as any, 'getFeatureFlagMap').mockReturnValue({
IS_FIELDS_PERMISSIONS_ENABLED: true,
});
jest
.spyOn(entityManager as any, 'extractTargetNameSingularFromEntityTarget')
@@ -276,15 +324,18 @@ describe('WorkspaceEntityManager', () => {
operationType: 'update',
permissionOptions: mockPermissionOptions,
selectedColumns: [],
updatedColumns: [],
});
expect(validateOperationIsPermittedOrThrow).toHaveBeenCalledWith({
entityName: 'test-entity',
isFieldPermissionsEnabled: true,
operationType: 'update',
objectMetadataMaps: mockInternalContext.objectMetadataMaps,
objectRecordsPermissions:
mockPermissionOptions.objectRecordsPermissions,
selectedColumns: [],
allFieldsSelected: false,
updatedColumns: [],
});
});
});
@@ -313,11 +364,13 @@ describe('WorkspaceEntityManager', () => {
expect(validateOperationIsPermittedOrThrow).toHaveBeenCalledWith({
entityName: 'test-entity',
operationType: 'delete',
isFieldPermissionsEnabled: true,
objectMetadataMaps: mockInternalContext.objectMetadataMaps,
objectRecordsPermissions:
mockPermissionOptions.objectRecordsPermissions,
selectedColumns: [],
allFieldsSelected: false,
updatedColumns: [],
});
});
});
@@ -1,4 +1,4 @@
import { Entity } from '@microsoft/microsoft-graph-types';
import isEmpty from 'lodash.isempty';
import { ObjectRecordsPermissions } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
@@ -35,10 +35,12 @@ import { WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/works
import { DatabaseEventAction } from 'src/engine/api/graphql/graphql-query-runner/enums/database-event-action';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { InternalServerError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import {
PermissionsException,
PermissionsExceptionCode,
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { WorkspaceDataSource } from 'src/engine/twenty-orm/datasource/workspace.datasource';
import { DeepPartialWithNestedRelationFields } from 'src/engine/twenty-orm/entity-manager/types/deep-partial-entity-with-nested-relation-fields.type';
import { QueryDeepPartialEntityWithNestedRelationFields } from 'src/engine/twenty-orm/entity-manager/types/query-deep-partial-entity-with-nested-relation-fields.type';
@@ -352,6 +354,7 @@ export class WorkspaceEntityManager extends EntityManager {
operationType,
permissionOptions,
selectedColumns,
updatedColumns = [],
}: {
target: EntityTarget<Entity> | Entity;
operationType: OperationType;
@@ -360,6 +363,7 @@ export class WorkspaceEntityManager extends EntityManager {
objectRecordsPermissions?: ObjectRecordsPermissions;
};
selectedColumns: string[];
updatedColumns?: string[];
}): void {
if (permissionOptions?.shouldBypassPermissionChecks === true) {
return;
@@ -378,6 +382,9 @@ export class WorkspaceEntityManager extends EntityManager {
objectMetadataMaps: this.internalContext.objectMetadataMaps,
selectedColumns,
allFieldsSelected: false,
updatedColumns,
isFieldPermissionsEnabled:
this.getFeatureFlagMap().IS_FIELDS_PERMISSIONS_ENABLED,
});
}
@@ -702,7 +709,7 @@ export class WorkspaceEntityManager extends EntityManager {
permissionOptions,
)
.setFindOptions(options || {})
.select('1')
.select('id')
.limit(1)
.getRawOne()
.then((result) => isDefined(result));
@@ -722,7 +729,7 @@ export class WorkspaceEntityManager extends EntityManager {
permissionOptions,
)
.setFindOptions({ where })
.select('1')
.select('id')
.limit(1)
.getRawOne()
.then((result) => isDefined(result));
@@ -762,7 +769,7 @@ export class WorkspaceEntityManager extends EntityManager {
.getCount();
}
async callAggregateFunCustom(
async callAggregateFunCustom<Entity extends ObjectLiteral>(
entityClass: EntityTarget<Entity>,
fnName: string,
columnName: string,
@@ -1033,13 +1040,6 @@ export class WorkspaceEntityManager extends EntityManager {
? maybeOptionsOrMaybePermissionOptions
: permissionOptions;
this.validatePermissions({
target: targetOrEntity,
operationType: 'update',
permissionOptions: permissionOptionsFromArgs,
selectedColumns: [], // TODO
});
let target =
arguments.length > 1 &&
(typeof targetOrEntity === 'function' ||
@@ -1047,7 +1047,9 @@ export class WorkspaceEntityManager extends EntityManager {
typeof targetOrEntity === 'string')
? targetOrEntity
: undefined;
const entity = target ? entityOrMaybeOptions : targetOrEntity;
const options = target
? maybeOptionsOrMaybePermissionOptions
: entityOrMaybeOptions;
@@ -1094,7 +1096,7 @@ export class WorkspaceEntityManager extends EntityManager {
{
where: { id: In(entityIds) },
},
permissionOptions,
{ shouldBypassPermissionChecks: true }, // Bypass as this is for event emission
);
const beforeUpdateMapById = beforeUpdate.reduce(
@@ -1116,6 +1118,18 @@ export class WorkspaceEntityManager extends EntityManager {
objectMetadataItem,
);
const updatedColumns = formattedEntityOrEntities
.map((e) => Object.keys(e))
.flat();
this.validatePermissions({
target: targetOrEntity,
operationType: 'update',
permissionOptions: permissionOptionsFromArgs,
selectedColumns: [],
updatedColumns,
});
const result = await new EntityPersistExecutor(
this.connection,
queryRunnerForEntityPersistExecutor,
@@ -1130,7 +1144,7 @@ export class WorkspaceEntityManager extends EntityManager {
const resultArray = Array.isArray(result) ? result : [result];
const formattedResult = formatResult<Entity[]>(
let formattedResult = formatResult<Entity[]>(
resultArray,
objectMetadataItem,
this.internalContext.objectMetadataMaps,
@@ -1157,9 +1171,79 @@ export class WorkspaceEntityManager extends EntityManager {
}
}
const isFieldPermissionsEnabled =
this.getFeatureFlagMap().IS_FIELDS_PERMISSIONS_ENABLED;
const permissionCheckApplies =
permissionOptionsFromArgs?.shouldBypassPermissionChecks !== true &&
objectMetadataItem.isSystem !== true;
if (isFieldPermissionsEnabled && permissionCheckApplies) {
formattedResult = this.getFormattedResultWithoutNonReadableFields({
formattedResult,
objectMetadataItem,
permissionOptionsFromArgs,
});
}
return isEntityArray ? formattedResult : formattedResult[0];
}
private getFormattedResultWithoutNonReadableFields<
Entity extends ObjectLiteral,
>({
formattedResult,
objectMetadataItem,
permissionOptionsFromArgs,
}: {
formattedResult: Entity[];
objectMetadataItem: ObjectMetadataItemWithFieldMaps;
permissionOptionsFromArgs: PermissionOptions | undefined;
}): Entity[] {
const restrictedFields =
permissionOptionsFromArgs?.objectRecordsPermissions?.[
objectMetadataItem.id
].restrictedFields;
if (!restrictedFields) {
throw new InternalServerError('Restricted fields not found');
}
if (isEmpty(restrictedFields)) {
return formattedResult;
}
const objectMetadataItemWithFieldMaps =
this.internalContext.objectMetadataMaps.byId[objectMetadataItem.id];
const restrictedFieldNames = new Set(
Object.entries(restrictedFields)
.filter(([_, fieldPermissions]) => fieldPermissions.canRead === false)
.map(([fieldMetadataId]) => {
const fieldMetadata =
objectMetadataItemWithFieldMaps?.fieldsById[fieldMetadataId];
if (!isDefined(fieldMetadata)) {
throw new InternalServerError(
`Field metadata not found for field ${fieldMetadataId}`,
);
}
return fieldMetadata.name;
}),
);
const filteredResult = formattedResult.map((individualFormattedResult) => {
return Object.fromEntries(
Object.entries(individualFormattedResult).filter(
([key]) => !restrictedFieldNames.has(key),
),
);
});
return filteredResult as Entity[];
}
override remove<Entity>(
entity: Entity,
options?: RemoveOptions,
@@ -15,7 +15,7 @@ import {
PermissionsExceptionMessage,
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
import { getFieldMetadataIdForColumnNameMap } from 'src/engine/twenty-orm/utils/get-field-metadata-id-for-column-name.util';
import { getColumnNameToFieldMetadataIdMap } from 'src/engine/twenty-orm/utils/get-column-name-to-field-metadata-id.util';
const getTargetEntityAndOperationType = (expressionMap: QueryExpressionMap) => {
const mainEntity = expressionMap.aliases[0].metadata.name;
@@ -43,6 +43,7 @@ export const validateOperationIsPermittedOrThrow = ({
selectedColumns,
isFieldPermissionsEnabled,
allFieldsSelected,
updatedColumns,
}: {
entityName: string;
operationType: OperationType;
@@ -51,6 +52,7 @@ export const validateOperationIsPermittedOrThrow = ({
selectedColumns: string[];
isFieldPermissionsEnabled?: boolean;
allFieldsSelected: boolean;
updatedColumns: string[];
}) => {
const objectMetadataIdForEntity =
objectMetadataMaps.idByNameSingular[entityName];
@@ -77,8 +79,8 @@ export const validateOperationIsPermittedOrThrow = ({
return;
}
const fieldMetadataIdForColumnNameMap = isFieldPermissionsEnabled
? getFieldMetadataIdForColumnNameMap(objectMetadata)
const columnNameToFieldMetadataIdMap = isFieldPermissionsEnabled
? getColumnNameToFieldMetadataIdMap(objectMetadata)
: {};
const permissionsForEntity =
@@ -97,7 +99,7 @@ export const validateOperationIsPermittedOrThrow = ({
validateReadFieldPermissionOrThrow({
restrictedFields: permissionsForEntity.restrictedFields,
selectedColumns,
fieldMetadataIdForColumnNameMap,
columnNameToFieldMetadataIdMap,
allFieldsSelected,
});
}
@@ -115,7 +117,15 @@ export const validateOperationIsPermittedOrThrow = ({
validateReadFieldPermissionOrThrow({
restrictedFields: permissionsForEntity.restrictedFields,
selectedColumns,
fieldMetadataIdForColumnNameMap,
columnNameToFieldMetadataIdMap,
});
}
if (isFieldPermissionsEnabled && updatedColumns.length > 0) {
validateUpdateFieldPermissionOrThrow({
restrictedFields: permissionsForEntity.restrictedFields,
updatedColumns,
columnNameToFieldMetadataIdMap,
});
}
break;
@@ -131,7 +141,7 @@ export const validateOperationIsPermittedOrThrow = ({
validateReadFieldPermissionOrThrow({
restrictedFields: permissionsForEntity.restrictedFields,
selectedColumns,
fieldMetadataIdForColumnNameMap,
columnNameToFieldMetadataIdMap,
});
}
break;
@@ -148,7 +158,7 @@ export const validateOperationIsPermittedOrThrow = ({
validateReadFieldPermissionOrThrow({
restrictedFields: permissionsForEntity.restrictedFields,
selectedColumns,
fieldMetadataIdForColumnNameMap,
columnNameToFieldMetadataIdMap,
});
}
break;
@@ -189,6 +199,7 @@ export const validateQueryIsPermittedOrThrow = ({
);
let selectedColumns: string[] = [];
let updatedColumns: string[] = [];
if (isFieldPermissionsEnabled) {
selectedColumns = getSelectedColumnsFromExpressionMap({
@@ -196,6 +207,26 @@ export const validateQueryIsPermittedOrThrow = ({
expressionMap,
allFieldsSelected,
});
if (operationType !== 'select') {
const valuesSet = expressionMap.valuesSet;
if (Array.isArray(valuesSet)) {
updatedColumns = valuesSet.reduce((acc, value) => {
const keys = Object.keys(value);
keys.forEach((key) => {
if (!acc.includes(key)) {
acc.push(key);
}
});
return acc;
}, []);
} else {
updatedColumns = Object.keys(valuesSet ?? {});
}
}
}
validateOperationIsPermittedOrThrow({
@@ -203,21 +234,22 @@ export const validateQueryIsPermittedOrThrow = ({
operationType: operationType as OperationType,
objectRecordsPermissions,
objectMetadataMaps,
selectedColumns: selectedColumns,
selectedColumns,
isFieldPermissionsEnabled,
allFieldsSelected,
updatedColumns,
});
};
const validateReadFieldPermissionOrThrow = ({
restrictedFields,
selectedColumns,
fieldMetadataIdForColumnNameMap,
columnNameToFieldMetadataIdMap,
allFieldsSelected,
}: {
restrictedFields: RestrictedFields;
selectedColumns: string[];
fieldMetadataIdForColumnNameMap: Record<string, string>;
columnNameToFieldMetadataIdMap: Record<string, string>;
allFieldsSelected?: boolean;
}) => {
if (isEmpty(restrictedFields)) {
@@ -232,7 +264,7 @@ const validateReadFieldPermissionOrThrow = ({
}
for (const column of selectedColumns) {
const fieldMetadataId = fieldMetadataIdForColumnNameMap[column];
const fieldMetadataId = columnNameToFieldMetadataIdMap[column];
if (!fieldMetadataId) {
throw new InternalServerError(
@@ -249,6 +281,37 @@ const validateReadFieldPermissionOrThrow = ({
}
};
const validateUpdateFieldPermissionOrThrow = ({
restrictedFields,
updatedColumns,
columnNameToFieldMetadataIdMap,
}: {
restrictedFields: RestrictedFields;
updatedColumns: string[];
columnNameToFieldMetadataIdMap: Record<string, string>;
}) => {
if (isEmpty(restrictedFields)) {
return;
}
for (const column of updatedColumns) {
const fieldMetadataId = columnNameToFieldMetadataIdMap[column];
if (!fieldMetadataId) {
throw new InternalServerError(
`Field metadata id not found for column name ${column}`,
);
}
if (restrictedFields[fieldMetadataId]?.canUpdate === false) {
throw new PermissionsException(
PermissionsExceptionMessage.PERMISSION_DENIED,
PermissionsExceptionCode.PERMISSION_DENIED,
);
}
}
};
const getSelectedColumnsFromExpressionMap = ({
operationType,
expressionMap,
@@ -0,0 +1,265 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import { getColumnNameToFieldMetadataIdMap } from 'src/engine/twenty-orm/utils/get-column-name-to-field-metadata-id.util';
describe('getColumnNameToFieldMetadataIdMap', () => {
const createMockObjectMetadataItemWithFieldMaps = (
fieldsById: Record<string, any>,
): ObjectMetadataItemWithFieldMaps =>
({
id: 'test-object-id',
nameSingular: 'test',
namePlural: 'tests',
labelSingular: 'Test',
labelPlural: 'Tests',
description: 'Test object',
icon: 'IconTest',
targetTableName: 'test',
isCustom: false,
isRemote: false,
isActive: true,
isSystem: false,
isAuditLogged: false,
isSearchable: false,
labelIdentifierFieldMetadataId: '',
imageIdentifierFieldMetadataId: '',
workspaceId: 'test-workspace-id',
indexMetadatas: [],
fieldsById,
fieldIdByName: {},
fieldIdByJoinColumnName: {},
}) as unknown as ObjectMetadataItemWithFieldMaps;
const createMockFieldMetadata = (
id: string,
name: string,
type: FieldMetadataType,
) => ({
id,
name,
type,
label: name,
objectMetadataId: 'test-object-id',
isLabelSyncedWithName: true,
isNullable: true,
isUnique: false,
workspaceId: 'test-workspace-id',
createdAt: new Date(),
updatedAt: new Date(),
});
describe('with simple field types', () => {
it('should return a map with column name to field metadata id for simple field types', () => {
const fieldsById = {
'field-1': createMockFieldMetadata(
'field-1',
'name',
FieldMetadataType.TEXT,
),
'field-2': createMockFieldMetadata(
'field-2',
'age',
FieldMetadataType.NUMBER,
),
};
const objectMetadataItemWithFieldMaps =
createMockObjectMetadataItemWithFieldMaps(fieldsById);
const result = getColumnNameToFieldMetadataIdMap(
objectMetadataItemWithFieldMaps,
);
expect(result['name']).toBe('field-1');
expect(result['age']).toBe('field-2');
expect(Object.keys(result)).toHaveLength(2);
});
});
describe('with composite field types', () => {
it('should return column names to field metadata id for FULL_NAME composite type', () => {
const fieldsById = {
'field-1': createMockFieldMetadata(
'field-1',
'fullName',
FieldMetadataType.FULL_NAME,
),
};
const objectMetadataItemWithFieldMaps =
createMockObjectMetadataItemWithFieldMaps(fieldsById);
const result = getColumnNameToFieldMetadataIdMap(
objectMetadataItemWithFieldMaps,
);
expect(result['fullNameFirstName']).toBe('field-1');
expect(result['fullNameLastName']).toBe('field-1');
expect(Object.keys(result)).toHaveLength(2);
});
it('should return column names to field metadata id for CURRENCY composite type', () => {
const fieldsById = {
'field-1': createMockFieldMetadata(
'field-1',
'price',
FieldMetadataType.CURRENCY,
),
};
const objectMetadataItemWithFieldMaps =
createMockObjectMetadataItemWithFieldMaps(fieldsById);
const result = getColumnNameToFieldMetadataIdMap(
objectMetadataItemWithFieldMaps,
);
expect(result['priceAmountMicros']).toBe('field-1');
expect(result['priceCurrencyCode']).toBe('field-1');
expect(Object.keys(result)).toHaveLength(2);
});
it('should handle multiple composite fields', () => {
const fieldsById = {
'field-1': createMockFieldMetadata(
'field-1',
'fullName',
FieldMetadataType.FULL_NAME,
),
'field-2': createMockFieldMetadata(
'field-2',
'price',
FieldMetadataType.CURRENCY,
),
'field-3': createMockFieldMetadata(
'field-3',
'name',
FieldMetadataType.TEXT,
),
};
const objectMetadataItemWithFieldMaps =
createMockObjectMetadataItemWithFieldMaps(fieldsById);
const result = getColumnNameToFieldMetadataIdMap(
objectMetadataItemWithFieldMaps,
);
expect(result['fullNameFirstName']).toBe('field-1');
expect(result['fullNameLastName']).toBe('field-1');
expect(result['priceAmountMicros']).toBe('field-2');
expect(result['priceCurrencyCode']).toBe('field-2');
expect(result['name']).toBe('field-3');
expect(Object.keys(result)).toHaveLength(5);
});
});
describe('with mixed field types', () => {
it('should handle both simple and composite field types', () => {
const fieldsById = {
'field-1': createMockFieldMetadata(
'field-1',
'name',
FieldMetadataType.TEXT,
),
'field-2': createMockFieldMetadata(
'field-2',
'fullName',
FieldMetadataType.FULL_NAME,
),
'field-3': createMockFieldMetadata(
'field-3',
'age',
FieldMetadataType.NUMBER,
),
'field-4': createMockFieldMetadata(
'field-4',
'price',
FieldMetadataType.CURRENCY,
),
};
const objectMetadataItemWithFieldMaps =
createMockObjectMetadataItemWithFieldMaps(fieldsById);
const result = getColumnNameToFieldMetadataIdMap(
objectMetadataItemWithFieldMaps,
);
expect(result['name']).toBe('field-1');
expect(result['fullNameFirstName']).toBe('field-2');
expect(result['fullNameLastName']).toBe('field-2');
expect(result['age']).toBe('field-3');
expect(result['priceAmountMicros']).toBe('field-4');
expect(result['priceCurrencyCode']).toBe('field-4');
expect(Object.keys(result)).toHaveLength(6);
});
});
describe('with relation field types', () => {
it('should handle relation field types with join column name', () => {
const fieldsById = {
'field-1': {
...createMockFieldMetadata(
'field-1',
'company',
FieldMetadataType.RELATION,
),
settings: {
relationType: 'ONE_TO_ONE',
joinColumnName: 'companyId',
},
},
'field-2': createMockFieldMetadata(
'field-2',
'name',
FieldMetadataType.TEXT,
),
};
const objectMetadataItemWithFieldMaps =
createMockObjectMetadataItemWithFieldMaps(fieldsById);
const result = getColumnNameToFieldMetadataIdMap(
objectMetadataItemWithFieldMaps,
);
expect(result['companyId']).toBe('field-1');
expect(result['name']).toBe('field-2');
expect(Object.keys(result)).toHaveLength(2);
});
it('should skip ONE_TO_MANY relation field types', () => {
const fieldsById = {
'field-1': {
...createMockFieldMetadata(
'field-1',
'employees',
FieldMetadataType.RELATION,
),
settings: {
relationType: 'ONE_TO_MANY',
joinColumnName: 'companyId',
},
},
'field-2': createMockFieldMetadata(
'field-2',
'name',
FieldMetadataType.TEXT,
),
};
const objectMetadataItemWithFieldMaps =
createMockObjectMetadataItemWithFieldMaps(fieldsById);
const result = getColumnNameToFieldMetadataIdMap(
objectMetadataItemWithFieldMaps,
);
expect(result['name']).toBe('field-2');
expect(result['companyId']).toBeUndefined();
expect(Object.keys(result)).toHaveLength(1);
});
});
});
@@ -0,0 +1,61 @@
import { CompositeType } from 'src/engine/metadata-modules/field-metadata/interfaces/composite-type.interface';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import {
ColumnNameProcessor,
processFieldMetadataForColumnNameMapping,
} from 'src/engine/twenty-orm/utils/process-field-metadata-for-column-name-mapping.util';
export function getColumnNameToFieldMetadataIdMap(
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
) {
const columnNameToFieldMetadataIdMap: Record<string, string> = {};
const processor: ColumnNameProcessor = {
processCompositeField: ({
fieldMetadataId,
fieldMetadata,
compositeType,
}: {
fieldMetadataId: string;
fieldMetadata: FieldMetadataEntity;
compositeType: CompositeType;
}) => {
compositeType.properties.forEach((compositeProperty) => {
const columnName = computeCompositeColumnName(
fieldMetadata.name,
compositeProperty,
);
columnNameToFieldMetadataIdMap[columnName] = fieldMetadataId;
});
},
processRelationField: ({
fieldMetadataId,
columnName,
}: {
fieldMetadataId: string;
columnName: string;
}) => {
columnNameToFieldMetadataIdMap[columnName] = fieldMetadataId;
},
processSimpleField: ({
fieldMetadataId,
columnName,
}: {
fieldMetadataId: string;
columnName: string;
}) => {
columnNameToFieldMetadataIdMap[columnName] = fieldMetadataId;
},
};
processFieldMetadataForColumnNameMapping(
objectMetadataItemWithFieldMaps,
processor,
);
return columnNameToFieldMetadataIdMap;
}
@@ -1,47 +0,0 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { InternalServerError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
import {
computeColumnName,
computeCompositeColumnName,
} from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
export function getFieldMetadataIdForColumnNameMap(
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
) {
const columnNameToFieldMetadataIdMap: Record<string, string> = {};
for (const [fieldMetadataId, fieldMetadata] of Object.entries(
objectMetadataItemWithFieldMaps.fieldsById,
)) {
if (isCompositeFieldMetadataType(fieldMetadata.type)) {
const compositeType = compositeTypeDefinitions.get(fieldMetadata.type);
if (!compositeType) {
throw new InternalServerError(
`Composite type not found for field metadata type ${fieldMetadata.type}`,
);
}
compositeType.properties.forEach((compositeProperty) => {
const columnName = computeCompositeColumnName(
fieldMetadata.name,
compositeProperty,
);
columnNameToFieldMetadataIdMap[columnName] = fieldMetadataId;
});
} else {
const columnName = computeColumnName(fieldMetadata, {
isForeignKey: fieldMetadata.type === FieldMetadataType.RELATION,
});
columnNameToFieldMetadataIdMap[columnName] = fieldMetadataId;
}
}
return columnNameToFieldMetadataIdMap;
}
@@ -1,29 +1,28 @@
import { InternalServerError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
import {
computeColumnName,
computeCompositeColumnName,
} from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import { CompositeType } from 'src/engine/metadata-modules/field-metadata/interfaces/composite-type.interface';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { computeCompositeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
import {
ColumnNameProcessor,
processFieldMetadataForColumnNameMapping,
} from 'src/engine/twenty-orm/utils/process-field-metadata-for-column-name-mapping.util';
export function getFieldMetadataIdToColumnNamesMap(
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
) {
const fieldMetadataToColumnNamesMap = new Map<string, string[]>();
for (const [fieldMetadataId, fieldMetadata] of Object.entries(
objectMetadataItemWithFieldMaps.fieldsById,
)) {
if (isCompositeFieldMetadataType(fieldMetadata.type)) {
const compositeType = compositeTypeDefinitions.get(fieldMetadata.type);
if (!compositeType) {
throw new InternalServerError(
`Composite type not found for field metadata type ${fieldMetadata.type}`,
);
}
const processor: ColumnNameProcessor = {
processCompositeField: ({
fieldMetadataId,
fieldMetadata,
compositeType,
}: {
fieldMetadataId: string;
fieldMetadata: FieldMetadataEntity;
compositeType: CompositeType;
}) => {
compositeType.properties.forEach((compositeProperty) => {
const columnName = computeCompositeColumnName(
fieldMetadata.name,
@@ -38,12 +37,33 @@ export function getFieldMetadataIdToColumnNamesMap(
columnName,
]);
});
} else {
const columnName = computeColumnName(fieldMetadata);
},
processRelationField: ({
fieldMetadataId,
columnName,
}: {
fieldMetadataId: string;
fieldMetadata: FieldMetadataEntity;
columnName: string;
}) => {
fieldMetadataToColumnNamesMap.set(fieldMetadataId, [columnName]); // TODO test
},
processSimpleField: ({
fieldMetadataId,
columnName,
}: {
fieldMetadataId: string;
fieldMetadata: FieldMetadataEntity;
columnName: string;
}) => {
fieldMetadataToColumnNamesMap.set(fieldMetadataId, [columnName]);
}
}
},
};
processFieldMetadataForColumnNameMapping(
objectMetadataItemWithFieldMaps,
processor,
);
return fieldMetadataToColumnNamesMap;
}
@@ -0,0 +1,100 @@
import { CompositeType } from 'src/engine/metadata-modules/field-metadata/interfaces/composite-type.interface';
import { FieldMetadataRelationSettings } from 'src/engine/metadata-modules/field-metadata/interfaces/field-metadata-settings.interface';
import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfaces/relation-type.interface';
import { isFieldMetadataRelationOrMorphRelation } from 'src/engine/api/graphql/workspace-schema-builder/utils/is-field-metadata-relation-or-morph-relation.utils';
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { computeColumnName } from 'src/engine/metadata-modules/field-metadata/utils/compute-column-name.util';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
import {
PermissionsException,
PermissionsExceptionCode,
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
export type ColumnNameProcessor = {
processCompositeField: ({
fieldMetadataId,
fieldMetadata,
compositeType,
}: {
fieldMetadataId: string;
fieldMetadata: FieldMetadataEntity;
compositeType: CompositeType;
}) => void;
processRelationField: ({
fieldMetadataId,
fieldMetadata,
columnName,
}: {
fieldMetadataId: string;
fieldMetadata: FieldMetadataEntity;
columnName: string;
}) => void;
processSimpleField: ({
fieldMetadataId,
fieldMetadata,
columnName,
}: {
fieldMetadataId: string;
fieldMetadata: FieldMetadataEntity;
columnName: string;
}) => void;
};
export function processFieldMetadataForColumnNameMapping(
objectMetadataItemWithFieldMaps: ObjectMetadataItemWithFieldMaps,
processor: ColumnNameProcessor,
) {
for (const [fieldMetadataId, fieldMetadata] of Object.entries(
objectMetadataItemWithFieldMaps.fieldsById,
)) {
if (isCompositeFieldMetadataType(fieldMetadata.type)) {
const compositeType = compositeTypeDefinitions.get(fieldMetadata.type);
if (!compositeType) {
throw new PermissionsException(
`Composite type not found for field metadata type ${fieldMetadata.type}`,
PermissionsExceptionCode.COMPOSITE_TYPE_NOT_FOUND,
);
}
processor.processCompositeField({
fieldMetadataId,
fieldMetadata,
compositeType,
});
} else {
if (isFieldMetadataRelationOrMorphRelation(fieldMetadata)) {
const fieldMetadataSettings =
fieldMetadata.settings as FieldMetadataRelationSettings;
if (fieldMetadataSettings?.relationType === RelationType.ONE_TO_MANY) {
continue;
}
const columnName = fieldMetadataSettings?.joinColumnName;
if (!columnName) {
throw new PermissionsException(
`Join column name is required for relation field metadata ${fieldMetadata.name}`,
PermissionsExceptionCode.JOIN_COLUMN_NAME_REQUIRED,
);
}
processor.processRelationField({
fieldMetadataId,
fieldMetadata,
columnName,
});
} else {
const columnName = computeColumnName(fieldMetadata);
processor.processSimpleField({
fieldMetadataId,
fieldMetadata,
columnName,
});
}
}
}
}
@@ -0,0 +1,435 @@
import { randomUUID } from 'crypto';
import gql from 'graphql-tag';
import request from 'supertest';
import { createCustomRoleWithObjectPermissions } from 'test/integration/graphql/utils/create-custom-role-with-object-permissions.util';
import { createManyOperationFactory } from 'test/integration/graphql/utils/create-many-operation-factory.util';
import { createOneOperationFactory } from 'test/integration/graphql/utils/create-one-operation-factory.util';
import { deleteRole } from 'test/integration/graphql/utils/delete-one-role.util';
import { makeGraphqlAPIRequestWithMemberRole } from 'test/integration/graphql/utils/make-graphql-api-request-with-member-role.util';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { updateFeatureFlagFactory } from 'test/integration/graphql/utils/update-feature-flag-factory.util';
import { updateManyOperationFactory } from 'test/integration/graphql/utils/update-many-operation-factory.util';
import { updateOneOperationFactory } from 'test/integration/graphql/utils/update-one-operation-factory.util';
import { updateWorkspaceMemberRole } from 'test/integration/graphql/utils/update-workspace-member-role.util';
import { upsertFieldPermissions } from 'test/integration/graphql/utils/upsert-field-permissions.util';
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
import { ErrorCode } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
import { PermissionsExceptionMessage } from 'src/engine/metadata-modules/permissions/permissions.exception';
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-workspaces.util';
import { WORKSPACE_MEMBER_DATA_SEED_IDS } from 'src/engine/workspace-manager/dev-seeder/data/constants/workspace-member-data-seeds.constant';
const client = request(`http://localhost:${APP_PORT}`);
const COMPANY_GQL_FIELDS_WITH_EMPLOYEES = `
id
name
employees
`;
const COMPANY_GQL_FIELDS_WITHOUT_EMPLOYEES = `
id
name
`;
const expectPermissionDeniedError = (response: any) => {
expect(response.body.errors[0].message).toBe(
PermissionsExceptionMessage.PERMISSION_DENIED,
);
expect(response.body.errors[0].extensions.code).toBe(ErrorCode.FORBIDDEN);
};
describe('Field update permissions restrictions', () => {
let companyId: string;
let personId: string;
let customRoleId: string;
let companyObjectId: string;
let restrictedCompanyFieldId: string;
let originalMemberRoleId: string;
const restrictUpdateAccessToCompanyEmployee = async (
roleId: string,
companyObjectId: string,
restrictedCompanyFieldId: string,
) => {
await upsertFieldPermissions({
roleId,
fieldPermissions: [
{
objectMetadataId: companyObjectId,
fieldMetadataId: restrictedCompanyFieldId,
canUpdateFieldValue: false,
},
],
});
};
const restrictReadAccessToCompanyEmployee = async (
roleId: string,
companyObjectId: string,
restrictedCompanyFieldId: string,
) => {
await upsertFieldPermissions({
roleId,
fieldPermissions: [
{
objectMetadataId: companyObjectId,
fieldMetadataId: restrictedCompanyFieldId,
canReadFieldValue: false,
},
],
});
};
beforeAll(async () => {
// Enable the feature flag
const enablePermissionsQuery = updateFeatureFlagFactory(
SEED_APPLE_WORKSPACE_ID,
'IS_FIELDS_PERMISSIONS_ENABLED',
true,
);
await makeGraphqlAPIRequest(enablePermissionsQuery);
// Get the original Member role ID for restoration later
const getRolesQuery = {
query: `
query GetRoles {
getRoles {
id
label
}
}
`,
};
const rolesResponse = await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(getRolesQuery);
originalMemberRoleId = rolesResponse.body.data.getRoles.find(
(role: any) => role.label === 'Member',
).id;
// Create a company and a person
companyId = randomUUID();
personId = randomUUID();
const createCompanyOp = createOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: 'id name employees',
data: { id: companyId, name: 'TestCompany', employees: 10 },
});
await makeGraphqlAPIRequest(createCompanyOp);
const createPersonOperation = createOneOperationFactory({
objectMetadataSingularName: 'person',
gqlFields: 'id city',
data: { id: personId, city: 'Paris', companyId },
});
await makeGraphqlAPIRequest(createPersonOperation);
// Get object and field metadata IDs
const getObjectMetadataOp = {
query: gql`
query {
objects(paging: { first: 1000 }) {
edges {
node {
id
nameSingular
}
}
}
}
`,
};
const objectMetadataResponse =
await makeMetadataAPIRequest(getObjectMetadataOp);
const objects = objectMetadataResponse.body.data.objects.edges;
companyObjectId = objects.find(
(obj: any) => obj.node.nameSingular === 'company',
).node.id;
const getFieldMetadataOp = {
query: gql`
query {
fields(paging: { first: 1000 }) {
edges {
node {
id
name
object {
nameSingular
}
}
}
}
}
`,
};
const fieldMetadataResponse =
await makeMetadataAPIRequest(getFieldMetadataOp);
const fields = fieldMetadataResponse.body.data.fields.edges;
restrictedCompanyFieldId = fields.find(
(field: any) =>
field.node.name === 'employees' &&
field.node.object.nameSingular === 'company',
).node.id;
});
afterAll(async () => {
// Restore the feature flag
const disablePermissionsQuery = updateFeatureFlagFactory(
SEED_APPLE_WORKSPACE_ID,
'IS_FIELDS_PERMISSIONS_ENABLED',
false,
);
await makeGraphqlAPIRequest(disablePermissionsQuery);
// Restore original role
const restoreMemberRoleQuery = {
query: `
mutation UpdateWorkspaceMemberRole {
updateWorkspaceMemberRole(
workspaceMemberId: "${WORKSPACE_MEMBER_DATA_SEED_IDS.JONY}"
roleId: "${originalMemberRoleId}"
) { id }
}
`,
};
await client
.post('/graphql')
.set('Authorization', `Bearer ${APPLE_JANE_ADMIN_ACCESS_TOKEN}`)
.send(restoreMemberRoleQuery);
});
beforeEach(async () => {
const { roleId } = await createCustomRoleWithObjectPermissions({
label: 'CompanyPeopleRole',
canReadCompany: true,
canReadPerson: true,
hasAllObjectRecordsReadPermission: true,
canUpdateCompany: true,
canUpdatePerson: true,
canUpdateOpportunities: true,
});
customRoleId = roleId;
await updateWorkspaceMemberRole({
client,
roleId: customRoleId,
workspaceMemberId: WORKSPACE_MEMBER_DATA_SEED_IDS.JONY,
});
});
afterEach(async () => {
if (customRoleId) {
await deleteRole(client, customRoleId);
customRoleId = '';
}
});
// describe('should throw an error if updating a restricted field', () => {
// beforeEach(async () => {
// await restrictUpdateAccessToCompanyEmployee(
// customRoleId,
// companyObjectId,
// restrictedCompanyFieldId,
// );
// });
// it('1. updateMany with restricted field', async () => {
// const graphqlOperation = updateManyOperationFactory({
// objectMetadataSingularName: 'company',
// objectMetadataPluralName: 'companies',
// gqlFields: COMPANY_GQL_FIELDS_WITH_EMPLOYEES,
// data: { employees: 20 },
// });
// const response =
// await makeGraphqlAPIRequestWithMemberRole(graphqlOperation);
// expectPermissionDeniedError(response);
// });
// it('2. updateOne with restricted field', async () => {
// const graphqlOperation = updateOneOperationFactory({
// objectMetadataSingularName: 'company',
// gqlFields: COMPANY_GQL_FIELDS_WITH_EMPLOYEES,
// recordId: companyId,
// data: { employees: 20 },
// });
// const response =
// await makeGraphqlAPIRequestWithMemberRole(graphqlOperation);
// expectPermissionDeniedError(response);
// });
// });
// describe('should succeed if updating non-restricted fields', () => {
// beforeEach(async () => {
// await restrictUpdateAccessToCompanyEmployee(
// customRoleId,
// companyObjectId,
// restrictedCompanyFieldId,
// );
// });
// it('1. updateMany with non-restricted field', async () => {
// const graphqlOperation = updateManyOperationFactory({
// objectMetadataSingularName: 'company',
// objectMetadataPluralName: 'companies',
// gqlFields: COMPANY_GQL_FIELDS_WITHOUT_EMPLOYEES,
// data: { name: 'UpdatedCompany' },
// });
// const response =
// await makeGraphqlAPIRequestWithMemberRole(graphqlOperation);
// expect(response.body.errors).toBeUndefined();
// expect(response.body.data).toBeDefined();
// expect(response.body.data.updateCompanies[0].name).toBe('UpdatedCompany');
// });
// it('2. updateOne with non-restricted field', async () => {
// const graphqlOperation = updateOneOperationFactory({
// objectMetadataSingularName: 'company',
// gqlFields: COMPANY_GQL_FIELDS_WITHOUT_EMPLOYEES,
// recordId: companyId,
// data: { name: 'UpdatedCompany2' },
// });
// const response =
// await makeGraphqlAPIRequestWithMemberRole(graphqlOperation);
// expect(response.body.errors).toBeUndefined();
// expect(response.body.data).toBeDefined();
// expect(response.body.data.updateCompany.name).toBe('UpdatedCompany2');
// });
// });
describe('should throw an error if creating with restricted fields', () => {
beforeEach(async () => {
await restrictUpdateAccessToCompanyEmployee(
customRoleId,
companyObjectId,
restrictedCompanyFieldId,
);
});
it('1. createMany with restricted field', async () => {
const graphqlOperation = createManyOperationFactory({
objectMetadataSingularName: 'company',
objectMetadataPluralName: 'companies',
gqlFields: COMPANY_GQL_FIELDS_WITH_EMPLOYEES,
data: [
{ id: randomUUID(), name: 'NewCompany1', employees: 15 },
{ id: randomUUID(), name: 'NewCompany2', employees: 20 },
],
});
const response =
await makeGraphqlAPIRequestWithMemberRole(graphqlOperation);
expectPermissionDeniedError(response);
});
it('2. createOne with restricted field', async () => {
const graphqlOperation = createOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: COMPANY_GQL_FIELDS_WITH_EMPLOYEES,
data: { id: randomUUID(), name: 'NewCompany3', employees: 25 },
});
const response =
await makeGraphqlAPIRequestWithMemberRole(graphqlOperation);
expectPermissionDeniedError(response);
});
});
describe('should throw an error if reading restricted fields in update operations', () => {
beforeEach(async () => {
await restrictReadAccessToCompanyEmployee(
customRoleId,
companyObjectId,
restrictedCompanyFieldId,
);
});
it('1. updateMany requesting restricted field in response', async () => {
const graphqlOperation = updateManyOperationFactory({
objectMetadataSingularName: 'company',
objectMetadataPluralName: 'companies',
gqlFields: COMPANY_GQL_FIELDS_WITH_EMPLOYEES,
data: { name: 'UpdatedCompany' },
});
const response =
await makeGraphqlAPIRequestWithMemberRole(graphqlOperation);
expectPermissionDeniedError(response);
});
it('2. updateOne requesting restricted field in response', async () => {
const graphqlOperation = updateOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: COMPANY_GQL_FIELDS_WITH_EMPLOYEES,
recordId: companyId,
data: { name: 'UpdatedCompany' },
});
const response =
await makeGraphqlAPIRequestWithMemberRole(graphqlOperation);
expectPermissionDeniedError(response);
});
});
describe('should succeed if not requesting restricted fields in update operations', () => {
beforeEach(async () => {
await restrictUpdateAccessToCompanyEmployee(
customRoleId,
companyObjectId,
restrictedCompanyFieldId,
);
});
it('1. updateMany not requesting restricted field in response', async () => {
const graphqlOperation = updateManyOperationFactory({
objectMetadataSingularName: 'company',
objectMetadataPluralName: 'companies',
gqlFields: COMPANY_GQL_FIELDS_WITHOUT_EMPLOYEES,
data: { name: 'UpdatedCompany' },
});
const response =
await makeGraphqlAPIRequestWithMemberRole(graphqlOperation);
expect(response.body.errors).toBeUndefined();
expect(response.body.data).toBeDefined();
expect(response.body.data.updateCompanies[0].name).toBe('UpdatedCompany');
});
it('2. updateOne not requesting restricted field in response', async () => {
const graphqlOperation = updateOneOperationFactory({
objectMetadataSingularName: 'company',
gqlFields: COMPANY_GQL_FIELDS_WITHOUT_EMPLOYEES,
recordId: companyId,
data: { name: 'UpdatedCompany2' },
});
const response =
await makeGraphqlAPIRequestWithMemberRole(graphqlOperation);
expect(response.body.errors).toBeUndefined();
expect(response.body.data).toBeDefined();
expect(response.body.data.updateCompany.name).toBe('UpdatedCompany2');
});
});
});
@@ -7,6 +7,9 @@ export const createCustomRoleWithObjectPermissions = async (options: {
canReadPerson?: boolean;
canReadCompany?: boolean;
canReadOpportunities?: boolean;
canUpdatePerson?: boolean;
canUpdateCompany?: boolean;
canUpdateOpportunities?: boolean;
hasAllObjectRecordsReadPermission?: boolean;
}) => {
const createRoleOperation = {
@@ -68,31 +71,47 @@ export const createCustomRoleWithObjectPermissions = async (options: {
// Create object permissions based on the options
const objectPermissions = [];
if (options.canReadPerson !== undefined) {
if (
options.canReadPerson !== undefined ||
options.canUpdatePerson !== undefined
) {
objectPermissions.push({
objectMetadataId: personObjectId,
canReadObjectRecords: options.canReadPerson,
canUpdateObjectRecords: false,
canUpdateObjectRecords:
options.canUpdatePerson === undefined ? false : options.canUpdatePerson,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
});
}
if (options.canReadCompany !== undefined) {
if (
options.canReadCompany !== undefined ||
options.canUpdateCompany !== undefined
) {
objectPermissions.push({
objectMetadataId: companyObjectId,
canReadObjectRecords: options.canReadCompany,
canUpdateObjectRecords: false,
canUpdateObjectRecords:
options.canUpdateCompany === undefined
? false
: options.canUpdateCompany,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
});
}
if (options.canReadOpportunities !== undefined) {
if (
options.canReadOpportunities !== undefined ||
options.canUpdateOpportunities !== undefined
) {
objectPermissions.push({
objectMetadataId: opportunityObjectId,
canReadObjectRecords: options.canReadOpportunities,
canUpdateObjectRecords: false,
canUpdateObjectRecords:
options.canUpdateOpportunities === undefined
? false
: options.canUpdateOpportunities,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
});
@@ -1,5 +1,7 @@
import gql from 'graphql-tag';
import { TEST_COMPANY_1_ID } from 'test/integration/constants/test-company-ids.constants';
import { TEST_PERSON_1_ID } from 'test/integration/constants/test-person-ids.constants';
import { TEST_PRIMARY_LINK_URL } from 'test/integration/constants/test-primary-link-url.constant';
import { makeGraphqlAPIRequest } from 'test/integration/graphql/utils/make-graphql-api-request.util';
import { updateFeatureFlagFactory } from 'test/integration/graphql/utils/update-feature-flag-factory.util';
import { upsertFieldPermissions } from 'test/integration/graphql/utils/upsert-field-permissions.util';
@@ -11,13 +13,25 @@ import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder
describe('Restricted fields', () => {
let personCity: string;
let adminRoleId: string;
let memberRoleId: string;
let personObjectId: string;
let emailsFieldId: string;
let phonesFieldId: string;
beforeAll(async () => {
personCity = generateRecordName(TEST_PERSON_1_ID);
await makeRestAPIRequest({
method: 'post',
path: '/companies',
body: {
id: TEST_COMPANY_1_ID,
domainName: {
primaryLinkUrl: TEST_PRIMARY_LINK_URL,
},
},
});
await makeRestAPIRequest({
method: 'post',
path: '/people',
@@ -27,6 +41,11 @@ describe('Restricted fields', () => {
emails: {
primaryEmail: 'test@test.com',
},
phones: {
primaryPhoneNumber: '123456789',
primaryPhoneCountryCode: 'US',
primaryPhoneCallingCode: '+1',
},
},
});
@@ -85,7 +104,13 @@ describe('Restricted fields', () => {
field.node.object.nameSingular === 'person',
).node.id;
// Get admin role ID
phonesFieldId = fields.find(
(field: any) =>
field.node.name === 'phones' &&
field.node.object.nameSingular === 'person',
).node.id;
// Get member role ID
const getRolesOperation = {
query: gql`
query {
@@ -99,13 +124,13 @@ describe('Restricted fields', () => {
const rolesResponse = await makeMetadataAPIRequest(getRolesOperation);
adminRoleId = rolesResponse.body.data.getRoles.find(
memberRoleId = rolesResponse.body.data.getRoles.find(
(role: any) => role.label === 'Member',
)?.id;
// Create field permission restricting read access to email field
await upsertFieldPermissions({
roleId: adminRoleId,
roleId: memberRoleId,
fieldPermissions: [
{
objectMetadataId: personObjectId,
@@ -137,7 +162,8 @@ describe('Restricted fields', () => {
await makeGraphqlAPIRequest(disablePermissionsQuery);
});
it('should hide fields when user has restricted read permissions', async () => {
it('should hide fields when user has restricted read permissions - findOne', async () => {
await makeRestAPIRequest({
method: 'get',
path: `/people/${TEST_PERSON_1_ID}`,
@@ -152,6 +178,248 @@ describe('Restricted fields', () => {
expect(person.emails).toBeUndefined();
});
});
describe('updateOne', () => {
it('should hide fields in the response when user has restricted read permissions', async () => {
// Create field permission restricting update access to phones field
await upsertFieldPermissions({
roleId: memberRoleId,
fieldPermissions: [
{
objectMetadataId: personObjectId,
fieldMetadataId: phonesFieldId,
canReadFieldValue: false,
canUpdateFieldValue: null,
},
],
});
await makeRestAPIRequest({
method: 'patch',
path: `/people/${TEST_PERSON_1_ID}`,
bearer: APPLE_JONY_MEMBER_ACCESS_TOKEN,
body: {
name: {
firstName: 'John',
},
},
})
.expect(200)
.expect((res) => {
const updatedPerson = res.body.data.updatePerson;
expect(updatedPerson.name.firstName).toBe('John');
expect(updatedPerson.phones).toBeUndefined();
});
});
it('should block update when user tries to update non-updatable field', async () => {
// Create field permission restricting update access to phones field
await upsertFieldPermissions({
roleId: memberRoleId,
fieldPermissions: [
{
objectMetadataId: personObjectId,
fieldMetadataId: phonesFieldId,
canReadFieldValue: null,
canUpdateFieldValue: false,
},
],
});
await makeRestAPIRequest({
method: 'patch',
path: `/people/${TEST_PERSON_1_ID}`,
bearer: APPLE_JONY_MEMBER_ACCESS_TOKEN,
body: {
phones: {
primaryPhoneNumber: '987654321',
primaryPhoneCountryCode: 'FR',
primaryPhoneCallingCode: '+33',
},
},
})
.expect(400)
.expect((res) => {
expect(res.body.messages[0]).toContain(
'User does not have permission',
);
});
});
it('should allow update when user has no restricted update permissions', async () => {
// Remove field permission restrictions
await upsertFieldPermissions({
roleId: memberRoleId,
fieldPermissions: [
{
objectMetadataId: personObjectId,
fieldMetadataId: phonesFieldId,
canReadFieldValue: null,
canUpdateFieldValue: null,
},
],
});
await makeRestAPIRequest({
method: 'patch',
path: `/people/${TEST_PERSON_1_ID}`,
bearer: APPLE_JONY_MEMBER_ACCESS_TOKEN,
body: {
city: 'Updated City',
},
})
.expect(200)
.expect((res) => {
const updatedPerson = res.body.data.updatePerson;
expect(updatedPerson.city).toBe('Updated City');
});
});
});
describe('createOne', () => {
it('should block create when user has restricted update permissions on phones field', async () => {
// Create field permission restricting update access to phones field
await upsertFieldPermissions({
roleId: memberRoleId,
fieldPermissions: [
{
objectMetadataId: personObjectId,
fieldMetadataId: phonesFieldId,
canReadFieldValue: null,
canUpdateFieldValue: false,
},
],
});
await makeRestAPIRequest({
method: 'post',
path: `/people`,
bearer: APPLE_JONY_MEMBER_ACCESS_TOKEN,
body: {
phones: {
primaryPhoneNumber: '555123456',
primaryPhoneCountryCode: 'US',
primaryPhoneCallingCode: '+1',
},
},
})
.expect(400)
.expect((res) => {
expect(res.body.messages[0]).toContain(
'User does not have permission',
);
});
});
it('should allow create when user has no restricted update permissions', async () => {
// Remove field permission restrictions on phones
await upsertFieldPermissions({
roleId: memberRoleId,
fieldPermissions: [
{
objectMetadataId: personObjectId,
fieldMetadataId: phonesFieldId,
canReadFieldValue: null,
canUpdateFieldValue: null,
},
],
});
await makeRestAPIRequest({
method: 'post',
path: `/people`,
bearer: APPLE_JONY_MEMBER_ACCESS_TOKEN,
body: {
city: 'New City',
},
})
.expect(201)
.expect((res) => {
const createdPerson = res.body.data.createPerson;
expect(createdPerson.city).toBe('New City');
expect(createdPerson.emails).toBeUndefined(); // No reading rights on emails
});
});
});
describe('createMany', () => {
it('should block createMany when user has restricted update permissions on phones field', async () => {
// Create field permission restricting update access to phones field
await upsertFieldPermissions({
roleId: memberRoleId,
fieldPermissions: [
{
objectMetadataId: personObjectId,
fieldMetadataId: phonesFieldId,
canReadFieldValue: null,
canUpdateFieldValue: false,
},
],
});
await makeRestAPIRequest({
method: 'post',
path: `/batch/people`,
bearer: APPLE_JONY_MEMBER_ACCESS_TOKEN,
body: [
{
phones: {
primaryPhoneNumber: '555123456',
primaryPhoneCountryCode: 'US',
primaryPhoneCallingCode: '+1',
},
},
],
})
.expect(400)
.expect((res) => {
expect(res.body.messages[0]).toContain(
'User does not have permission',
);
});
});
it('should allow createMany when user has no restricted update permissions', async () => {
// Remove field permission restrictions
await upsertFieldPermissions({
roleId: memberRoleId,
fieldPermissions: [
{
objectMetadataId: personObjectId,
fieldMetadataId: phonesFieldId,
canReadFieldValue: null,
canUpdateFieldValue: null,
},
],
});
await makeRestAPIRequest({
method: 'post',
path: `/batch/people`,
bearer: APPLE_JONY_MEMBER_ACCESS_TOKEN,
body: [
{
city: 'Batch City 1',
},
{
city: 'Batch City 2',
},
],
})
.expect(201)
.expect((res) => {
const createdPeople = res.body.data.createPeople;
expect(createdPeople).toHaveLength(2);
expect(createdPeople[0].city).toBe('Batch City 1');
expect(createdPeople[0].emails).toBeUndefined(); // No reading rights on emails
expect(createdPeople[1].city).toBe('Batch City 2');
expect(createdPeople[1].emails).toBeUndefined(); // No reading rights on emails
});
});
});
});
describe('With feature flag disabled', () => {
@@ -170,5 +438,26 @@ describe('Restricted fields', () => {
expect(person.emails).toBeDefined();
});
});
it('should allow updates despite field permission restriction', async () => {
await makeRestAPIRequest({
method: 'patch',
path: `/people/${TEST_PERSON_1_ID}`,
bearer: APPLE_JONY_MEMBER_ACCESS_TOKEN,
body: {
phones: {
primaryPhoneNumber: '111222333',
primaryPhoneCountryCode: 'US',
primaryPhoneCallingCode: '+1',
},
},
})
.expect(200)
.expect((res) => {
const updatedPerson = res.body.data.updatePerson;
expect(updatedPerson.phones.primaryPhoneNumber).toBe('111222333');
});
});
});
});