[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:
+1
-1
@@ -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';
|
||||
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
|
||||
+5
-9
@@ -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,
|
||||
+13
@@ -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)
|
||||
);
|
||||
};
|
||||
+1
-1
@@ -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,
|
||||
});
|
||||
|
||||
+4
-2
@@ -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,
|
||||
|
||||
+2
@@ -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 {
|
||||
|
||||
+2
@@ -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;
|
||||
|
||||
+54
-1
@@ -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: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
+97
-13
@@ -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,
|
||||
|
||||
+265
@@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
+61
@@ -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;
|
||||
}
|
||||
-47
@@ -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;
|
||||
}
|
||||
+44
-24
@@ -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;
|
||||
}
|
||||
|
||||
+100
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+435
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
+25
-6
@@ -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,
|
||||
});
|
||||
|
||||
+294
-5
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user