From b64951634e99325e174dfd300e737ffe58c89603 Mon Sep 17 00:00:00 2001 From: Weiko Date: Tue, 20 Jan 2026 06:36:38 +0100 Subject: [PATCH] fix rls create new record with composite (#17243) ## Context buildValueFromFilter was not handling composite filters which was needed for RLS. This PR implements that and fix some issues with RLS Tested with a few composite + relation fields Screenshot 2026-01-19 at 15 38 42 --- .../useBuildRecordInputFromRLSPredicates.ts | 146 ++++-- .../hooks/useCreateNewIndexRecord.ts | 10 +- .../utils/buildRecordInputFromFilter.ts | 19 +- .../utils/buildValueFromFilter.ts | 457 +++++++++++++----- ...gsRolePermissionsObjectLevelObjectForm.tsx | 31 +- ...ordLevelPermissionFieldSelectFieldMenu.tsx | 18 +- ...evelRecordLevelPermissionMeValueSelect.tsx | 7 +- ...ecordLevelPermissionPredicateFieldTypes.ts | 17 + .../useRecordLevelPermissionFilterActions.ts | 41 +- ...ct-records-to-graphql-connection.helper.ts | 3 +- .../workspace-insert-query-builder.ts | 9 +- .../workspace-update-query-builder.ts | 11 +- ...ow-level-permission-predicate.util.spec.ts | 281 +++++++++++ ...rls-row-level-permission-predicate.util.ts | 50 +- ...alidate-rls-predicates-for-records.util.ts | 3 +- packages/twenty-shared/src/utils/index.ts | 1 + .../src/utils/typeguard/isPlainObject.ts | 4 + 17 files changed, 906 insertions(+), 202 deletions(-) create mode 100644 packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/constants/RecordLevelPermissionPredicateFieldTypes.ts create mode 100644 packages/twenty-server/src/engine/twenty-orm/utils/__tests__/is-record-matching-rls-row-level-permission-predicate.util.spec.ts create mode 100644 packages/twenty-shared/src/utils/typeguard/isPlainObject.ts diff --git a/packages/twenty-front/src/modules/object-record/hooks/useBuildRecordInputFromRLSPredicates.ts b/packages/twenty-front/src/modules/object-record/hooks/useBuildRecordInputFromRLSPredicates.ts index 82439c7d15..5fc95b3c08 100644 --- a/packages/twenty-front/src/modules/object-record/hooks/useBuildRecordInputFromRLSPredicates.ts +++ b/packages/twenty-front/src/modules/object-record/hooks/useBuildRecordInputFromRLSPredicates.ts @@ -7,12 +7,22 @@ import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSi import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject'; import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord'; import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions'; +import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType'; import { buildRecordInputFromFilter } from '@/object-record/record-table/utils/buildRecordInputFromFilter'; +import { buildCompositeValueFromSubField } from '@/object-record/record-table/utils/buildValueFromFilter'; import { type ObjectRecord } from '@/object-record/types/ObjectRecord'; import { isUndefined } from '@sniptt/guards'; import { useRecoilValue } from 'recoil'; import { RelationType } from 'twenty-shared/types'; -import { isDefined } from 'twenty-shared/utils'; +import { isDefined, isPlainObject } from 'twenty-shared/utils'; + +const mergeCompositeValues = ( + existingValue: unknown, + incomingValue: unknown, +) => + isPlainObject(existingValue) && isPlainObject(incomingValue) + ? { ...existingValue, ...incomingValue } + : incomingValue; export const useBuildRecordInputFromRLSPredicates = ({ objectMetadataItem, @@ -37,9 +47,60 @@ export const useBuildRecordInputFromRLSPredicates = ({ objectMetadataItem.id, ); - const buildRecordInputFromRLSPredicates = (): Partial => { - const recordInput: Partial = {}; + const getRecordInputFieldName = (fieldMetadataItem: { + name: string; + type: string; + settings?: { relationType?: RelationType }; + }) => + fieldMetadataItem.type === 'RELATION' && + fieldMetadataItem.settings?.relationType === RelationType.MANY_TO_ONE + ? `${fieldMetadataItem.name}Id` + : fieldMetadataItem.name; + const getWorkspaceMemberFieldValue = ({ + workspaceMemberFieldMetadataId, + workspaceMemberSubFieldName, + }: { + workspaceMemberFieldMetadataId?: string | null; + workspaceMemberSubFieldName?: string | null; + }) => { + const workspaceMemberFieldMetadataItem = + workspaceMemberObjectMetadataItem?.fields.find( + (field) => field.id === workspaceMemberFieldMetadataId, + ); + + if (!isDefined(workspaceMemberFieldMetadataItem)) { + throw new Error( + `Workspace member field metadata item not found for id: ${workspaceMemberFieldMetadataId}`, + ); + } + + let workspaceMemberFieldValue = + currentWorkspaceMemberRecord?.[workspaceMemberFieldMetadataItem.name]; + + if (isCompositeFieldType(workspaceMemberFieldMetadataItem.type)) { + if (!workspaceMemberSubFieldName) { + throw new Error( + `Workspace member subfield name not found for field: ${workspaceMemberFieldMetadataItem.name}`, + ); + } + + const compositeValue = workspaceMemberFieldValue as + | Record + | undefined; + workspaceMemberFieldValue = compositeValue?.[workspaceMemberSubFieldName]; + } + + if (isUndefined(workspaceMemberFieldValue)) { + throw new Error( + `Current workspace member field value not found for field: ${workspaceMemberFieldMetadataItem.name}`, + ); + } + + return workspaceMemberFieldValue; + }; + + const buildRecordInputFromRLSPredicates = (): Partial => { const rlsPredicates = objectPermissions.rowLevelPermissionPredicates.filter( (predicate) => predicate.objectMetadataId === objectMetadataItem.id, ); @@ -57,6 +118,8 @@ export const useBuildRecordInputFromRLSPredicates = ({ ) .filter(isDefined); + const recordInputFromDynamicFilters: Partial = {}; + rlsPredicatesAsRecordFilters.forEach((filter) => { const fieldMetadataItem = fieldMetadataItemMap.get( filter.fieldMetadataId, @@ -67,35 +130,42 @@ export const useBuildRecordInputFromRLSPredicates = ({ } if (isDefined(filter.rlsDynamicValue)) { - const workspaceMemberFieldMetadataItem = - workspaceMemberObjectMetadataItem?.fields.find( - (field) => - field.id === - filter.rlsDynamicValue?.workspaceMemberFieldMetadataId, - ); + const recordInputField = getRecordInputFieldName(fieldMetadataItem); + const currentWorkspaceMemberFieldValue = getWorkspaceMemberFieldValue({ + workspaceMemberFieldMetadataId: + filter.rlsDynamicValue?.workspaceMemberFieldMetadataId, + workspaceMemberSubFieldName: + filter.rlsDynamicValue?.workspaceMemberSubFieldName, + }); - if (!isDefined(workspaceMemberFieldMetadataItem)) { - throw new Error( - `Workspace member field metadata item not found for id: ${filter.rlsDynamicValue?.workspaceMemberFieldMetadataId}`, - ); + if (isCompositeFieldType(fieldMetadataItem.type)) { + if (!filter.subFieldName) { + throw new Error( + `Subfield name not found for composite field: ${fieldMetadataItem.name}`, + ); + } + + const compositeValue = buildCompositeValueFromSubField({ + compositeFieldType: fieldMetadataItem.type, + subFieldName: filter.subFieldName, + value: currentWorkspaceMemberFieldValue, + }); + + if (!compositeValue) { + throw new Error( + `Composite subfield not found for field: ${fieldMetadataItem.name}`, + ); + } + + recordInputFromDynamicFilters[recordInputField] = + mergeCompositeValues( + recordInputFromDynamicFilters[recordInputField], + compositeValue, + ); + } else { + recordInputFromDynamicFilters[recordInputField] = + currentWorkspaceMemberFieldValue; } - - const recordInputField = - fieldMetadataItem.type === 'RELATION' && - fieldMetadataItem.settings?.relationType === RelationType.MANY_TO_ONE - ? `${fieldMetadataItem.name}Id` - : fieldMetadataItem.name; - - const currentWorkspaceMemberFieldValue = - currentWorkspaceMemberRecord?.[workspaceMemberFieldMetadataItem.name]; - - if (isUndefined(currentWorkspaceMemberFieldValue)) { - throw new Error( - `Current workspace member field value not found for field: ${workspaceMemberFieldMetadataItem.name}`, - ); - } - - recordInput[recordInputField] = currentWorkspaceMemberFieldValue; } }); @@ -105,16 +175,24 @@ export const useBuildRecordInputFromRLSPredicates = ({ (filter) => !isDefined(filter.rlsDynamicValue), ); - const recordInputFromFilters = buildRecordInputFromFilter({ + const recordInputFromStaticFilters = buildRecordInputFromFilter({ currentRecordFilters: staticFilters, objectMetadataItem, currentWorkspaceMember: currentWorkspaceMember ?? undefined, }); - return { - ...recordInput, - ...recordInputFromFilters, + const mergedRecordInput: Partial = { + ...recordInputFromDynamicFilters, }; + + Object.entries(recordInputFromStaticFilters).forEach(([key, value]) => { + mergedRecordInput[key] = mergeCompositeValues( + mergedRecordInput[key], + value, + ); + }); + + return mergedRecordInput; }; return { buildRecordInputFromRLSPredicates }; diff --git a/packages/twenty-front/src/modules/object-record/record-table/hooks/useCreateNewIndexRecord.ts b/packages/twenty-front/src/modules/object-record/record-table/hooks/useCreateNewIndexRecord.ts index c1cd21c0ef..3a07f87b64 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/hooks/useCreateNewIndexRecord.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/hooks/useCreateNewIndexRecord.ts @@ -77,15 +77,19 @@ export const useCreateNewIndexRecord = ({ buildRecordInputFromRLSPredicates(); const recordInputFromFilters = buildRecordInputFromFilters(); + const mergedRecordInput = { + ...recordInputFromRLSPredicates, + ...recordInputFromFilters, + ...recordInput, + }; + const recordIndexOpenRecordIn = snapshot .getLoadable(recordIndexOpenRecordInState) .getValue(); const createdRecord = await createOneRecord({ id: recordId, - ...recordInputFromRLSPredicates, - ...recordInputFromFilters, - ...recordInput, + ...mergedRecordInput, }); if ( diff --git a/packages/twenty-front/src/modules/object-record/record-table/utils/buildRecordInputFromFilter.ts b/packages/twenty-front/src/modules/object-record/record-table/utils/buildRecordInputFromFilter.ts index 181dbc5d59..717d7637ab 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/utils/buildRecordInputFromFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/utils/buildRecordInputFromFilter.ts @@ -3,7 +3,15 @@ import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataI import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; import { buildValueFromFilter } from '@/object-record/record-table/utils/buildValueFromFilter'; import { type ObjectRecord } from 'twenty-shared/types'; -import { isDefined } from 'twenty-shared/utils'; +import { isDefined, isPlainObject } from 'twenty-shared/utils'; + +const mergeCompositeValues = ( + existingValue: unknown, + incomingValue: unknown, +) => + isPlainObject(existingValue) && isPlainObject(incomingValue) + ? { ...existingValue, ...incomingValue } + : incomingValue; export const buildRecordInputFromFilter = ({ currentRecordFilters, @@ -37,10 +45,17 @@ export const buildRecordInputFromFilter = ({ } recordInput[`${fieldMetadataItem.name}Id`] = value; } else { - recordInput[fieldMetadataItem.name] = buildValueFromFilter({ + const value = buildValueFromFilter({ filter, options: fieldMetadataItem.options ?? undefined, }); + if (!isDefined(value)) { + return; + } + recordInput[fieldMetadataItem.name] = mergeCompositeValues( + recordInput[fieldMetadataItem.name], + value, + ); } }); diff --git a/packages/twenty-front/src/modules/object-record/record-table/utils/buildValueFromFilter.ts b/packages/twenty-front/src/modules/object-record/record-table/utils/buildValueFromFilter.ts index b92f5425d5..b5bb2ece06 100644 --- a/packages/twenty-front/src/modules/object-record/record-table/utils/buildValueFromFilter.ts +++ b/packages/twenty-front/src/modules/object-record/record-table/utils/buildValueFromFilter.ts @@ -1,122 +1,84 @@ import { type CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState'; -import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem'; import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType'; import { type RecordFilter, type RecordFilterToRecordInputOperand, } from '@/object-record/record-filter/types/RecordFilter'; -import { FILTER_OPERANDS_MAP } from '@/object-record/record-filter/utils/getRecordFilterOperands'; -import { ViewFilterOperand } from 'twenty-shared/types'; +import { + FILTER_OPERANDS_MAP, + getRecordFilterOperands, +} from '@/object-record/record-filter/utils/getRecordFilterOperands'; +import { COMPOSITE_FIELD_TYPE_SUB_FIELDS_NAMES } from 'twenty-shared/constants'; +import { + compositeTypeDefinitions, + FieldMetadataType, + ViewFilterOperand, + type FieldMetadataOptions, +} from 'twenty-shared/types'; import { assertUnreachable, parseJson } from 'twenty-shared/utils'; import { RelationType } from '~/generated-metadata/graphql'; +import { convertCurrencyAmountToCurrencyMicros } from '~/utils/convertCurrencyToCurrencyMicros'; -export const buildValueFromFilter = ({ - filter, - options, - relationType, - currentWorkspaceMember, - label, -}: { - filter: RecordFilter; - options?: FieldMetadataItemOption[]; +type FilterOption = { + label: string; + position: number; + value: string; +}; + +type CompositeValue = Record; + +type ValueComputeContext = { + value: string; + operand: ViewFilterOperand; + options?: FilterOption[] | null; relationType?: RelationType; currentWorkspaceMember?: CurrentWorkspaceMember; label?: string; -}) => { - if (isCompositeFieldType(filter.type)) { - return; - } - - if (filter.type === 'RAW_JSON') { - return; - } - - const operands = FILTER_OPERANDS_MAP[filter.type]; - if (!operands.some((operand) => operand === filter.operand)) { - throw new Error('Operand not supported for this field type'); - } - - switch (filter.type) { - case 'TEXT': { - return computeValueFromFilterText( - filter.operand as (typeof FILTER_OPERANDS_MAP)['TEXT'][number], - filter.value, - ); - } - case 'RATING': - return computeValueFromFilterRating( - filter.operand as (typeof FILTER_OPERANDS_MAP)['RATING'][number], - filter.value, - options, - ); - case 'DATE_TIME': - case 'DATE': - return computeValueFromFilterDate( - filter.operand as (typeof FILTER_OPERANDS_MAP)['DATE_TIME'][number], - filter.value, - ); - case 'NUMBER': - return computeValueFromFilterNumber( - filter.operand as (typeof FILTER_OPERANDS_MAP)['NUMBER'][number], - filter.value, - ); - case 'BOOLEAN': - return computeValueFromFilterBoolean( - filter.operand as (typeof FILTER_OPERANDS_MAP)['BOOLEAN'][number], - filter.value, - ); - case 'TS_VECTOR': - return computeValueFromFilterTSVector( - filter.operand as (typeof FILTER_OPERANDS_MAP)['TS_VECTOR'][number], - filter.value, - ); - case 'ARRAY': - return computeValueFromFilterArray( - filter.operand as (typeof FILTER_OPERANDS_MAP)['ARRAY'][number], - filter.value, - ); - case 'SELECT': - return computeValueFromFilterSelect( - filter.operand as (typeof FILTER_OPERANDS_MAP)['SELECT'][number], - filter.value, - options, - ); - case 'MULTI_SELECT': - return computeValueFromFilterMultiSelect( - filter.operand as (typeof FILTER_OPERANDS_MAP)['MULTI_SELECT'][number], - filter.value, - ); - case 'RELATION': { - return computeValueFromFilterRelation( - filter.operand as (typeof FILTER_OPERANDS_MAP)['RELATION'][number], - filter.value, - relationType, - currentWorkspaceMember, - label, - ); - } - case 'UUID': - return computeValueFromFilterUUID( - filter.operand as (typeof FILTER_OPERANDS_MAP)['UUID'][number], - filter.value, - ); - default: - assertUnreachable(filter.type); - } }; -const computeValueFromFilterText = ( - operand: RecordFilterToRecordInputOperand<'TEXT'>, +type ValueHandler = (context: ValueComputeContext) => unknown; + +type CompositeFilterContext = { + filter: RecordFilter; + relationType?: RelationType; + currentWorkspaceMember?: CurrentWorkspaceMember; + label?: string; +}; + +const getCompositeSubFieldProperty = ({ + compositeFieldType, + subFieldName, +}: { + compositeFieldType: FieldMetadataType; + subFieldName: string; +}) => { + const compositeType = compositeTypeDefinitions.get(compositeFieldType); + if (!compositeType) { + return; + } + + return compositeType.properties.find( + (property) => property.name === subFieldName, + ); +}; + +type ContainsBasedOperand = + | ViewFilterOperand.CONTAINS + | ViewFilterOperand.DOES_NOT_CONTAIN + | ViewFilterOperand.IS_EMPTY + | ViewFilterOperand.IS_NOT_EMPTY; + +const computeValueFromContainsOperand = ( + operand: ContainsBasedOperand, value: string, ) => { switch (operand) { case ViewFilterOperand.CONTAINS: - return value; case ViewFilterOperand.IS_NOT_EMPTY: return value; - case ViewFilterOperand.IS_EMPTY: case ViewFilterOperand.DOES_NOT_CONTAIN: + case ViewFilterOperand.IS_EMPTY: return undefined; default: assertUnreachable(operand); @@ -151,17 +113,15 @@ const computeValueFromFilterNumber = ( value: string, ) => { switch (operand) { - //TODO: we shouln't create values from those filters as it makes no sense for the user + // TODO: we shouln't create values from those filters as it makes no sense for the user case ViewFilterOperand.GREATER_THAN_OR_EQUAL: return Number(value) + 1; case ViewFilterOperand.LESS_THAN_OR_EQUAL: return Number(value) - 1; case ViewFilterOperand.IS_NOT_EMPTY: - return Number(value); case ViewFilterOperand.IS: return Number(value); case ViewFilterOperand.IS_NOT: - return undefined; case ViewFilterOperand.IS_EMPTY: return undefined; default: @@ -181,28 +141,12 @@ const computeValueFromFilterBoolean = ( } }; -const computeValueFromFilterArray = ( - operand: RecordFilterToRecordInputOperand<'ARRAY'>, - value: string, -) => { - switch (operand) { - case ViewFilterOperand.CONTAINS: - case ViewFilterOperand.IS_NOT_EMPTY: - return value; - case ViewFilterOperand.DOES_NOT_CONTAIN: - case ViewFilterOperand.IS_EMPTY: - return undefined; - default: - assertUnreachable(operand); - } -}; - const computeValueFromFilterRating = ( operand: RecordFilterToRecordInputOperand<'RATING'>, value: string, - options?: FieldMetadataItemOption[], + options?: FilterOption[] | null, ) => { - const option = options?.find((option) => option.label === value); + const option = options?.find((optionItem) => optionItem.label === value); if (!option) { return undefined; } @@ -213,15 +157,15 @@ const computeValueFromFilterRating = ( return option.value; case ViewFilterOperand.GREATER_THAN_OR_EQUAL: { const plusOne = options?.find( - (opt) => opt.position === option.position + 1, + (optionItem) => optionItem.position === option.position + 1, )?.value; - return plusOne ? plusOne : option.value; + return plusOne ?? option.value; } case ViewFilterOperand.LESS_THAN_OR_EQUAL: { const minusOne = options?.find( - (opt) => opt.position === option.position - 1, + (optionItem) => optionItem.position === option.position - 1, )?.value; - return minusOne ? minusOne : option.value; + return minusOne ?? option.value; } case ViewFilterOperand.IS_EMPTY: return undefined; @@ -233,14 +177,16 @@ const computeValueFromFilterRating = ( const computeValueFromFilterSelect = ( operand: RecordFilterToRecordInputOperand<'SELECT'>, value: string, - options?: FieldMetadataItemOption[], + options?: FilterOption[] | null, ) => { switch (operand) { case ViewFilterOperand.IS: case ViewFilterOperand.IS_NOT_EMPTY: try { const valueParsed = parseJson(value)?.[0]; - const option = options?.find((option) => option.value === valueParsed); + const option = options?.find( + (optionItem) => optionItem.value === valueParsed, + ); if (!option) { return undefined; } @@ -265,7 +211,7 @@ const computeValueFromFilterMultiSelect = ( case ViewFilterOperand.IS_NOT_EMPTY: try { const parsedValue = parseJson(value); - return parsedValue ? parsedValue : undefined; + return parsedValue ?? undefined; } catch { return undefined; } @@ -299,7 +245,7 @@ const computeValueFromFilterRelation = ( return parsedValue?.selectedRecordIds?.[0]; } } - return undefined; //todo + return undefined; // todo } case ViewFilterOperand.IS_NOT: case ViewFilterOperand.IS_NOT_EMPTY: // todo @@ -333,3 +279,262 @@ const computeValueFromFilterUUID = ( assertUnreachable(operand); } }; + +const VALUE_HANDLER_REGISTRY: Partial> = + { + [FieldMetadataType.TEXT]: ({ operand, value }) => + computeValueFromContainsOperand(operand as ContainsBasedOperand, value), + [FieldMetadataType.ARRAY]: ({ operand, value }) => + computeValueFromContainsOperand(operand as ContainsBasedOperand, value), + [FieldMetadataType.RAW_JSON]: ({ operand, value }) => + computeValueFromContainsOperand(operand as ContainsBasedOperand, value), + [FieldMetadataType.DATE_TIME]: ({ operand, value }) => + computeValueFromFilterDate( + operand as RecordFilterToRecordInputOperand<'DATE_TIME'>, + value, + ), + [FieldMetadataType.DATE]: ({ operand, value }) => + computeValueFromFilterDate( + operand as RecordFilterToRecordInputOperand<'DATE_TIME'>, + value, + ), + [FieldMetadataType.NUMBER]: ({ operand, value }) => + computeValueFromFilterNumber( + operand as RecordFilterToRecordInputOperand<'NUMBER'>, + value, + ), + [FieldMetadataType.NUMERIC]: ({ operand, value }) => + computeValueFromFilterNumber( + operand as RecordFilterToRecordInputOperand<'NUMBER'>, + value, + ), + [FieldMetadataType.BOOLEAN]: ({ operand, value }) => + computeValueFromFilterBoolean( + operand as RecordFilterToRecordInputOperand<'BOOLEAN'>, + value, + ), + [FieldMetadataType.RATING]: ({ operand, value, options }) => + computeValueFromFilterRating( + operand as RecordFilterToRecordInputOperand<'RATING'>, + value, + options, + ), + [FieldMetadataType.SELECT]: ({ operand, value, options }) => + computeValueFromFilterSelect( + operand as RecordFilterToRecordInputOperand<'SELECT'>, + value, + options, + ), + [FieldMetadataType.MULTI_SELECT]: ({ operand, value }) => + computeValueFromFilterMultiSelect( + operand as RecordFilterToRecordInputOperand<'MULTI_SELECT'>, + value, + ), + [FieldMetadataType.RELATION]: ({ + operand, + value, + relationType, + currentWorkspaceMember, + label, + }) => + computeValueFromFilterRelation( + operand as RecordFilterToRecordInputOperand<'RELATION'>, + value, + relationType, + currentWorkspaceMember, + label, + ), + [FieldMetadataType.TS_VECTOR]: ({ operand, value }) => + computeValueFromFilterTSVector( + operand as RecordFilterToRecordInputOperand<'TS_VECTOR'>, + value, + ), + [FieldMetadataType.UUID]: ({ operand, value }) => + computeValueFromFilterUUID( + operand as RecordFilterToRecordInputOperand<'UUID'>, + value, + ), + }; + +const COMPOSITE_FIELD_VALUE_TRANSFORMERS: Partial< + Record unknown> +> = { + [FieldMetadataType.CURRENCY]: (value, subFieldName) => + subFieldName === + COMPOSITE_FIELD_TYPE_SUB_FIELDS_NAMES[FieldMetadataType.CURRENCY] + .amountMicros + ? convertCurrencyAmountToCurrencyMicros(Number(value)) + : value, +}; + +const SUPPORTED_COMPOSITE_FIELD_TYPES: FieldMetadataType[] = [ + FieldMetadataType.CURRENCY, + FieldMetadataType.ADDRESS, + FieldMetadataType.FULL_NAME, + FieldMetadataType.LINKS, + FieldMetadataType.EMAILS, + FieldMetadataType.PHONES, + FieldMetadataType.ACTOR, + FieldMetadataType.RICH_TEXT_V2, +]; + +const computeValueFromSubFieldType = ({ + fieldType, + filter, + options, + relationType, + currentWorkspaceMember, + label, +}: { + fieldType: FieldMetadataType; + filter: RecordFilter; + options?: FieldMetadataOptions | null; + relationType?: RelationType; + currentWorkspaceMember?: CurrentWorkspaceMember; + label?: string; +}) => { + const handler = VALUE_HANDLER_REGISTRY[fieldType]; + if (!handler) { + return; + } + + return handler({ + value: filter.value, + operand: filter.operand, + options: options as FilterOption[] | null | undefined, + relationType, + currentWorkspaceMember, + label, + }); +}; + +export const buildCompositeValueFromSubField = ({ + compositeFieldType, + subFieldName, + value, + transformValue, +}: { + compositeFieldType: FieldMetadataType; + subFieldName: string; + value: unknown; + transformValue?: (value: unknown) => unknown; +}): CompositeValue | undefined => { + const subFieldProperty = getCompositeSubFieldProperty({ + compositeFieldType, + subFieldName, + }); + + if (!subFieldProperty) { + return; + } + + return { + [subFieldName]: transformValue ? transformValue(value) : value, + }; +}; + +const buildCompositeValueFromFilter = ({ + filter, + relationType, + currentWorkspaceMember, + label, +}: CompositeFilterContext) => { + if (!filter.subFieldName) { + return; + } + + const compositeFieldType = filter.type as FieldMetadataType; + + if (!SUPPORTED_COMPOSITE_FIELD_TYPES.includes(compositeFieldType)) { + return; + } + + const subFieldProperty = getCompositeSubFieldProperty({ + compositeFieldType, + subFieldName: filter.subFieldName, + }); + + if (!subFieldProperty) { + return; + } + + const operands = getRecordFilterOperands({ + filterType: filter.type, + subFieldName: filter.subFieldName, + }); + + if (!operands.some((operand) => operand === filter.operand)) { + throw new Error('Operand not supported for this sub field type'); + } + + const subFieldValue = computeValueFromSubFieldType({ + fieldType: subFieldProperty.type, + filter, + options: subFieldProperty.options, + relationType, + currentWorkspaceMember, + label, + }); + + if (subFieldValue === undefined) { + return; + } + + const transformValue = COMPOSITE_FIELD_VALUE_TRANSFORMERS[compositeFieldType]; + + return buildCompositeValueFromSubField({ + compositeFieldType, + subFieldName: filter.subFieldName, + value: subFieldValue, + transformValue: transformValue + ? (fieldValue) => + transformValue(fieldValue, filter.subFieldName as string) + : undefined, + }); +}; + +export const buildValueFromFilter = ({ + filter, + options, + relationType, + currentWorkspaceMember, + label, +}: { + filter: RecordFilter; + options?: FilterOption[] | null; + relationType?: RelationType; + currentWorkspaceMember?: CurrentWorkspaceMember; + label?: string; +}) => { + if (isCompositeFieldType(filter.type)) { + return buildCompositeValueFromFilter({ + filter, + relationType, + currentWorkspaceMember, + label, + }); + } + + if (filter.type === 'RAW_JSON') { + return; + } + + const operands = FILTER_OPERANDS_MAP[filter.type]; + if (!operands.some((operand) => operand === filter.operand)) { + throw new Error('Operand not supported for this field type'); + } + + const handler = VALUE_HANDLER_REGISTRY[filter.type as FieldMetadataType]; + if (!handler) { + return; + } + + return handler({ + value: filter.value, + operand: filter.operand, + options, + relationType, + currentWorkspaceMember, + label, + }); +}; diff --git a/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/object-form/components/SettingsRolePermissionsObjectLevelObjectForm.tsx b/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/object-form/components/SettingsRolePermissionsObjectLevelObjectForm.tsx index c0cd3ffe31..b88c5e28e7 100644 --- a/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/object-form/components/SettingsRolePermissionsObjectLevelObjectForm.tsx +++ b/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/object-form/components/SettingsRolePermissionsObjectLevelObjectForm.tsx @@ -1,5 +1,6 @@ import { currentWorkspaceState } from '@/auth/states/currentWorkspaceState'; import { useObjectMetadataItemById } from '@/object-metadata/hooks/useObjectMetadataItemById'; +import { isFilterOperandExpectingValue } from '@/object-record/object-filter-dropdown/utils/isFilterOperandExpectingValue'; import { SettingsPageContainer } from '@/settings/components/SettingsPageContainer'; import { SettingsRolePermissionsObjectLevelObjectFieldPermissionTable } from '@/settings/roles/role-permissions/object-level-permissions/field-permissions/components/SettingsRolePermissionsObjectLevelObjectFieldPermissionTable'; import { SettingsRolePermissionsObjectLevelObjectFormObjectLevel } from '@/settings/roles/role-permissions/object-level-permissions/object-form/components/SettingsRolePermissionsObjectLevelObjectFormObjectLevel'; @@ -10,7 +11,7 @@ import { useFeatureFlagsMap } from '@/workspace/hooks/useFeatureFlagsMap'; import { t } from '@lingui/core/macro'; import { useSearchParams } from 'react-router-dom'; import { useRecoilValue } from 'recoil'; -import { SettingsPath } from 'twenty-shared/types'; +import { SettingsPath, type ViewFilterOperand } from 'twenty-shared/types'; import { getSettingsPath, isDefined } from 'twenty-shared/utils'; import { Button } from 'twenty-ui/input'; import { @@ -105,6 +106,31 @@ export const SettingsRolePermissionsObjectLevelObjectForm = ({ ? getSettingsPath(SettingsPath.AIAgentDetail, { agentId: agent.id }) : getSettingsPath(SettingsPath.RoleDetail, { roleId }); + const objectPredicates = + settingsDraftRole.rowLevelPermissionPredicates?.filter( + (predicate) => predicate.objectMetadataId === objectMetadataItem.id, + ) ?? []; + + const hasInvalidPredicate = objectPredicates.some((predicate) => { + if (isDefined(predicate.workspaceMemberFieldMetadataId)) { + return false; + } + + const operand = predicate.operand as unknown as ViewFilterOperand; + + if (!isFilterOperandExpectingValue(operand)) { + return false; + } + + return ( + !isDefined(predicate.value) || + predicate.value === '' || + predicate.value === '[]' + ); + }); + + const isFinishDisabled = hasInvalidPredicate; + return ( } > diff --git a/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectFieldMenu.tsx b/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectFieldMenu.tsx index 1bc73c1b7c..bc764f9257 100644 --- a/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectFieldMenu.tsx +++ b/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectFieldMenu.tsx @@ -3,6 +3,7 @@ import { useLingui } from '@lingui/react/macro'; import { getFilterTypeFromFieldType } from 'twenty-shared/utils'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem'; import { AdvancedFilterFieldSelectSearchInput } from '@/object-record/advanced-filter/components/AdvancedFilterFieldSelectSearchInput'; import { useAdvancedFilterFieldSelectDropdown } from '@/object-record/advanced-filter/hooks/useAdvancedFilterFieldSelectDropdown'; @@ -15,6 +16,7 @@ import { objectFilterDropdownSearchInputComponentState } from '@/object-record/o import { objectFilterDropdownSubMenuFieldTypeComponentState } from '@/object-record/object-filter-dropdown/states/objectFilterDropdownSubMenuFieldTypeComponentState'; import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType'; import { useFilterableFieldMetadataItems } from '@/object-record/record-filter/hooks/useFilterableFieldMetadataItems'; +import { RECORD_LEVEL_PERMISSION_PREDICATE_FIELD_TYPES } from '@/settings/roles/role-permissions/object-level-permissions/record-level-permissions/constants/RecordLevelPermissionPredicateFieldTypes'; import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; import { DropdownMenuItemsContainer } from '@/ui/layout/dropdown/components/DropdownMenuItemsContainer'; import { DropdownMenuSectionLabel } from '@/ui/layout/dropdown/components/DropdownMenuSectionLabel'; @@ -25,6 +27,7 @@ import { useSelectableList } from '@/ui/layout/selectable-list/hooks/useSelectab import { useRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentState'; import { useSetRecoilComponentState } from '@/ui/utilities/state/component-state/hooks/useSetRecoilComponentState'; import { useContext } from 'react'; +import { FieldMetadataType } from 'twenty-shared/types'; type SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectFieldMenuProps = { @@ -53,10 +56,17 @@ export const SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectF ); const filteredFieldMetadataItems = filterableFieldMetadataItems - .filter((fieldMetadataItem) => - fieldMetadataItem.label - .toLocaleLowerCase() - .includes(objectFilterDropdownSearchInput.toLocaleLowerCase()), + .filter( + (fieldMetadataItem) => + fieldMetadataItem.label + .toLocaleLowerCase() + .includes(objectFilterDropdownSearchInput.toLocaleLowerCase()) && + (RECORD_LEVEL_PERMISSION_PREDICATE_FIELD_TYPES.includes( + fieldMetadataItem.type, + ) || + (fieldMetadataItem.type === FieldMetadataType.RELATION && + fieldMetadataItem.relation?.targetObjectMetadata.nameSingular === + CoreObjectNameSingular.WorkspaceMember)), ) .sort((a, b) => a.label.localeCompare(b.label)); diff --git a/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelPermissionMeValueSelect.tsx b/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelPermissionMeValueSelect.tsx index 1599624488..1a46a2e56b 100644 --- a/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelPermissionMeValueSelect.tsx +++ b/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelPermissionMeValueSelect.tsx @@ -22,6 +22,7 @@ import { currentRecordFiltersComponentState } from '@/object-record/record-filte import { SETTINGS_COMPOSITE_FIELD_TYPE_CONFIGS } from '@/settings/data-model/constants/SettingsCompositeFieldTypeConfigs'; import { type CompositeFieldSubFieldName } from '@/settings/data-model/types/CompositeFieldSubFieldName'; import { type CompositeFieldType } from '@/settings/data-model/types/CompositeFieldType'; +import { RECORD_LEVEL_PERMISSION_PREDICATE_FIELD_TYPES } from '@/settings/roles/role-permissions/object-level-permissions/record-level-permissions/constants/RecordLevelPermissionPredicateFieldTypes'; import { DropdownContent } from '@/ui/layout/dropdown/components/DropdownContent'; import { DropdownMenuHeader } from '@/ui/layout/dropdown/components/DropdownMenuHeader/DropdownMenuHeader'; import { DropdownMenuHeaderLeftComponent } from '@/ui/layout/dropdown/components/DropdownMenuHeader/internal/DropdownMenuHeaderLeftComponent'; @@ -123,7 +124,11 @@ export const SettingsRolePermissionsObjectLevelRecordLevelPermissionMeValueSelec return true; } - if (targetFieldType === FieldMetadataType.RELATION) { + if ( + !RECORD_LEVEL_PERMISSION_PREDICATE_FIELD_TYPES.includes( + targetFieldType, + ) + ) { return false; } diff --git a/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/constants/RecordLevelPermissionPredicateFieldTypes.ts b/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/constants/RecordLevelPermissionPredicateFieldTypes.ts new file mode 100644 index 0000000000..f3427da390 --- /dev/null +++ b/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/constants/RecordLevelPermissionPredicateFieldTypes.ts @@ -0,0 +1,17 @@ +import { FieldMetadataType } from 'twenty-shared/types'; + +export const RECORD_LEVEL_PERMISSION_PREDICATE_FIELD_TYPES = [ + FieldMetadataType.BOOLEAN, + FieldMetadataType.NUMBER, + FieldMetadataType.NUMERIC, + FieldMetadataType.DATE_TIME, + FieldMetadataType.DATE, + FieldMetadataType.SELECT, + FieldMetadataType.MULTI_SELECT, + FieldMetadataType.TEXT, + FieldMetadataType.LINKS, + FieldMetadataType.PHONES, + FieldMetadataType.EMAILS, + FieldMetadataType.FULL_NAME, + FieldMetadataType.ADDRESS, +]; diff --git a/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/hooks/useRecordLevelPermissionFilterActions.ts b/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/hooks/useRecordLevelPermissionFilterActions.ts index d335ff63c2..833a31370b 100644 --- a/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/hooks/useRecordLevelPermissionFilterActions.ts +++ b/packages/twenty-front/src/modules/settings/roles/role-permissions/object-level-permissions/record-level-permissions/hooks/useRecordLevelPermissionFilterActions.ts @@ -1,16 +1,21 @@ /* @license Enterprise */ import { useRecoilValue } from 'recoil'; -import { RecordFilterGroupLogicalOperator } from 'twenty-shared/types'; +import { + FieldMetadataType, + RecordFilterGroupLogicalOperator, +} from 'twenty-shared/types'; import { getFilterTypeFromFieldType, isDefined } from 'twenty-shared/utils'; import { v4 } from 'uuid'; import { availableFieldMetadataItemsForFilterFamilySelector } from '@/object-metadata/states/availableFieldMetadataItemsForFilterFamilySelector'; +import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular'; import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem'; import { useChildRecordFiltersAndRecordFilterGroups } from '@/object-record/advanced-filter/hooks/useChildRecordFiltersAndRecordFilterGroups'; import { useGetDefaultFieldMetadataItemForFilter } from '@/object-record/advanced-filter/hooks/useGetDefaultFieldMetadataItemForFilter'; import { useSetRecordFilterUsedInAdvancedFilterDropdownRow } from '@/object-record/advanced-filter/hooks/useSetRecordFilterUsedInAdvancedFilterDropdownRow'; import { rootLevelRecordFilterGroupComponentSelector } from '@/object-record/advanced-filter/states/rootLevelRecordFilterGroupComponentSelector'; +import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType'; import { useUpsertRecordFilterGroup } from '@/object-record/record-filter-group/hooks/useUpsertRecordFilterGroup'; import { type RecordFilterGroup } from '@/object-record/record-filter-group/types/RecordFilterGroup'; import { useCreateEmptyRecordFilterFromFieldMetadataItem } from '@/object-record/record-filter/hooks/useCreateEmptyRecordFilterFromFieldMetadataItem'; @@ -18,6 +23,7 @@ import { useUpsertRecordFilter } from '@/object-record/record-filter/hooks/useUp import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter'; import { getDefaultSubFieldNameForCompositeFilterableFieldType } from '@/object-record/record-filter/utils/getDefaultSubFieldNameForCompositeFilterableFieldType'; import { getRecordFilterOperands } from '@/object-record/record-filter/utils/getRecordFilterOperands'; +import { RECORD_LEVEL_PERMISSION_PREDICATE_FIELD_TYPES } from '@/settings/roles/role-permissions/object-level-permissions/record-level-permissions/constants/RecordLevelPermissionPredicateFieldTypes'; import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue'; type UseRecordLevelPermissionFilterActionsProps = { @@ -51,7 +57,38 @@ export const useRecordLevelPermissionFilterActions = ({ }); const getDefaultFieldMetadataItemForRLS = () => { - const fieldMetadataItemForId = availableFieldMetadataItemsForFilter.find( + const availableFieldMetadataItemsForRls = + availableFieldMetadataItemsForFilter.filter((fieldMetadataItem) => { + return ( + RECORD_LEVEL_PERMISSION_PREDICATE_FIELD_TYPES.includes( + fieldMetadataItem.type, + ) || + (fieldMetadataItem.type === FieldMetadataType.RELATION && + fieldMetadataItem.relation?.targetObjectMetadata.nameSingular === + CoreObjectNameSingular.WorkspaceMember) + ); + }); + + const nonCompositeFieldMetadataItems = availableFieldMetadataItemsForRls + .toSorted((firstField, secondField) => + firstField.label.localeCompare(secondField.label), + ) + .filter((fieldMetadataItem) => { + const filterType = getFilterTypeFromFieldType(fieldMetadataItem.type); + + return !isCompositeFieldType(filterType); + }); + + const defaultFieldMetadataItemForRls = + nonCompositeFieldMetadataItems.find( + (fieldMetadataItem) => fieldMetadataItem.name !== 'id', + ) ?? nonCompositeFieldMetadataItems[0]; + + if (isDefined(defaultFieldMetadataItemForRls)) { + return defaultFieldMetadataItemForRls; + } + + const fieldMetadataItemForId = availableFieldMetadataItemsForRls.find( (fieldMetadataItem) => fieldMetadataItem.name === 'id', ); diff --git a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper.ts b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper.ts index 34556c9b8a..97b478417b 100644 --- a/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper.ts +++ b/packages/twenty-server/src/engine/api/graphql/graphql-query-runner/helpers/object-records-to-graphql-connection.helper.ts @@ -3,7 +3,7 @@ import { FieldMetadataType, type ObjectRecord, } from 'twenty-shared/types'; -import { isDefined } from 'twenty-shared/utils'; +import { isDefined, isPlainObject } from 'twenty-shared/utils'; import { type ObjectRecordOrderBy } from 'src/engine/api/graphql/workspace-query-builder/interfaces/object-record.interface'; import { type IConnection } from 'src/engine/api/graphql/workspace-query-runner/interfaces/connection.interface'; @@ -24,7 +24,6 @@ import { findFlatEntityByIdInFlatEntityMapsOrThrow } from 'src/engine/metadata-m import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; import { isMorphOrRelationFlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/utils/is-morph-or-relation-flat-field-metadata.util'; import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; -import { isPlainObject } from 'src/utils/is-plain-object'; // TODO: Refacto-common - Rename CommonRecordsToGraphqlConnectionHelper export class ObjectRecordsToGraphqlConnectionHelper { diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-insert-query-builder.ts b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-insert-query-builder.ts index f9cde8c9e9..4c03fce946 100644 --- a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-insert-query-builder.ts +++ b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-insert-query-builder.ts @@ -283,8 +283,15 @@ export class WorkspaceInsertQueryBuilder< ? this.expressionMap.valuesSet : [this.expressionMap.valuesSet]; + const valuesToInsertFormatted = formatResult( + valuesToInsert, + objectMetadata, + this.internalContext.flatObjectMetadataMaps, + this.internalContext.flatFieldMetadataMaps, + ); + validateRLSPredicatesForRecords({ - records: valuesToInsert, + records: valuesToInsertFormatted, objectMetadata, internalContext: this.internalContext, authContext: this.authContext, diff --git a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-update-query-builder.ts b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-update-query-builder.ts index 615bf864cf..509a6d9760 100644 --- a/packages/twenty-server/src/engine/twenty-orm/repository/workspace-update-query-builder.ts +++ b/packages/twenty-server/src/engine/twenty-orm/repository/workspace-update-query-builder.ts @@ -177,7 +177,7 @@ export class WorkspaceUpdateQueryBuilder< this.applyRowLevelPermissionPredicates(); const valuesSet = this.expressionMap.valuesSet ?? {}; - const updatedRecords: T[] = formattedBefore.map( + const updatedRecords: T[] = before.map( (record, index) => ({ ...record, @@ -571,8 +571,15 @@ export class WorkspaceUpdateQueryBuilder< this.internalContext, ); + const updatedRecordsFormatted = formatResult( + updatedRecords, + objectMetadata, + this.internalContext.flatObjectMetadataMaps, + this.internalContext.flatFieldMetadataMaps, + ); + validateRLSPredicatesForRecords({ - records: updatedRecords, + records: updatedRecordsFormatted, objectMetadata, internalContext: this.internalContext, authContext: this.authContext, diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/is-record-matching-rls-row-level-permission-predicate.util.spec.ts b/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/is-record-matching-rls-row-level-permission-predicate.util.spec.ts new file mode 100644 index 0000000000..b07b2f894d --- /dev/null +++ b/packages/twenty-server/src/engine/twenty-orm/utils/__tests__/is-record-matching-rls-row-level-permission-predicate.util.spec.ts @@ -0,0 +1,281 @@ +import { FieldMetadataType, type ObjectRecord } from 'twenty-shared/types'; + +import { type FlatEntityMaps } from 'src/engine/metadata-modules/flat-entity/types/flat-entity-maps.type'; +import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type'; +import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type'; +import { isRecordMatchingRLSRowLevelPermissionPredicate } from 'src/engine/twenty-orm/utils/is-record-matching-rls-row-level-permission-predicate.util'; + +describe('isRecordMatchingRLSRowLevelPermissionPredicate', () => { + const createMockFlatObjectMetadata = ( + fieldMetadataIds: string[], + ): FlatObjectMetadata => + ({ + id: 'test-object-id', + nameSingular: 'test', + namePlural: 'tests', + labelSingular: 'Test', + labelPlural: 'Tests', + icon: 'IconTest', + targetTableName: 'test', + isCustom: false, + isRemote: false, + isActive: true, + isSystem: false, + isAuditLogged: false, + isSearchable: false, + workspaceId: 'test-workspace-id', + universalIdentifier: 'test-object-id', + indexMetadataIds: [], + fieldMetadataIds, + viewIds: [], + applicationId: 'test-application-id', + isLabelSyncedWithName: false, + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + shortcut: null, + description: null, + standardOverrides: null, + isUIReadOnly: false, + standardId: null, + labelIdentifierFieldMetadataId: null, + imageIdentifierFieldMetadataId: null, + duplicateCriteria: null, + }) as FlatObjectMetadata; + + const createMockFlatFieldMetadata = ( + id: string, + name: string, + type: FieldMetadataType, + settings?: Record, + ): FlatFieldMetadata => + ({ + id, + name, + type, + label: name, + objectMetadataId: 'test-object-id', + isLabelSyncedWithName: true, + isNullable: true, + createdAt: new Date(), + updatedAt: new Date(), + universalIdentifier: id, + viewFieldIds: [], + viewFilterIds: [], + kanbanAggregateOperationViewIds: [], + calendarViewIds: [], + mainGroupByFieldMetadataViewIds: [], + applicationId: null, + settings, + }) as unknown as FlatFieldMetadata; + + const buildFlatFieldMetadataMaps = ( + fields: FlatFieldMetadata[], + ): FlatEntityMaps => ({ + byId: fields.reduce( + (accumulator, field) => { + accumulator[field.id] = field; + + return accumulator; + }, + {} as Record, + ), + idByUniversalIdentifier: fields.reduce( + (accumulator, field) => { + accumulator[field.universalIdentifier] = field.id; + + return accumulator; + }, + {} as Record, + ), + universalIdentifiersByApplicationId: {}, + }); + + const fieldMetadata = [ + createMockFlatFieldMetadata( + 'job-title-id', + 'jobTitle', + FieldMetadataType.TEXT, + ), + createMockFlatFieldMetadata('name-id', 'name', FieldMetadataType.FULL_NAME), + createMockFlatFieldMetadata( + 'address-id', + 'address', + FieldMetadataType.ADDRESS, + ), + createMockFlatFieldMetadata( + 'company-id', + 'company', + FieldMetadataType.RELATION, + { + joinColumnName: 'companyId', + }, + ), + ]; + + const flatObjectMetadata = createMockFlatObjectMetadata( + fieldMetadata.map((field) => field.id), + ); + const flatFieldMetadataMaps = buildFlatFieldMetadataMaps(fieldMetadata); + + const baseRecord: ObjectRecord = { + jobTitle: 'Engineer', + name: { + firstName: 'Jane', + lastName: 'Doe', + }, + address: { + addressStreet1: 'Main Street', + addressCity: 'Paris', + }, + companyId: 'company-1', + deletedAt: null, + id: 'record-1', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + } as ObjectRecord; + + it('returns true for an empty filter on non-deleted record', () => { + const result = isRecordMatchingRLSRowLevelPermissionPredicate({ + record: baseRecord, + filter: {}, + flatObjectMetadata, + flatFieldMetadataMaps, + }); + + expect(result).toBe(true); + }); + + it('returns false for deleted records without deletedAt filter', () => { + const result = isRecordMatchingRLSRowLevelPermissionPredicate({ + record: { ...baseRecord, deletedAt: new Date().toISOString() }, + filter: { + jobTitle: { + eq: 'Engineer', + }, + }, + flatObjectMetadata, + flatFieldMetadataMaps, + }); + + expect(result).toBe(false); + }); + + it('treats multiple filter keys as an implicit and', () => { + const result = isRecordMatchingRLSRowLevelPermissionPredicate({ + record: baseRecord, + filter: { + jobTitle: { + eq: 'Engineer', + }, + name: { + firstName: { + eq: 'Jane', + }, + }, + }, + flatObjectMetadata, + flatFieldMetadataMaps, + }); + + expect(result).toBe(true); + }); + + it('treats "or" with object as an "and"', () => { + const matchingResult = isRecordMatchingRLSRowLevelPermissionPredicate({ + record: baseRecord, + filter: { + or: { + jobTitle: { + eq: 'Engineer', + }, + name: { + lastName: { + eq: 'Doe', + }, + }, + }, + }, + flatObjectMetadata, + flatFieldMetadataMaps, + }); + + const nonMatchingResult = isRecordMatchingRLSRowLevelPermissionPredicate({ + record: { + ...baseRecord, + name: { + ...baseRecord.name, + lastName: 'Smith', + }, + }, + filter: { + or: { + jobTitle: { + eq: 'Engineer', + }, + name: { + lastName: { + eq: 'Doe', + }, + }, + }, + }, + flatObjectMetadata, + flatFieldMetadataMaps, + }); + + expect(matchingResult).toBe(true); + expect(nonMatchingResult).toBe(false); + }); + + it('supports "not" filter negation', () => { + const result = isRecordMatchingRLSRowLevelPermissionPredicate({ + record: baseRecord, + filter: { + not: { + jobTitle: { + eq: 'Engineer', + }, + }, + }, + flatObjectMetadata, + flatFieldMetadataMaps, + }); + + expect(result).toBe(false); + }); + + it('matches composite address filters using at least one sub-field', () => { + const result = isRecordMatchingRLSRowLevelPermissionPredicate({ + record: baseRecord, + filter: { + address: { + addressStreet1: { + eq: 'Main Street', + }, + addressCity: { + eq: 'London', + }, + }, + }, + flatObjectMetadata, + flatFieldMetadataMaps, + }); + + expect(result).toBe(true); + }); + + it('supports relation join column filters', () => { + const result = isRecordMatchingRLSRowLevelPermissionPredicate({ + record: baseRecord, + filter: { + companyId: { + eq: 'company-1', + }, + }, + flatObjectMetadata, + flatFieldMetadataMaps, + }); + + expect(result).toBe(true); + }); +}); diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/is-record-matching-rls-row-level-permission-predicate.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/is-record-matching-rls-row-level-permission-predicate.util.ts index a9dc73506d..b607490b0f 100644 --- a/packages/twenty-server/src/engine/twenty-orm/utils/is-record-matching-rls-row-level-permission-predicate.util.ts +++ b/packages/twenty-server/src/engine/twenty-orm/utils/is-record-matching-rls-row-level-permission-predicate.util.ts @@ -207,16 +207,22 @@ export const isRecordMatchingRLSRowLevelPermissionPredicate = ({ ); } + const recordFieldValue = record[filterKey]; + + if (!isDefined(recordFieldValue)) { + return false; + } + switch (objectMetadataField.type) { case FieldMetadataType.RATING: return isMatchingRatingFilter({ ratingFilter: filterValue as RatingFilter, - value: record[filterKey], + value: recordFieldValue, }); case FieldMetadataType.TEXT: { return isMatchingStringFilter({ stringFilter: filterValue as StringFilter, - value: record[filterKey], + value: recordFieldValue, }); } case FieldMetadataType.RICH_TEXT: { @@ -225,35 +231,35 @@ export const isRecordMatchingRLSRowLevelPermissionPredicate = ({ // This should be tackled in Q4'24 return isMatchingStringFilter({ stringFilter: filterValue as StringFilter, - value: record[filterKey], + value: recordFieldValue, }); } case FieldMetadataType.RICH_TEXT_V2: { return isMatchingRichTextV2Filter({ richTextV2Filter: filterValue as RichTextV2Filter, - value: record[filterKey], + value: recordFieldValue, }); } case FieldMetadataType.SELECT: return isMatchingSelectFilter({ selectFilter: filterValue as SelectFilter, - value: record[filterKey], + value: recordFieldValue, }); case FieldMetadataType.MULTI_SELECT: return isMatchingMultiSelectFilter({ multiSelectFilter: filterValue as MultiSelectFilter, - value: record[filterKey], + value: recordFieldValue, }); case FieldMetadataType.ARRAY: { return isMatchingArrayFilter({ arrayFilter: filterValue as ArrayFilter, - value: record[filterKey], + value: recordFieldValue, }); } case FieldMetadataType.RAW_JSON: { return isMatchingRawJsonFilter({ rawJsonFilter: filterValue as RawJsonFilter, - value: record[filterKey], + value: recordFieldValue, }); } case FieldMetadataType.FULL_NAME: { @@ -263,12 +269,12 @@ export const isRecordMatchingRLSRowLevelPermissionPredicate = ({ (fullNameFilter.firstName === undefined || isMatchingStringFilter({ stringFilter: fullNameFilter.firstName, - value: record[filterKey].firstName, + value: recordFieldValue.firstName, })) && (fullNameFilter.lastName === undefined || isMatchingStringFilter({ stringFilter: fullNameFilter.lastName, - value: record[filterKey].lastName, + value: recordFieldValue.lastName, })) ); } @@ -293,7 +299,7 @@ export const isRecordMatchingRLSRowLevelPermissionPredicate = ({ return isMatchingStringFilter({ stringFilter: value, - value: record[filterKey][key], + value: recordFieldValue[key], }); }); } @@ -311,7 +317,7 @@ export const isRecordMatchingRLSRowLevelPermissionPredicate = ({ return isMatchingStringFilter({ stringFilter: value, - value: record[filterKey][key], + value: recordFieldValue[key], }); }); } @@ -319,32 +325,32 @@ export const isRecordMatchingRLSRowLevelPermissionPredicate = ({ case FieldMetadataType.DATE_TIME: { return isMatchingDateFilter({ dateFilter: filterValue as DateFilter, - value: record[filterKey], + value: recordFieldValue, }); } case FieldMetadataType.NUMBER: case FieldMetadataType.NUMERIC: { return isMatchingFloatFilter({ floatFilter: filterValue as FloatFilter, - value: record[filterKey], + value: recordFieldValue, }); } case FieldMetadataType.UUID: { return isMatchingUUIDFilter({ uuidFilter: filterValue as UUIDFilter, - value: record[filterKey], + value: recordFieldValue, }); } case FieldMetadataType.BOOLEAN: { return isMatchingBooleanFilter({ booleanFilter: filterValue as BooleanFilter, - value: record[filterKey], + value: recordFieldValue, }); } case FieldMetadataType.CURRENCY: { return isMatchingCurrencyFilter({ currencyFilter: filterValue as CurrencyFilter, - value: record[filterKey], + value: recordFieldValue, }); } case FieldMetadataType.ACTOR: { @@ -354,7 +360,7 @@ export const isRecordMatchingRLSRowLevelPermissionPredicate = ({ actorFilter.name === undefined || isMatchingStringFilter({ stringFilter: actorFilter.name, - value: record[filterKey].name, + value: recordFieldValue.name, }) ); } @@ -367,7 +373,7 @@ export const isRecordMatchingRLSRowLevelPermissionPredicate = ({ return isMatchingStringFilter({ stringFilter: emailsFilter.primaryEmail, - value: record[filterKey].primaryEmail, + value: recordFieldValue.primaryEmail, }); } case FieldMetadataType.PHONES: { @@ -384,7 +390,7 @@ export const isRecordMatchingRLSRowLevelPermissionPredicate = ({ return isMatchingStringFilter({ stringFilter: value, - value: record[filterKey][key], + value: recordFieldValue[key], }); }); } @@ -399,7 +405,7 @@ export const isRecordMatchingRLSRowLevelPermissionPredicate = ({ if (isJoinColumn) { return isMatchingUUIDFilter({ uuidFilter: filterValue as UUIDFilter, - value: record[filterKey], + value: recordFieldValue, }); } @@ -410,7 +416,7 @@ export const isRecordMatchingRLSRowLevelPermissionPredicate = ({ case FieldMetadataType.TS_VECTOR: { return isMatchingTSVectorFilter({ tsVectorFilter: filterValue as TSVectorFilter, - value: record[filterKey], + value: recordFieldValue, }); } default: { diff --git a/packages/twenty-server/src/engine/twenty-orm/utils/validate-rls-predicates-for-records.util.ts b/packages/twenty-server/src/engine/twenty-orm/utils/validate-rls-predicates-for-records.util.ts index 14dcc77864..16b71a2ec2 100644 --- a/packages/twenty-server/src/engine/twenty-orm/utils/validate-rls-predicates-for-records.util.ts +++ b/packages/twenty-server/src/engine/twenty-orm/utils/validate-rls-predicates-for-records.util.ts @@ -1,5 +1,6 @@ /* @license Enterprise */ +import { type ObjectRecord } from 'twenty-shared/types'; import { type ObjectLiteral } from 'typeorm'; import { type WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/workspace-internal-context.interface'; @@ -59,7 +60,7 @@ export const validateRLSPredicatesForRecords = ({ for (const record of records) { const matchesRLS = isRecordMatchingRLSRowLevelPermissionPredicate({ - record, + record: record as unknown as ObjectRecord, filter: recordFilter, flatObjectMetadata: objectMetadata, flatFieldMetadataMaps: internalContext.flatFieldMetadataMaps, diff --git a/packages/twenty-shared/src/utils/index.ts b/packages/twenty-shared/src/utils/index.ts index d1fe72d777..3b88d5ed91 100644 --- a/packages/twenty-shared/src/utils/index.ts +++ b/packages/twenty-shared/src/utils/index.ts @@ -160,6 +160,7 @@ export { export type { StringPropertyKeys } from './trim-and-remove-duplicated-whitespaces-from-object-string-properties'; export { trimAndRemoveDuplicatedWhitespacesFromObjectStringProperties } from './trim-and-remove-duplicated-whitespaces-from-object-string-properties'; export { trimAndRemoveDuplicatedWhitespacesFromString } from './trim-and-remove-duplicated-whitespaces-from-string'; +export { isPlainObject } from './typeguard/isPlainObject'; export { throwIfNotDefined } from './typeguard/throwIfNotDefined'; export { absoluteUrlSchema } from './url/absoluteUrlSchema'; export { buildSignedPath } from './url/buildSignedPath'; diff --git a/packages/twenty-shared/src/utils/typeguard/isPlainObject.ts b/packages/twenty-shared/src/utils/typeguard/isPlainObject.ts new file mode 100644 index 0000000000..0216716af8 --- /dev/null +++ b/packages/twenty-shared/src/utils/typeguard/isPlainObject.ts @@ -0,0 +1,4 @@ +export const isPlainObject = ( + value: unknown, +): value is Record => + typeof value === 'object' && value !== null && !Array.isArray(value);