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
<img width="559" height="206" alt="Screenshot 2026-01-19 at 15 38 42"
src="https://github.com/user-attachments/assets/d64afa6e-3e12-4843-a215-a693665112c5"
/>
This commit is contained in:
Weiko
2026-01-20 06:36:38 +01:00
committed by GitHub
parent 39470b74a4
commit b64951634e
17 changed files with 906 additions and 202 deletions
@@ -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<ObjectRecord> => {
const recordInput: Partial<ObjectRecord> = {};
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<string, unknown>
| 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<ObjectRecord> => {
const rlsPredicates = objectPermissions.rowLevelPermissionPredicates.filter(
(predicate) => predicate.objectMetadataId === objectMetadataItem.id,
);
@@ -57,6 +118,8 @@ export const useBuildRecordInputFromRLSPredicates = ({
)
.filter(isDefined);
const recordInputFromDynamicFilters: Partial<ObjectRecord> = {};
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<ObjectRecord> = {
...recordInputFromDynamicFilters,
};
Object.entries(recordInputFromStaticFilters).forEach(([key, value]) => {
mergedRecordInput[key] = mergeCompositeValues(
mergedRecordInput[key],
value,
);
});
return mergedRecordInput;
};
return { buildRecordInputFromRLSPredicates };
@@ -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 (
@@ -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,
);
}
});
@@ -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<string, unknown>;
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<string[]>(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<string[]>(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<Record<FieldMetadataType, ValueHandler>> =
{
[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<FieldMetadataType, (value: unknown, subFieldName: string) => 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,
});
};
@@ -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 (
<SubMenuTopBarContainer
title={t`2. Set ${objectLabelPlural} permissions`}
@@ -115,7 +141,8 @@ export const SettingsRolePermissionsObjectLevelObjectForm = ({
variant="secondary"
size="small"
accent="blue"
to={finishButtonPath}
to={isFinishDisabled ? undefined : finishButtonPath}
disabled={isFinishDisabled}
/>
}
>
@@ -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));
@@ -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;
}
@@ -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,
];
@@ -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',
);
@@ -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 {
@@ -283,8 +283,15 @@ export class WorkspaceInsertQueryBuilder<
? this.expressionMap.valuesSet
: [this.expressionMap.valuesSet];
const valuesToInsertFormatted = formatResult<T[]>(
valuesToInsert,
objectMetadata,
this.internalContext.flatObjectMetadataMaps,
this.internalContext.flatFieldMetadataMaps,
);
validateRLSPredicatesForRecords({
records: valuesToInsert,
records: valuesToInsertFormatted,
objectMetadata,
internalContext: this.internalContext,
authContext: this.authContext,
@@ -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<T[]>(
updatedRecords,
objectMetadata,
this.internalContext.flatObjectMetadataMaps,
this.internalContext.flatFieldMetadataMaps,
);
validateRLSPredicatesForRecords({
records: updatedRecords,
records: updatedRecordsFormatted,
objectMetadata,
internalContext: this.internalContext,
authContext: this.authContext,
@@ -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<string, unknown>,
): 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<FlatFieldMetadata> => ({
byId: fields.reduce(
(accumulator, field) => {
accumulator[field.id] = field;
return accumulator;
},
{} as Record<string, FlatFieldMetadata>,
),
idByUniversalIdentifier: fields.reduce(
(accumulator, field) => {
accumulator[field.universalIdentifier] = field.id;
return accumulator;
},
{} as Record<string, string>,
),
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);
});
});
@@ -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: {
@@ -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 = <T extends ObjectLiteral>({
for (const record of records) {
const matchesRLS = isRecordMatchingRLSRowLevelPermissionPredicate({
record,
record: record as unknown as ObjectRecord,
filter: recordFilter,
flatObjectMetadata: objectMetadata,
flatFieldMetadataMaps: internalContext.flatFieldMetadataMaps,
@@ -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';
@@ -0,0 +1,4 @@
export const isPlainObject = (
value: unknown,
): value is Record<string, unknown> =>
typeof value === 'object' && value !== null && !Array.isArray(value);