Adapt rest api to field permissions (#13314)
Closes https://github.com/twentyhq/core-team-issues/issues/1217 We should only query and return the fields that are readable when using the rest api. This is behind a feature flag.
This commit is contained in:
@ -12,7 +12,7 @@ import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api
|
||||
@Injectable()
|
||||
export class RestApiCreateManyHandler extends RestApiBaseHandler {
|
||||
async handle(request: Request) {
|
||||
const { objectMetadata, repository } =
|
||||
const { objectMetadata, repository, restrictedFields } =
|
||||
await this.getRepositoryAndMetadataOrFail(request);
|
||||
|
||||
const body = request.body;
|
||||
@ -63,6 +63,7 @@ export class RestApiCreateManyHandler extends RestApiBaseHandler {
|
||||
repository,
|
||||
objectMetadata,
|
||||
depth: this.depthInputFactory.create(request),
|
||||
restrictedFields,
|
||||
});
|
||||
|
||||
if (records.length !== body.length) {
|
||||
|
||||
@ -12,7 +12,7 @@ import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api
|
||||
@Injectable()
|
||||
export class RestApiCreateOneHandler extends RestApiBaseHandler {
|
||||
async handle(request: Request) {
|
||||
const { objectMetadata, repository } =
|
||||
const { objectMetadata, repository, restrictedFields } =
|
||||
await this.getRepositoryAndMetadataOrFail(request);
|
||||
|
||||
const overriddenBody = await this.recordInputTransformerService.process({
|
||||
@ -46,6 +46,7 @@ export class RestApiCreateOneHandler extends RestApiBaseHandler {
|
||||
repository,
|
||||
objectMetadata,
|
||||
depth: this.depthInputFactory.create(request),
|
||||
restrictedFields,
|
||||
});
|
||||
|
||||
const record = records[0];
|
||||
|
||||
@ -15,10 +15,17 @@ export class RestApiDeleteOneHandler extends RestApiBaseHandler {
|
||||
throw new BadRequestException('Record ID not found');
|
||||
}
|
||||
|
||||
const { objectMetadata, repository } =
|
||||
const { objectMetadata, repository, restrictedFields } =
|
||||
await this.getRepositoryAndMetadataOrFail(request);
|
||||
|
||||
const selectOptions = this.getSelectOptionsFromRestrictedFields({
|
||||
restrictedFields,
|
||||
objectMetadata,
|
||||
});
|
||||
|
||||
const recordToDelete = await repository.findOneOrFail({
|
||||
where: { id: recordId },
|
||||
select: selectOptions,
|
||||
});
|
||||
|
||||
await repository.delete(recordId);
|
||||
|
||||
@ -17,8 +17,12 @@ export class RestApiFindDuplicatesHandler extends RestApiBaseHandler {
|
||||
async handle(request: Request) {
|
||||
this.validate(request);
|
||||
|
||||
const { repository, objectMetadata, objectMetadataItemWithFieldsMaps } =
|
||||
await this.getRepositoryAndMetadataOrFail(request);
|
||||
const {
|
||||
repository,
|
||||
objectMetadata,
|
||||
objectMetadataItemWithFieldsMaps,
|
||||
restrictedFields,
|
||||
} = await this.getRepositoryAndMetadataOrFail(request);
|
||||
|
||||
const existingRecordsQueryBuilder = repository.createQueryBuilder(
|
||||
objectMetadataItemWithFieldsMaps.nameSingular,
|
||||
@ -60,6 +64,7 @@ export class RestApiFindDuplicatesHandler extends RestApiBaseHandler {
|
||||
objectMetadata,
|
||||
objectMetadataItemWithFieldsMaps,
|
||||
extraFilters: duplicateCondition,
|
||||
restrictedFields,
|
||||
});
|
||||
|
||||
const paginatedResult = this.formatPaginatedDuplicatesResult({
|
||||
|
||||
@ -7,8 +7,12 @@ import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api
|
||||
@Injectable()
|
||||
export class RestApiFindManyHandler extends RestApiBaseHandler {
|
||||
async handle(request: Request) {
|
||||
const { repository, objectMetadata, objectMetadataItemWithFieldsMaps } =
|
||||
await this.getRepositoryAndMetadataOrFail(request);
|
||||
const {
|
||||
repository,
|
||||
objectMetadata,
|
||||
objectMetadataItemWithFieldsMaps,
|
||||
restrictedFields,
|
||||
} = await this.getRepositoryAndMetadataOrFail(request);
|
||||
|
||||
const {
|
||||
records,
|
||||
@ -22,6 +26,7 @@ export class RestApiFindManyHandler extends RestApiBaseHandler {
|
||||
repository,
|
||||
objectMetadata,
|
||||
objectMetadataItemWithFieldsMaps,
|
||||
restrictedFields,
|
||||
});
|
||||
|
||||
return this.formatPaginatedResult({
|
||||
|
||||
@ -18,8 +18,12 @@ export class RestApiFindOneHandler extends RestApiBaseHandler {
|
||||
);
|
||||
}
|
||||
|
||||
const { repository, objectMetadata, objectMetadataItemWithFieldsMaps } =
|
||||
await this.getRepositoryAndMetadataOrFail(request);
|
||||
const {
|
||||
repository,
|
||||
objectMetadata,
|
||||
objectMetadataItemWithFieldsMaps,
|
||||
restrictedFields,
|
||||
} = await this.getRepositoryAndMetadataOrFail(request);
|
||||
|
||||
const { records } = await this.findRecords({
|
||||
request,
|
||||
@ -27,6 +31,7 @@ export class RestApiFindOneHandler extends RestApiBaseHandler {
|
||||
repository,
|
||||
objectMetadata,
|
||||
objectMetadataItemWithFieldsMaps,
|
||||
restrictedFields,
|
||||
});
|
||||
|
||||
const record = records?.[0];
|
||||
|
||||
@ -20,7 +20,7 @@ export class RestApiUpdateOneHandler extends RestApiBaseHandler {
|
||||
throw new BadRequestException('Record ID not found');
|
||||
}
|
||||
|
||||
const { objectMetadata, repository } =
|
||||
const { objectMetadata, repository, restrictedFields } =
|
||||
await this.getRepositoryAndMetadataOrFail(request);
|
||||
|
||||
const recordToUpdate = await repository.findOneOrFail({
|
||||
@ -42,6 +42,7 @@ export class RestApiUpdateOneHandler extends RestApiBaseHandler {
|
||||
repository,
|
||||
objectMetadata,
|
||||
depth: this.depthInputFactory.create(request),
|
||||
restrictedFields,
|
||||
});
|
||||
|
||||
const record = records[0];
|
||||
|
||||
@ -2,7 +2,8 @@ import { BadRequestException, Inject } from '@nestjs/common';
|
||||
|
||||
import { Request } from 'express';
|
||||
import chunk from 'lodash.chunk';
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
import isEmpty from 'lodash.isempty';
|
||||
import { FieldMetadataType, RestrictedFields } from 'twenty-shared/types';
|
||||
import { capitalize, isDefined } from 'twenty-shared/utils';
|
||||
import { In, ObjectLiteral } from 'typeorm';
|
||||
|
||||
@ -25,6 +26,9 @@ import {
|
||||
import { computeCursorArgFilter } from 'src/engine/api/utils/compute-cursor-arg-filter.utils';
|
||||
import { CreatedByFromAuthContextService } from 'src/engine/core-modules/actor/services/created-by-from-auth-context.service';
|
||||
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
|
||||
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
|
||||
import { FeatureFlagService } from 'src/engine/core-modules/feature-flag/services/feature-flag.service';
|
||||
import { InternalServerError } from 'src/engine/core-modules/graphql/utils/graphql-errors.util';
|
||||
import { RecordInputTransformerService } from 'src/engine/core-modules/record-transformer/services/record-input-transformer.service';
|
||||
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
|
||||
import { ObjectMetadataMaps } from 'src/engine/metadata-modules/types/object-metadata-maps';
|
||||
@ -34,6 +38,7 @@ import { WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/wo
|
||||
import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
|
||||
import { TwentyORMManager } from 'src/engine/twenty-orm/twenty-orm.manager';
|
||||
import { formatResult as formatGetManyData } from 'src/engine/twenty-orm/utils/format-result.util';
|
||||
import { getFieldMetadataIdToColumnNamesMap } from 'src/engine/twenty-orm/utils/get-field-metadata-id-to-column-names-map.util';
|
||||
import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-of-type.util';
|
||||
|
||||
export interface PageInfo {
|
||||
@ -81,6 +86,8 @@ export abstract class RestApiBaseHandler {
|
||||
protected readonly workspacePermissionsCacheService: WorkspacePermissionsCacheService;
|
||||
@Inject()
|
||||
protected readonly createdByFromAuthContextService: CreatedByFromAuthContextService;
|
||||
@Inject()
|
||||
protected readonly featureFlagService: FeatureFlagService;
|
||||
|
||||
protected abstract handle(
|
||||
request: Request,
|
||||
@ -134,11 +141,48 @@ export abstract class RestApiBaseHandler {
|
||||
roleId,
|
||||
);
|
||||
|
||||
let restrictedFields: RestrictedFields = {};
|
||||
|
||||
if (
|
||||
await this.featureFlagService.isFeatureEnabled(
|
||||
FeatureFlagKey.IS_FIELDS_PERMISSIONS_ENABLED,
|
||||
workspace.id,
|
||||
)
|
||||
) {
|
||||
if (roleId) {
|
||||
const objectMetadataPermissions =
|
||||
await this.workspacePermissionsCacheService.getObjectRecordPermissionsForRoles(
|
||||
{
|
||||
workspaceId: workspace.id,
|
||||
roleIds: roleId ? [roleId] : undefined,
|
||||
},
|
||||
);
|
||||
|
||||
if (
|
||||
!isDefined(
|
||||
objectMetadataPermissions?.[roleId]?.[
|
||||
objectMetadata.objectMetadataMapItem.id
|
||||
]?.restrictedFields,
|
||||
)
|
||||
) {
|
||||
throw new InternalServerError(
|
||||
'Fields permissions not found for role',
|
||||
);
|
||||
}
|
||||
|
||||
restrictedFields =
|
||||
objectMetadataPermissions[roleId][
|
||||
objectMetadata.objectMetadataMapItem.id
|
||||
].restrictedFields;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
objectMetadata,
|
||||
repository,
|
||||
workspaceDataSource,
|
||||
objectMetadataItemWithFieldsMaps,
|
||||
restrictedFields,
|
||||
};
|
||||
}
|
||||
|
||||
@ -201,6 +245,7 @@ export abstract class RestApiBaseHandler {
|
||||
repository,
|
||||
objectMetadata,
|
||||
depth,
|
||||
restrictedFields,
|
||||
}: {
|
||||
recordIds: string[];
|
||||
repository: WorkspaceRepository<ObjectLiteral>;
|
||||
@ -209,6 +254,7 @@ export abstract class RestApiBaseHandler {
|
||||
objectMetadataMapItem: ObjectMetadataItemWithFieldMaps;
|
||||
};
|
||||
depth: Depth | undefined;
|
||||
restrictedFields: RestrictedFields;
|
||||
}) {
|
||||
const relations = this.getRelations({
|
||||
objectMetadata,
|
||||
@ -217,7 +263,17 @@ export abstract class RestApiBaseHandler {
|
||||
|
||||
const relationsChunk = chunk(relations, 50);
|
||||
|
||||
let selectOptions = undefined;
|
||||
|
||||
if (!isEmpty(restrictedFields)) {
|
||||
selectOptions = this.getSelectOptionsFromRestrictedFields({
|
||||
restrictedFields,
|
||||
objectMetadata,
|
||||
});
|
||||
}
|
||||
|
||||
const recordsWithoutRelations = await repository.find({
|
||||
...(selectOptions && { select: selectOptions }),
|
||||
where: { id: In(recordIds) },
|
||||
});
|
||||
|
||||
@ -227,6 +283,7 @@ export abstract class RestApiBaseHandler {
|
||||
|
||||
for (const relationChunk of relationsChunk) {
|
||||
const records = await repository.find({
|
||||
...(selectOptions && { select: selectOptions }),
|
||||
where: { id: In(recordIds) },
|
||||
relations: relationChunk,
|
||||
});
|
||||
@ -254,6 +311,36 @@ export abstract class RestApiBaseHandler {
|
||||
};
|
||||
}
|
||||
|
||||
public getSelectOptionsFromRestrictedFields({
|
||||
restrictedFields,
|
||||
objectMetadata,
|
||||
}: {
|
||||
restrictedFields: RestrictedFields;
|
||||
objectMetadata: { objectMetadataMapItem: ObjectMetadataItemWithFieldMaps };
|
||||
}) {
|
||||
const restrictedFieldsIds = Object.entries(restrictedFields)
|
||||
.filter(([_, value]) => value.canRead === false)
|
||||
.map(([key]) => key);
|
||||
|
||||
const fieldMetadataIdToColumnNamesMap = getFieldMetadataIdToColumnNamesMap(
|
||||
objectMetadata.objectMetadataMapItem,
|
||||
);
|
||||
|
||||
const restrictedFieldsColumnNames: string[] = restrictedFieldsIds
|
||||
.map((fieldId) => fieldMetadataIdToColumnNamesMap.get(fieldId))
|
||||
.filter(isDefined)
|
||||
.flat();
|
||||
|
||||
const allColumnNames = [...fieldMetadataIdToColumnNamesMap.values()].flat();
|
||||
|
||||
return Object.fromEntries(
|
||||
allColumnNames.map((columnName) => [
|
||||
columnName,
|
||||
!restrictedFieldsColumnNames.includes(columnName),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
public formatResult<T>({
|
||||
operation,
|
||||
objectNameSingular,
|
||||
@ -303,6 +390,7 @@ export abstract class RestApiBaseHandler {
|
||||
objectMetadata,
|
||||
objectMetadataItemWithFieldsMaps,
|
||||
extraFilters,
|
||||
restrictedFields,
|
||||
}: {
|
||||
request: Request;
|
||||
recordId?: string;
|
||||
@ -313,6 +401,7 @@ export abstract class RestApiBaseHandler {
|
||||
};
|
||||
objectMetadataItemWithFieldsMaps: ObjectMetadataItemWithFieldMaps;
|
||||
extraFilters?: Partial<ObjectRecordFilter>;
|
||||
restrictedFields: RestrictedFields;
|
||||
}) {
|
||||
const objectMetadataNameSingular =
|
||||
objectMetadata.objectMetadataMapItem.nameSingular;
|
||||
@ -373,6 +462,7 @@ export abstract class RestApiBaseHandler {
|
||||
repository,
|
||||
objectMetadata,
|
||||
depth: this.depthInputFactory.create(request),
|
||||
restrictedFields,
|
||||
});
|
||||
|
||||
const hasMoreRecords = records.length < totalCount;
|
||||
|
||||
@ -15,6 +15,7 @@ import { RestApiCoreService } from 'src/engine/api/rest/core/services/rest-api-c
|
||||
import { RestApiService } from 'src/engine/api/rest/rest-api.service';
|
||||
import { ActorModule } from 'src/engine/core-modules/actor/actor.module';
|
||||
import { AuthModule } from 'src/engine/core-modules/auth/auth.module';
|
||||
import { FeatureFlagModule } from 'src/engine/core-modules/feature-flag/feature-flag.module';
|
||||
import { RecordTransformerModule } from 'src/engine/core-modules/record-transformer/record-transformer.module';
|
||||
import { WorkspacePermissionsCacheModule } from 'src/engine/metadata-modules/workspace-permissions-cache/workspace-permissions-cache.module';
|
||||
import { TwentyORMModule } from 'src/engine/twenty-orm/twenty-orm.module';
|
||||
@ -40,6 +41,7 @@ const restApiCoreResolvers = [
|
||||
RecordTransformerModule,
|
||||
WorkspacePermissionsCacheModule,
|
||||
ActorModule,
|
||||
FeatureFlagModule,
|
||||
],
|
||||
controllers: [RestApiCoreController],
|
||||
providers: [
|
||||
|
||||
@ -4,6 +4,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import {
|
||||
ObjectRecordsPermissions,
|
||||
ObjectRecordsPermissionsByRoleId,
|
||||
RestrictedFields,
|
||||
} from 'twenty-shared/types';
|
||||
import { isDefined } from 'twenty-shared/utils';
|
||||
import { In, Repository } from 'typeorm';
|
||||
@ -203,10 +204,7 @@ export class WorkspacePermissionsCacheService {
|
||||
let canUpdate = role.canUpdateAllObjectRecords;
|
||||
let canSoftDelete = role.canSoftDeleteAllObjectRecords;
|
||||
let canDestroy = role.canDestroyAllObjectRecords;
|
||||
const restrictedFields: Record<
|
||||
string,
|
||||
{ canRead?: boolean | null; canUpdate?: boolean | null }
|
||||
> = {};
|
||||
const restrictedFields: RestrictedFields = {};
|
||||
|
||||
if (
|
||||
standardId &&
|
||||
|
||||
@ -0,0 +1,211 @@
|
||||
import { FieldMetadataType } from 'twenty-shared/types';
|
||||
|
||||
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
|
||||
import { getFieldMetadataIdToColumnNamesMap } from 'src/engine/twenty-orm/utils/get-field-metadata-id-to-column-names-map.util';
|
||||
|
||||
describe('getFieldMetadataIdToColumnNamesMap', () => {
|
||||
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 single column name 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 = getFieldMetadataIdToColumnNamesMap(
|
||||
objectMetadataItemWithFieldMaps,
|
||||
);
|
||||
|
||||
expect(result.get('field-1')).toEqual(['name']);
|
||||
expect(result.get('field-2')).toEqual(['age']);
|
||||
expect(result.size).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('with composite field types', () => {
|
||||
it('should return multiple column names for FULL_NAME composite type', () => {
|
||||
const fieldsById = {
|
||||
'field-1': createMockFieldMetadata(
|
||||
'field-1',
|
||||
'fullName',
|
||||
FieldMetadataType.FULL_NAME,
|
||||
),
|
||||
};
|
||||
|
||||
const objectMetadataItemWithFieldMaps =
|
||||
createMockObjectMetadataItemWithFieldMaps(fieldsById);
|
||||
|
||||
const result = getFieldMetadataIdToColumnNamesMap(
|
||||
objectMetadataItemWithFieldMaps,
|
||||
);
|
||||
|
||||
expect(result.get('field-1')).toEqual([
|
||||
'fullNameFirstName',
|
||||
'fullNameLastName',
|
||||
]);
|
||||
expect(result.size).toBe(1);
|
||||
});
|
||||
|
||||
it('should return multiple column names for CURRENCY composite type', () => {
|
||||
const fieldsById = {
|
||||
'field-1': createMockFieldMetadata(
|
||||
'field-1',
|
||||
'price',
|
||||
FieldMetadataType.CURRENCY,
|
||||
),
|
||||
};
|
||||
|
||||
const objectMetadataItemWithFieldMaps =
|
||||
createMockObjectMetadataItemWithFieldMaps(fieldsById);
|
||||
|
||||
const result = getFieldMetadataIdToColumnNamesMap(
|
||||
objectMetadataItemWithFieldMaps,
|
||||
);
|
||||
|
||||
expect(result.get('field-1')).toEqual([
|
||||
'priceAmountMicros',
|
||||
'priceCurrencyCode',
|
||||
]);
|
||||
expect(result.size).toBe(1);
|
||||
});
|
||||
|
||||
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 = getFieldMetadataIdToColumnNamesMap(
|
||||
objectMetadataItemWithFieldMaps,
|
||||
);
|
||||
|
||||
expect(result.get('field-1')).toEqual([
|
||||
'fullNameFirstName',
|
||||
'fullNameLastName',
|
||||
]);
|
||||
expect(result.get('field-2')).toEqual([
|
||||
'priceAmountMicros',
|
||||
'priceCurrencyCode',
|
||||
]);
|
||||
expect(result.get('field-3')).toEqual(['name']);
|
||||
expect(result.size).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
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 = getFieldMetadataIdToColumnNamesMap(
|
||||
objectMetadataItemWithFieldMaps,
|
||||
);
|
||||
|
||||
expect(result.get('field-1')).toEqual(['name']);
|
||||
expect(result.get('field-2')).toEqual([
|
||||
'fullNameFirstName',
|
||||
'fullNameLastName',
|
||||
]);
|
||||
expect(result.get('field-3')).toEqual(['age']);
|
||||
expect(result.get('field-4')).toEqual([
|
||||
'priceAmountMicros',
|
||||
'priceCurrencyCode',
|
||||
]);
|
||||
expect(result.size).toBe(4);
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,49 @@
|
||||
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 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}`,
|
||||
);
|
||||
}
|
||||
|
||||
compositeType.properties.forEach((compositeProperty) => {
|
||||
const columnName = computeCompositeColumnName(
|
||||
fieldMetadata.name,
|
||||
compositeProperty,
|
||||
);
|
||||
|
||||
const existingColumns =
|
||||
fieldMetadataToColumnNamesMap.get(fieldMetadataId) ?? [];
|
||||
|
||||
fieldMetadataToColumnNamesMap.set(fieldMetadataId, [
|
||||
...existingColumns,
|
||||
columnName,
|
||||
]);
|
||||
});
|
||||
} else {
|
||||
const columnName = computeColumnName(fieldMetadata);
|
||||
|
||||
fieldMetadataToColumnNamesMap.set(fieldMetadataId, [columnName]);
|
||||
}
|
||||
}
|
||||
|
||||
return fieldMetadataToColumnNamesMap;
|
||||
}
|
||||
@ -0,0 +1,26 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
export const updateFeatureFlagFactory = (
|
||||
workspaceId: string,
|
||||
featureFlag: string,
|
||||
value: boolean,
|
||||
) => ({
|
||||
query: gql`
|
||||
mutation UpdateWorkspaceFeatureFlag(
|
||||
$workspaceId: String!
|
||||
$featureFlag: String!
|
||||
$value: Boolean!
|
||||
) {
|
||||
updateWorkspaceFeatureFlag(
|
||||
workspaceId: $workspaceId
|
||||
featureFlag: $featureFlag
|
||||
value: $value
|
||||
)
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
workspaceId,
|
||||
featureFlag,
|
||||
value,
|
||||
},
|
||||
});
|
||||
@ -0,0 +1,39 @@
|
||||
import gql from 'graphql-tag';
|
||||
|
||||
export const createUpsertFieldPermissionsOperation = (
|
||||
roleId: string,
|
||||
fieldPermissions: Array<{
|
||||
objectMetadataId: string;
|
||||
fieldMetadataId: string;
|
||||
canReadFieldValue?: boolean | null;
|
||||
canUpdateFieldValue?: boolean | null;
|
||||
}>,
|
||||
selectedFields: string[] = [
|
||||
'id',
|
||||
'roleId',
|
||||
'objectMetadataId',
|
||||
'fieldMetadataId',
|
||||
'canReadFieldValue',
|
||||
'canUpdateFieldValue',
|
||||
],
|
||||
) => ({
|
||||
query: gql`
|
||||
mutation UpsertFieldPermissions(
|
||||
$roleId: String!
|
||||
$fieldPermissions: [FieldPermissionInput!]!
|
||||
) {
|
||||
upsertFieldPermissions(
|
||||
upsertFieldPermissionsInput: {
|
||||
roleId: $roleId
|
||||
fieldPermissions: $fieldPermissions
|
||||
}
|
||||
) {
|
||||
${selectedFields.join('\n')}
|
||||
}
|
||||
}
|
||||
`,
|
||||
variables: {
|
||||
roleId,
|
||||
fieldPermissions,
|
||||
},
|
||||
});
|
||||
@ -0,0 +1,30 @@
|
||||
import { createUpsertFieldPermissionsOperation } from 'test/integration/graphql/utils/upsert-field-permissions-operation-factory.util';
|
||||
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
|
||||
|
||||
export const upsertFieldPermissions = async ({
|
||||
roleId,
|
||||
fieldPermissions,
|
||||
selectedFields,
|
||||
}: {
|
||||
roleId: string;
|
||||
fieldPermissions: Array<{
|
||||
objectMetadataId: string;
|
||||
fieldMetadataId: string;
|
||||
canReadFieldValue?: boolean | null;
|
||||
canUpdateFieldValue?: boolean | null;
|
||||
}>;
|
||||
selectedFields?: string[];
|
||||
}) => {
|
||||
const operation = createUpsertFieldPermissionsOperation(
|
||||
roleId,
|
||||
fieldPermissions,
|
||||
selectedFields,
|
||||
);
|
||||
|
||||
const response = await makeMetadataAPIRequest(operation);
|
||||
|
||||
return {
|
||||
data: response.body.data,
|
||||
errors: response.body.errors,
|
||||
};
|
||||
};
|
||||
@ -0,0 +1,174 @@
|
||||
import gql from 'graphql-tag';
|
||||
import { TEST_PERSON_1_ID } from 'test/integration/constants/test-person-ids.constants';
|
||||
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';
|
||||
import { makeMetadataAPIRequest } from 'test/integration/metadata/suites/utils/make-metadata-api-request.util';
|
||||
import { makeRestAPIRequest } from 'test/integration/rest/utils/make-rest-api-request.util';
|
||||
import { generateRecordName } from 'test/integration/utils/generate-record-name';
|
||||
|
||||
import { SEED_APPLE_WORKSPACE_ID } from 'src/engine/workspace-manager/dev-seeder/core/utils/seed-workspaces.util';
|
||||
|
||||
describe('Restricted fields', () => {
|
||||
let personCity: string;
|
||||
let adminRoleId: string;
|
||||
let personObjectId: string;
|
||||
let emailsFieldId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
personCity = generateRecordName(TEST_PERSON_1_ID);
|
||||
|
||||
await makeRestAPIRequest({
|
||||
method: 'post',
|
||||
path: '/people',
|
||||
body: {
|
||||
id: TEST_PERSON_1_ID,
|
||||
city: personCity,
|
||||
emails: {
|
||||
primaryEmail: 'test@test.com',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Get object metadata IDs for Person and Company
|
||||
const getObjectMetadataOperation = {
|
||||
query: gql`
|
||||
query {
|
||||
objects(paging: { first: 1000 }) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
nameSingular
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
const objectMetadataResponse = await makeMetadataAPIRequest(
|
||||
getObjectMetadataOperation,
|
||||
);
|
||||
const objects = objectMetadataResponse.body.data.objects.edges;
|
||||
|
||||
personObjectId = objects.find(
|
||||
(obj: any) => obj.node.nameSingular === 'person',
|
||||
)?.node.id;
|
||||
|
||||
// Get field metadata ID for email field
|
||||
const getFieldMetadataOperation = {
|
||||
query: gql`
|
||||
query {
|
||||
fields(paging: { first: 1000 }) {
|
||||
edges {
|
||||
node {
|
||||
id
|
||||
name
|
||||
object {
|
||||
nameSingular
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
const fieldMetadataResponse = await makeMetadataAPIRequest(
|
||||
getFieldMetadataOperation,
|
||||
);
|
||||
const fields = fieldMetadataResponse.body.data.fields.edges;
|
||||
|
||||
emailsFieldId = fields.find(
|
||||
(field: any) =>
|
||||
field.node.name === 'emails' &&
|
||||
field.node.object.nameSingular === 'person',
|
||||
).node.id;
|
||||
|
||||
// Get admin role ID
|
||||
const getRolesOperation = {
|
||||
query: gql`
|
||||
query {
|
||||
getRoles {
|
||||
id
|
||||
label
|
||||
}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
const rolesResponse = await makeMetadataAPIRequest(getRolesOperation);
|
||||
|
||||
adminRoleId = rolesResponse.body.data.getRoles.find(
|
||||
(role: any) => role.label === 'Member',
|
||||
)?.id;
|
||||
|
||||
// Create field permission restricting read access to email field
|
||||
await upsertFieldPermissions({
|
||||
roleId: adminRoleId,
|
||||
fieldPermissions: [
|
||||
{
|
||||
objectMetadataId: personObjectId,
|
||||
fieldMetadataId: emailsFieldId,
|
||||
canReadFieldValue: false,
|
||||
canUpdateFieldValue: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
describe('With Feature flag enabled', () => {
|
||||
beforeAll(async () => {
|
||||
const enablePermissionsQuery = updateFeatureFlagFactory(
|
||||
SEED_APPLE_WORKSPACE_ID,
|
||||
'IS_FIELDS_PERMISSIONS_ENABLED',
|
||||
true,
|
||||
);
|
||||
|
||||
await makeGraphqlAPIRequest(enablePermissionsQuery);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
const disablePermissionsQuery = updateFeatureFlagFactory(
|
||||
SEED_APPLE_WORKSPACE_ID,
|
||||
'IS_FIELDS_PERMISSIONS_ENABLED',
|
||||
false,
|
||||
);
|
||||
|
||||
await makeGraphqlAPIRequest(disablePermissionsQuery);
|
||||
});
|
||||
it('should hide fields when user has restricted read permissions', async () => {
|
||||
await makeRestAPIRequest({
|
||||
method: 'get',
|
||||
path: `/people/${TEST_PERSON_1_ID}`,
|
||||
bearer: APPLE_JONY_MEMBER_ACCESS_TOKEN,
|
||||
})
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
const person = res.body.data.person;
|
||||
|
||||
expect(person).toBeDefined();
|
||||
expect(person.id).toBeDefined();
|
||||
expect(person.emails).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('With feature flag disabled', () => {
|
||||
it('should query all fields despite field permission restriction', async () => {
|
||||
await makeRestAPIRequest({
|
||||
method: 'get',
|
||||
path: `/people/${TEST_PERSON_1_ID}`,
|
||||
bearer: APPLE_JONY_MEMBER_ACCESS_TOKEN,
|
||||
})
|
||||
.expect(200)
|
||||
.expect((res) => {
|
||||
const person = res.body.data.person;
|
||||
|
||||
expect(person).toBeDefined();
|
||||
expect(person.id).toBeDefined();
|
||||
expect(person.emails).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user