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:
+281
@@ -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);
|
||||
});
|
||||
});
|
||||
+28
-22
@@ -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: {
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user