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