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
@@ -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,