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:
Marie
2025-07-22 10:46:43 +02:00
committed by GitHub
parent f95573ab4c
commit c8753ae59e
81 changed files with 847 additions and 47 deletions

View File

@ -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) {

View File

@ -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];

View File

@ -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);

View File

@ -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({

View File

@ -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({

View File

@ -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];

View File

@ -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];

View File

@ -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;

View File

@ -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: [

View File

@ -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 &&

View File

@ -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);
});
});
});

View File

@ -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;
}

View File

@ -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,
},
});

View File

@ -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,
},
});

View File

@ -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,
};
};

View File

@ -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();
});
});
});
});