[permissions] Adapt field permissions to connect + createMany (#13655)

In this PR we adapt field permission to two things

1. Connect: the recent insertion of the connect feature introduces the
possibility to have "connect" objects in typeORM's expressionMap's
valueSet, where we previously only had column names (if i followed
correctly). For instance for a person object that a N - 1 relationship
to company, person will have both companyId and company as possible
valueSet keys for the upsert. We need to reflect that in our
`getColumnNameToFieldMetadataIdMap` util that returns a map containing
every possible value we could encounter in valueSet. In an attempt to
tie this to the schema generation where this is introduced, I created
the shallow util extractGraphQLRelationFieldNames (probably ill-named -
im willing to update the name if @etiennejouan has a better idea?) to
remind us that these two are linked.

2. CreateMany: When calling query builders methods directly on custom
object (like we do in graphql-create-many-resolver), we need to be
careful to call them with a selection of readable fields. We had a
debate on whether we should compute this selection containing all
readable fields by default under the hood when no selected fields are
indicated. I am still not 100% convinced as I think it should remain the
caller's responsibility, but this case reminds us that it could easily
be forgotten by developers - although it is all the more the case as we
don't have seeds yet that help us realize that ([PR on the
way](https://github.com/twentyhq/twenty/pull/13646)).
This commit is contained in:
Marie
2025-08-06 10:31:52 +02:00
committed by GitHub
parent a804d65210
commit 9957057d1f
12 changed files with 114 additions and 60 deletions
@@ -20,6 +20,7 @@ import { ObjectRecordsToGraphqlConnectionHelper } from 'src/engine/api/graphql/g
import { buildColumnsToReturn } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-return';
import { buildColumnsToSelect } from 'src/engine/api/graphql/graphql-query-runner/utils/build-columns-to-select';
import { assertIsValidUuid } from 'src/engine/api/graphql/workspace-query-runner/utils/assert-is-valid-uuid.util';
import { getAllSelectableFields } from 'src/engine/api/utils/get-all-selectable-fields.utils';
import { compositeTypeDefinitions } from 'src/engine/metadata-modules/field-metadata/composite-types';
import { assertMutationNotOnRemoteObject } from 'src/engine/metadata-modules/object-metadata/utils/assert-mutation-not-on-remote-object.util';
import { ObjectMetadataItemWithFieldMaps } from 'src/engine/metadata-modules/types/object-metadata-item-with-field-maps';
@@ -195,7 +196,24 @@ export class GraphqlQueryCreateManyResolverService extends GraphqlQueryBaseResol
queryBuilder.orWhere(condition);
});
return await queryBuilder.withDeleted().getMany();
const restrictedFields =
executionArgs.repository.objectRecordsPermissions?.[
objectMetadataItemWithFieldMaps.id
]?.restrictedFields;
const selectOptions = getAllSelectableFields({
restrictedFields: restrictedFields ?? {},
objectMetadata: {
objectMetadataMapItem: objectMetadataItemWithFieldMaps,
},
});
return await queryBuilder
.withDeleted()
.setFindOptions({
select: selectOptions,
})
.getMany();
}
private getValueFromPath(
@@ -0,0 +1,19 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
export const extractGraphQLRelationFieldNames = (
fieldMetadata: FieldMetadataEntity<
FieldMetadataType.RELATION | FieldMetadataType.MORPH_RELATION
>,
) => {
const joinColumnName = fieldMetadata.settings?.joinColumnName;
if (!joinColumnName) {
throw new Error('Join column name is not defined');
}
const fieldMetadataName = fieldMetadata.name;
return { joinColumnName, fieldMetadataName };
};
@@ -13,6 +13,7 @@ import { RelationType } from 'src/engine/metadata-modules/field-metadata/interfa
import { InputTypeDefinitionKind } from 'src/engine/api/graphql/workspace-schema-builder/factories/input-type-definition.factory';
import { ObjectTypeDefinitionKind } from 'src/engine/api/graphql/workspace-schema-builder/factories/object-type-definition.factory';
import { formatRelationConnectInputTarget } from 'src/engine/api/graphql/workspace-schema-builder/factories/relation-connect-input-type-definition.factory';
import { extractGraphQLRelationFieldNames } from 'src/engine/api/graphql/workspace-schema-builder/utils/extract-graphql-relation-field-names.util';
import { isFieldMetadataRelationOrMorphRelation } from 'src/engine/api/graphql/workspace-schema-builder/utils/is-field-metadata-relation-or-morph-relation.utils';
import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/field-metadata.entity';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.util';
@@ -172,11 +173,8 @@ const generateRelationField = <
return relationField;
}
const joinColumnName = fieldMetadata.settings?.joinColumnName;
if (!joinColumnName) {
throw new Error('Join column name is not defined');
}
const { joinColumnName, fieldMetadataName } =
extractGraphQLRelationFieldNames(fieldMetadata);
const target = getTarget(fieldMetadata);
const typeFactoryOptions = getTypeFactoryOptions(fieldMetadata, kind);
@@ -220,7 +218,7 @@ const generateRelationField = <
}
// @ts-expect-error legacy noImplicitAny
relationField[fieldMetadata.name] = {
relationField[fieldMetadataName] = {
type: type,
description: fieldMetadata.description,
};
@@ -5,7 +5,11 @@ import { isFieldMetadataEntityOfType } from 'src/engine/utils/is-field-metadata-
export const isFieldMetadataRelationOrMorphRelation = (
fieldMetadata: FieldMetadataEntity<FieldMetadataType>,
) => {
): fieldMetadata is FieldMetadataEntity &
(
| FieldMetadataEntity<FieldMetadataType.RELATION>
| FieldMetadataEntity<FieldMetadataType.MORPH_RELATION>
) => {
return (
isFieldMetadataEntityOfType(fieldMetadata, FieldMetadataType.RELATION) ||
isFieldMetadataEntityOfType(fieldMetadata, FieldMetadataType.MORPH_RELATION)
@@ -5,6 +5,7 @@ import { Request } from 'express';
import { RestApiBaseHandler } from 'src/engine/api/rest/core/interfaces/rest-api-base.handler';
import { parseCorePath } from 'src/engine/api/rest/core/query-builder/utils/path-parsers/parse-core-path.utils';
import { getAllSelectableFields } from 'src/engine/api/utils/get-all-selectable-fields.utils';
@Injectable()
export class RestApiDeleteOneHandler extends RestApiBaseHandler {
@@ -18,7 +19,7 @@ export class RestApiDeleteOneHandler extends RestApiBaseHandler {
const { objectMetadata, repository, restrictedFields } =
await this.getRepositoryAndMetadataOrFail(request);
const selectOptions = this.getAllSelectableFields({
const selectOptions = getAllSelectableFields({
restrictedFields,
objectMetadata,
});
@@ -24,6 +24,7 @@ import {
MAX_DEPTH,
} from 'src/engine/api/rest/input-factories/depth-input.factory';
import { computeCursorArgFilter } from 'src/engine/api/utils/compute-cursor-arg-filter.utils';
import { getAllSelectableFields } from 'src/engine/api/utils/get-all-selectable-fields.utils';
import { CreatedByFromAuthContextService } from 'src/engine/core-modules/actor/services/created-by-from-auth-context.service';
import { ApiKeyRoleService } from 'src/engine/core-modules/api-key/api-key-role.service';
import { AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
@@ -44,7 +45,6 @@ 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 {
@@ -310,7 +310,7 @@ export abstract class RestApiBaseHandler {
let selectOptions = undefined;
if (!isEmpty(restrictedFields)) {
selectOptions = this.getAllSelectableFields({
selectOptions = getAllSelectableFields({
restrictedFields,
objectMetadata,
});
@@ -355,36 +355,6 @@ export abstract class RestApiBaseHandler {
};
}
public getAllSelectableFields({
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,
@@ -0,0 +1,37 @@
import { RestrictedFields } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
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';
export const getAllSelectableFields = ({
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();
const restrictedFieldsColumnNamesSet = new Set(restrictedFieldsColumnNames);
return Object.fromEntries(
allColumnNames.map((columnName) => [
columnName,
!restrictedFieldsColumnNamesSet.has(columnName),
]),
);
};