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