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

@ -3579,6 +3579,11 @@ msgstr ""
msgid "john.doe@example.com"
msgstr ""
#. js-lingui-id: fSPjtl
#: src/pages/auth/SignInUp.tsx
msgid "Join {workspaceName} team"
msgstr ""
#. js-lingui-id: VIK/N0
#: src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx
#~ msgid "JSON keys cannot contain spaces"

View File

@ -3579,6 +3579,11 @@ msgstr ""
msgid "john.doe@example.com"
msgstr ""
#. js-lingui-id: fSPjtl
#: src/pages/auth/SignInUp.tsx
msgid "Join {workspaceName} team"
msgstr ""
#. js-lingui-id: VIK/N0
#: src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx
#~ msgid "JSON keys cannot contain spaces"

View File

@ -3579,6 +3579,11 @@ msgstr ""
msgid "john.doe@example.com"
msgstr ""
#. js-lingui-id: fSPjtl
#: src/pages/auth/SignInUp.tsx
msgid "Join {workspaceName} team"
msgstr ""
#. js-lingui-id: VIK/N0
#: src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx
#~ msgid "JSON keys cannot contain spaces"

View File

@ -3579,6 +3579,11 @@ msgstr ""
msgid "john.doe@example.com"
msgstr ""
#. js-lingui-id: fSPjtl
#: src/pages/auth/SignInUp.tsx
msgid "Join {workspaceName} team"
msgstr ""
#. js-lingui-id: VIK/N0
#: src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx
#~ msgid "JSON keys cannot contain spaces"

View File

@ -3579,6 +3579,11 @@ msgstr ""
msgid "john.doe@example.com"
msgstr ""
#. js-lingui-id: fSPjtl
#: src/pages/auth/SignInUp.tsx
msgid "Join {workspaceName} team"
msgstr ""
#. js-lingui-id: VIK/N0
#: src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx
#~ msgid "JSON keys cannot contain spaces"

View File

@ -3579,6 +3579,11 @@ msgstr ""
msgid "john.doe@example.com"
msgstr ""
#. js-lingui-id: fSPjtl
#: src/pages/auth/SignInUp.tsx
msgid "Join {workspaceName} team"
msgstr ""
#. js-lingui-id: VIK/N0
#: src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx
#~ msgid "JSON keys cannot contain spaces"

View File

@ -3579,6 +3579,11 @@ msgstr ""
msgid "john.doe@example.com"
msgstr ""
#. js-lingui-id: fSPjtl
#: src/pages/auth/SignInUp.tsx
msgid "Join {workspaceName} team"
msgstr ""
#. js-lingui-id: VIK/N0
#: src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx
#~ msgid "JSON keys cannot contain spaces"

View File

@ -3574,6 +3574,11 @@ msgstr "john.doe"
msgid "john.doe@example.com"
msgstr "john.doe@example.com"
#. js-lingui-id: fSPjtl
#: src/pages/auth/SignInUp.tsx
msgid "Join {workspaceName} team"
msgstr "Join {workspaceName} team"
#. js-lingui-id: VIK/N0
#: src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx
#~ msgid "JSON keys cannot contain spaces"

View File

@ -3579,6 +3579,11 @@ msgstr ""
msgid "john.doe@example.com"
msgstr ""
#. js-lingui-id: fSPjtl
#: src/pages/auth/SignInUp.tsx
msgid "Join {workspaceName} team"
msgstr ""
#. js-lingui-id: VIK/N0
#: src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx
#~ msgid "JSON keys cannot contain spaces"

View File

@ -3579,6 +3579,11 @@ msgstr ""
msgid "john.doe@example.com"
msgstr ""
#. js-lingui-id: fSPjtl
#: src/pages/auth/SignInUp.tsx
msgid "Join {workspaceName} team"
msgstr ""
#. js-lingui-id: VIK/N0
#: src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx
#~ msgid "JSON keys cannot contain spaces"

View File

@ -3579,6 +3579,11 @@ msgstr ""
msgid "john.doe@example.com"
msgstr ""
#. js-lingui-id: fSPjtl
#: src/pages/auth/SignInUp.tsx
msgid "Join {workspaceName} team"
msgstr ""
#. js-lingui-id: VIK/N0
#: src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx
#~ msgid "JSON keys cannot contain spaces"

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -3579,6 +3579,11 @@ msgstr ""
msgid "john.doe@example.com"
msgstr ""
#. js-lingui-id: fSPjtl
#: src/pages/auth/SignInUp.tsx
msgid "Join {workspaceName} team"
msgstr ""
#. js-lingui-id: VIK/N0
#: src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx
#~ msgid "JSON keys cannot contain spaces"

View File

@ -3579,6 +3579,11 @@ msgstr ""
msgid "john.doe@example.com"
msgstr ""
#. js-lingui-id: fSPjtl
#: src/pages/auth/SignInUp.tsx
msgid "Join {workspaceName} team"
msgstr ""
#. js-lingui-id: VIK/N0
#: src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx
#~ msgid "JSON keys cannot contain spaces"

View File

@ -3579,6 +3579,11 @@ msgstr ""
msgid "john.doe@example.com"
msgstr ""
#. js-lingui-id: fSPjtl
#: src/pages/auth/SignInUp.tsx
msgid "Join {workspaceName} team"
msgstr ""
#. js-lingui-id: VIK/N0
#: src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx
#~ msgid "JSON keys cannot contain spaces"

View File

@ -3579,6 +3579,11 @@ msgstr ""
msgid "john.doe@example.com"
msgstr ""
#. js-lingui-id: fSPjtl
#: src/pages/auth/SignInUp.tsx
msgid "Join {workspaceName} team"
msgstr ""
#. js-lingui-id: VIK/N0
#: src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx
#~ msgid "JSON keys cannot contain spaces"

View File

@ -3579,6 +3579,11 @@ msgstr ""
msgid "john.doe@example.com"
msgstr ""
#. js-lingui-id: fSPjtl
#: src/pages/auth/SignInUp.tsx
msgid "Join {workspaceName} team"
msgstr ""
#. js-lingui-id: VIK/N0
#: src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx
#~ msgid "JSON keys cannot contain spaces"

View File

@ -3579,6 +3579,11 @@ msgstr ""
msgid "john.doe@example.com"
msgstr ""
#. js-lingui-id: fSPjtl
#: src/pages/auth/SignInUp.tsx
msgid "Join {workspaceName} team"
msgstr ""
#. js-lingui-id: VIK/N0
#: src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx
#~ msgid "JSON keys cannot contain spaces"

View File

@ -3579,6 +3579,11 @@ msgstr ""
msgid "john.doe@example.com"
msgstr ""
#. js-lingui-id: fSPjtl
#: src/pages/auth/SignInUp.tsx
msgid "Join {workspaceName} team"
msgstr ""
#. js-lingui-id: VIK/N0
#: src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx
#~ msgid "JSON keys cannot contain spaces"

View File

@ -3579,6 +3579,11 @@ msgstr ""
msgid "john.doe@example.com"
msgstr ""
#. js-lingui-id: fSPjtl
#: src/pages/auth/SignInUp.tsx
msgid "Join {workspaceName} team"
msgstr ""
#. js-lingui-id: VIK/N0
#: src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx
#~ msgid "JSON keys cannot contain spaces"

View File

@ -3571,6 +3571,11 @@ msgstr ""
msgid "john.doe@example.com"
msgstr ""
#. js-lingui-id: fSPjtl
#: src/pages/auth/SignInUp.tsx
msgid "Join {workspaceName} team"
msgstr ""
#. js-lingui-id: VIK/N0
#: src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx
#~ msgid "JSON keys cannot contain spaces"

View File

@ -3579,6 +3579,11 @@ msgstr ""
msgid "john.doe@example.com"
msgstr ""
#. js-lingui-id: fSPjtl
#: src/pages/auth/SignInUp.tsx
msgid "Join {workspaceName} team"
msgstr ""
#. js-lingui-id: VIK/N0
#: src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx
#~ msgid "JSON keys cannot contain spaces"

View File

@ -3579,6 +3579,11 @@ msgstr ""
msgid "john.doe@example.com"
msgstr ""
#. js-lingui-id: fSPjtl
#: src/pages/auth/SignInUp.tsx
msgid "Join {workspaceName} team"
msgstr ""
#. js-lingui-id: VIK/N0
#: src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx
#~ msgid "JSON keys cannot contain spaces"

View File

@ -3579,6 +3579,11 @@ msgstr ""
msgid "john.doe@example.com"
msgstr ""
#. js-lingui-id: fSPjtl
#: src/pages/auth/SignInUp.tsx
msgid "Join {workspaceName} team"
msgstr ""
#. js-lingui-id: VIK/N0
#: src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx
#~ msgid "JSON keys cannot contain spaces"

View File

@ -3579,6 +3579,11 @@ msgstr ""
msgid "john.doe@example.com"
msgstr ""
#. js-lingui-id: fSPjtl
#: src/pages/auth/SignInUp.tsx
msgid "Join {workspaceName} team"
msgstr ""
#. js-lingui-id: VIK/N0
#: src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx
#~ msgid "JSON keys cannot contain spaces"

View File

@ -3579,6 +3579,11 @@ msgstr ""
msgid "john.doe@example.com"
msgstr ""
#. js-lingui-id: fSPjtl
#: src/pages/auth/SignInUp.tsx
msgid "Join {workspaceName} team"
msgstr ""
#. js-lingui-id: VIK/N0
#: src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx
#~ msgid "JSON keys cannot contain spaces"

View File

@ -3579,6 +3579,11 @@ msgstr ""
msgid "john.doe@example.com"
msgstr ""
#. js-lingui-id: fSPjtl
#: src/pages/auth/SignInUp.tsx
msgid "Join {workspaceName} team"
msgstr ""
#. js-lingui-id: VIK/N0
#: src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx
#~ msgid "JSON keys cannot contain spaces"

View File

@ -3579,6 +3579,11 @@ msgstr ""
msgid "john.doe@example.com"
msgstr ""
#. js-lingui-id: fSPjtl
#: src/pages/auth/SignInUp.tsx
msgid "Join {workspaceName} team"
msgstr ""
#. js-lingui-id: VIK/N0
#: src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx
#~ msgid "JSON keys cannot contain spaces"

View File

@ -3579,6 +3579,11 @@ msgstr ""
msgid "john.doe@example.com"
msgstr ""
#. js-lingui-id: fSPjtl
#: src/pages/auth/SignInUp.tsx
msgid "Join {workspaceName} team"
msgstr ""
#. js-lingui-id: VIK/N0
#: src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx
#~ msgid "JSON keys cannot contain spaces"

View File

@ -3579,6 +3579,11 @@ msgstr ""
msgid "john.doe@example.com"
msgstr ""
#. js-lingui-id: fSPjtl
#: src/pages/auth/SignInUp.tsx
msgid "Join {workspaceName} team"
msgstr ""
#. js-lingui-id: VIK/N0
#: src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx
#~ msgid "JSON keys cannot contain spaces"

View File

@ -3579,6 +3579,11 @@ msgstr ""
msgid "john.doe@example.com"
msgstr ""
#. js-lingui-id: fSPjtl
#: src/pages/auth/SignInUp.tsx
msgid "Join {workspaceName} team"
msgstr ""
#. js-lingui-id: VIK/N0
#: src/modules/workflow/workflow-trigger/components/WorkflowEditTriggerWebhookForm.tsx
#~ msgid "JSON keys cannot contain spaces"

View File

@ -83,7 +83,8 @@ export const SignInUp = () => {
const title = useMemo(() => {
if (isDefined(workspaceInviteHash)) {
return `Join ${workspaceFromInviteHash?.displayName ?? ''} team`;
const workspaceName = workspaceFromInviteHash?.displayName ?? '';
return t`Join ${workspaceName} team`;
}
if (signInUpStep === SignInUpStep.WorkspaceSelection) {

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

View File

@ -0,0 +1,4 @@
export type RestrictedFields = Record<
string,
{ canRead?: boolean | null; canUpdate?: boolean | null }
>;

View File

@ -13,6 +13,7 @@ export { FieldMetadataType } from './FieldMetadataType';
export type { IsExactly } from './IsExactly';
export type { ObjectRecordsPermissions } from './ObjectRecordsPermissions';
export type { ObjectRecordsPermissionsByRoleId } from './ObjectRecordsPermissionsByRoleId';
export type { RestrictedFields } from './RestrictedFields';
export type { StepFilterGroup, StepFilter } from './StepFilters';
export { StepLogicalOperator } from './StepFilters';
export { ViewFilterOperand } from './ViewFilterOperand';