Fix insert new record with RLS (#17164)

## Context
Now that RLS predicates are applied, creating a record through the FE
(which is empty by default) is failing if your role has predicates and
your input does not respect them (which will always be true since, as
said above, input will be pretty much empty)

## Implementation
- Moved isMatching* filters to twenty-shared
- Implemented isMatchingRlsPredicates utils in the backend (ORM) to
check before insertion/update if the record is matching the current user
role Rls predicates, reusing the isMatching* filters utils moved to
twenty-shared
- Frontend now applies RLS predicates before creating a new record
(similarly to what we do with view filters)

Note:
It seems composite were not properly handled with view-filter insertion
logic, since I'm reusing the util for now, the issue remains for RLS and
will need to be addressed
This commit is contained in:
Weiko
2026-01-15 17:40:47 +01:00
committed by GitHub
parent 2cff75d772
commit 8413c6f3dd
84 changed files with 1499 additions and 463 deletions
@@ -21,6 +21,8 @@ describe('computePermissionIntersection', () => {
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
},
};
@@ -39,6 +41,8 @@ describe('computePermissionIntersection', () => {
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: true,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
},
};
@@ -49,6 +53,8 @@ describe('computePermissionIntersection', () => {
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: true,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
},
};
@@ -63,6 +69,8 @@ describe('computePermissionIntersection', () => {
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: true,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
});
});
@@ -74,6 +82,8 @@ describe('computePermissionIntersection', () => {
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: true,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
},
};
@@ -84,6 +94,8 @@ describe('computePermissionIntersection', () => {
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: true,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
},
};
@@ -98,6 +110,8 @@ describe('computePermissionIntersection', () => {
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: true,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
});
});
@@ -109,6 +123,8 @@ describe('computePermissionIntersection', () => {
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: true,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
},
};
@@ -125,6 +141,8 @@ describe('computePermissionIntersection', () => {
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
});
});
});
@@ -138,6 +156,8 @@ describe('computePermissionIntersection', () => {
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
},
[objectMetadataId2]: {
canReadObjectRecords: true,
@@ -145,6 +165,8 @@ describe('computePermissionIntersection', () => {
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
},
};
@@ -155,6 +177,8 @@ describe('computePermissionIntersection', () => {
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: false,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
},
[objectMetadataId2]: {
canReadObjectRecords: true,
@@ -162,6 +186,8 @@ describe('computePermissionIntersection', () => {
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
},
};
@@ -176,6 +202,8 @@ describe('computePermissionIntersection', () => {
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
});
expect(result[objectMetadataId2]).toEqual({
@@ -184,6 +212,8 @@ describe('computePermissionIntersection', () => {
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
});
});
});
@@ -206,6 +236,8 @@ describe('computePermissionIntersection', () => {
canUpdate: null,
},
},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
},
};
@@ -225,6 +257,8 @@ describe('computePermissionIntersection', () => {
canUpdate: false,
},
},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
},
};
@@ -258,6 +292,8 @@ describe('computePermissionIntersection', () => {
canUpdate: false,
},
},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
},
};
@@ -273,6 +309,8 @@ describe('computePermissionIntersection', () => {
canUpdate: false,
},
},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
},
};
@@ -303,6 +341,8 @@ describe('computePermissionIntersection', () => {
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: true,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
},
};
@@ -313,6 +353,8 @@ describe('computePermissionIntersection', () => {
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: true,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
},
};
@@ -323,6 +365,8 @@ describe('computePermissionIntersection', () => {
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: true,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
},
};
@@ -334,6 +378,8 @@ describe('computePermissionIntersection', () => {
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: true,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
});
});
});
@@ -3,9 +3,11 @@
import {
FieldMetadataType,
RecordFilterGroupLogicalOperator,
RowLevelPermissionPredicateGroupLogicalOperator,
type CompositeFieldSubFieldName,
type PartialFieldMetadataItemOption,
type RecordGqlOperationFilter,
type RowLevelPermissionPredicateValue,
} from 'twenty-shared/types';
import {
computeRecordGqlOperationFilter,
@@ -25,10 +27,8 @@ import {
PermissionsException,
PermissionsExceptionCode,
} from 'src/engine/metadata-modules/permissions/permissions.exception';
import { RowLevelPermissionPredicateGroupLogicalOperator } from 'src/engine/metadata-modules/row-level-permission-predicate/enums/row-level-permission-predicate-group-logical-operator.enum';
import { type FlatRowLevelPermissionPredicateGroupMaps } from 'src/engine/metadata-modules/row-level-permission-predicate/types/flat-row-level-permission-predicate-group-maps.type';
import { type FlatRowLevelPermissionPredicateMaps } from 'src/engine/metadata-modules/row-level-permission-predicate/types/flat-row-level-permission-predicate-maps.type';
import { type RowLevelPermissionPredicateValue } from 'src/engine/metadata-modules/row-level-permission-predicate/types/row-level-permission-predicate-value.type';
import { validateEnumValueCompatibility } from 'src/engine/twenty-orm/utils/validate-enum-value-compatibility.util';
type BuildRowLevelPermissionRecordFilterArgs = {
@@ -85,6 +85,8 @@ export const computePermissionIntersection = (
canSoftDeleteObjectRecords,
canDestroyObjectRecords,
restrictedFields,
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
};
}
@@ -0,0 +1,423 @@
/* @license Enterprise */
import { isObject } from '@sniptt/guards';
import {
FieldMetadataType,
type ActorFilter,
type AddressFilter,
type AndObjectRecordFilter,
type ArrayFilter,
type BooleanFilter,
type CurrencyFilter,
type DateFilter,
type EmailsFilter,
type FloatFilter,
type FullNameFilter,
type LeafObjectRecordFilter,
type LinksFilter,
type MultiSelectFilter,
type NotObjectRecordFilter,
type OrObjectRecordFilter,
type PhonesFilter,
type RatingFilter,
type RawJsonFilter,
type RecordGqlOperationFilter,
type RichTextV2Filter,
type SelectFilter,
type StringFilter,
type TSVectorFilter,
type UUIDFilter,
} from 'twenty-shared/types';
import {
isDefined,
isEmptyObject,
isMatchingArrayFilter,
isMatchingBooleanFilter,
isMatchingCurrencyFilter,
isMatchingDateFilter,
isMatchingFloatFilter,
isMatchingMultiSelectFilter,
isMatchingRatingFilter,
isMatchingRawJsonFilter,
isMatchingRichTextV2Filter,
isMatchingSelectFilter,
isMatchingStringFilter,
isMatchingTSVectorFilter,
isMatchingUUIDFilter,
} from 'twenty-shared/utils';
import { getFlatFieldsFromFlatObjectMetadata } from 'src/engine/api/graphql/workspace-schema-builder/utils/get-flat-fields-for-flat-object-metadata.util';
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';
const isLeafFilter = (
filter: RecordGqlOperationFilter,
): filter is LeafObjectRecordFilter => {
return !isAndFilter(filter) && !isOrFilter(filter) && !isNotFilter(filter);
};
const isAndFilter = (
filter: RecordGqlOperationFilter,
): filter is AndObjectRecordFilter => 'and' in filter && !!filter.and;
const isImplicitAndFilter = (filter: RecordGqlOperationFilter) =>
Object.keys(filter).length > 1;
const isOrFilter = (
filter: RecordGqlOperationFilter,
): filter is OrObjectRecordFilter => 'or' in filter && !!filter.or;
const isNotFilter = (
filter: RecordGqlOperationFilter,
): filter is NotObjectRecordFilter => 'not' in filter && !!filter.not;
export const isRecordMatchingRLSRowLevelPermissionPredicate = ({
record,
filter,
flatObjectMetadata,
flatFieldMetadataMaps,
}: {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
record: any;
filter: RecordGqlOperationFilter;
flatObjectMetadata: FlatObjectMetadata;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
}): boolean => {
if (Object.keys(filter).length === 0 && record.deletedAt === null) {
return true;
}
if (isImplicitAndFilter(filter)) {
return Object.entries(filter).every(([filterKey, value]) =>
isRecordMatchingRLSRowLevelPermissionPredicate({
record,
filter: { [filterKey]: value },
flatObjectMetadata,
flatFieldMetadataMaps,
}),
);
}
if (isAndFilter(filter)) {
const filterValue = filter.and;
if (!Array.isArray(filterValue)) {
throw new Error(
'Unexpected value for "and" filter : ' + JSON.stringify(filterValue),
);
}
return (
filterValue.length === 0 ||
filterValue.every((andFilter) =>
isRecordMatchingRLSRowLevelPermissionPredicate({
record,
filter: andFilter,
flatObjectMetadata,
flatFieldMetadataMaps,
}),
)
);
}
if (isOrFilter(filter)) {
const filterValue = filter.or;
if (Array.isArray(filterValue)) {
return (
filterValue.length === 0 ||
filterValue.some((orFilter) =>
isRecordMatchingRLSRowLevelPermissionPredicate({
record,
filter: orFilter,
flatObjectMetadata,
flatFieldMetadataMaps,
}),
)
);
}
if (isObject(filterValue)) {
// The API considers "or" with an object as an "and"
return isRecordMatchingRLSRowLevelPermissionPredicate({
record,
filter: filterValue,
flatObjectMetadata,
flatFieldMetadataMaps,
});
}
throw new Error('Unexpected value for "or" filter : ' + filterValue);
}
if (isNotFilter(filter)) {
const filterValue = filter.not;
if (!isDefined(filterValue)) {
throw new Error('Unexpected value for "not" filter : ' + filterValue);
}
return (
isEmptyObject(filterValue) ||
!isRecordMatchingRLSRowLevelPermissionPredicate({
record,
filter: filterValue,
flatObjectMetadata,
flatFieldMetadataMaps,
})
);
}
if (isLeafFilter(filter)) {
if (isDefined(record.deletedAt) && filter.deletedAt === undefined) {
return false;
}
}
const objectFields = getFlatFieldsFromFlatObjectMetadata(
flatObjectMetadata,
flatFieldMetadataMaps,
);
return Object.entries(filter).every(([filterKey, filterValue]) => {
if (!isDefined(filterValue)) {
throw new Error(
'Unexpected value for filter key "' + filterKey + '" : ' + filterValue,
);
}
if (isEmptyObject(filterValue)) return true;
const objectMetadataField =
objectFields.find((field) => field.name === filterKey) ??
objectFields.find(
(field) =>
field.type === FieldMetadataType.RELATION &&
(field.settings as { joinColumnName?: string } | undefined)
?.joinColumnName === filterKey,
);
if (!isDefined(objectMetadataField)) {
throw new Error(
'Field metadata item "' +
filterKey +
'" not found for object metadata item ' +
flatObjectMetadata.nameSingular,
);
}
switch (objectMetadataField.type) {
case FieldMetadataType.RATING:
return isMatchingRatingFilter({
ratingFilter: filterValue as RatingFilter,
value: record[filterKey],
});
case FieldMetadataType.TEXT: {
return isMatchingStringFilter({
stringFilter: filterValue as StringFilter,
value: record[filterKey],
});
}
case FieldMetadataType.RICH_TEXT: {
// TODO: Implement a better rich text filter once it becomes a composite field
// See this issue for more context: https://github.com/twentyhq/twenty/issues/7613#issuecomment-2408944585
// This should be tackled in Q4'24
return isMatchingStringFilter({
stringFilter: filterValue as StringFilter,
value: record[filterKey],
});
}
case FieldMetadataType.RICH_TEXT_V2: {
return isMatchingRichTextV2Filter({
richTextV2Filter: filterValue as RichTextV2Filter,
value: record[filterKey],
});
}
case FieldMetadataType.SELECT:
return isMatchingSelectFilter({
selectFilter: filterValue as SelectFilter,
value: record[filterKey],
});
case FieldMetadataType.MULTI_SELECT:
return isMatchingMultiSelectFilter({
multiSelectFilter: filterValue as MultiSelectFilter,
value: record[filterKey],
});
case FieldMetadataType.ARRAY: {
return isMatchingArrayFilter({
arrayFilter: filterValue as ArrayFilter,
value: record[filterKey],
});
}
case FieldMetadataType.RAW_JSON: {
return isMatchingRawJsonFilter({
rawJsonFilter: filterValue as RawJsonFilter,
value: record[filterKey],
});
}
case FieldMetadataType.FULL_NAME: {
const fullNameFilter = filterValue as FullNameFilter;
return (
(fullNameFilter.firstName === undefined ||
isMatchingStringFilter({
stringFilter: fullNameFilter.firstName,
value: record[filterKey].firstName,
})) &&
(fullNameFilter.lastName === undefined ||
isMatchingStringFilter({
stringFilter: fullNameFilter.lastName,
value: record[filterKey].lastName,
}))
);
}
case FieldMetadataType.ADDRESS: {
const addressFilter = filterValue as AddressFilter;
const keys = [
'addressStreet1',
'addressStreet2',
'addressCity',
'addressState',
'addressCountry',
'addressPostcode',
] as const;
return keys.some((key) => {
const value = addressFilter[key];
if (value === undefined) {
return false;
}
return isMatchingStringFilter({
stringFilter: value,
value: record[filterKey][key],
});
});
}
case FieldMetadataType.LINKS: {
const linksFilter = filterValue as LinksFilter;
const keys = ['primaryLinkLabel', 'primaryLinkUrl'] as const;
return keys.some((key) => {
const value = linksFilter[key];
if (value === undefined) {
return false;
}
return isMatchingStringFilter({
stringFilter: value,
value: record[filterKey][key],
});
});
}
case FieldMetadataType.DATE:
case FieldMetadataType.DATE_TIME: {
return isMatchingDateFilter({
dateFilter: filterValue as DateFilter,
value: record[filterKey],
});
}
case FieldMetadataType.NUMBER:
case FieldMetadataType.NUMERIC: {
return isMatchingFloatFilter({
floatFilter: filterValue as FloatFilter,
value: record[filterKey],
});
}
case FieldMetadataType.UUID: {
return isMatchingUUIDFilter({
uuidFilter: filterValue as UUIDFilter,
value: record[filterKey],
});
}
case FieldMetadataType.BOOLEAN: {
return isMatchingBooleanFilter({
booleanFilter: filterValue as BooleanFilter,
value: record[filterKey],
});
}
case FieldMetadataType.CURRENCY: {
return isMatchingCurrencyFilter({
currencyFilter: filterValue as CurrencyFilter,
value: record[filterKey],
});
}
case FieldMetadataType.ACTOR: {
const actorFilter = filterValue as ActorFilter;
return (
actorFilter.name === undefined ||
isMatchingStringFilter({
stringFilter: actorFilter.name,
value: record[filterKey].name,
})
);
}
case FieldMetadataType.EMAILS: {
const emailsFilter = filterValue as EmailsFilter;
if (emailsFilter.primaryEmail === undefined) {
return false;
}
return isMatchingStringFilter({
stringFilter: emailsFilter.primaryEmail,
value: record[filterKey].primaryEmail,
});
}
case FieldMetadataType.PHONES: {
const phonesFilter = filterValue as PhonesFilter;
const keys: (keyof PhonesFilter)[] = ['primaryPhoneNumber'];
return keys.some((key) => {
const value = phonesFilter[key];
if (value === undefined) {
return false;
}
return isMatchingStringFilter({
stringFilter: value,
value: record[filterKey][key],
});
});
}
case FieldMetadataType.RELATION: {
const isJoinColumn =
(
objectMetadataField.settings as
| { joinColumnName?: string }
| undefined
)?.joinColumnName === filterKey;
if (isJoinColumn) {
return isMatchingUUIDFilter({
uuidFilter: filterValue as UUIDFilter,
value: record[filterKey],
});
}
throw new Error(
`Not implemented yet, use UUID filter instead on the corresponding "${filterKey}Id" field`,
);
}
case FieldMetadataType.TS_VECTOR: {
return isMatchingTSVectorFilter({
tsVectorFilter: filterValue as TSVectorFilter,
value: record[filterKey],
});
}
default: {
throw new Error(
`Not implemented yet for field type "${objectMetadataField.type}"`,
);
}
}
});
};
@@ -37,6 +37,7 @@ export const twentyORMGraphqlApiExceptionHandler = (
case TwentyORMExceptionCode.CONNECT_RECORD_NOT_FOUND:
case TwentyORMExceptionCode.CONNECT_NOT_ALLOWED:
case TwentyORMExceptionCode.CONNECT_UNIQUE_CONSTRAINT_ERROR:
case TwentyORMExceptionCode.RLS_VALIDATION_FAILED:
case TwentyORMExceptionCode.TOO_MANY_RECORDS_TO_UPDATE:
throw new UserInputError(error.message, {
userFriendlyMessage: error.userFriendlyMessage,
@@ -0,0 +1,75 @@
/* @license Enterprise */
import { type ObjectLiteral } from 'typeorm';
import { type WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/workspace-internal-context.interface';
import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import {
TwentyORMException,
TwentyORMExceptionCode,
} from 'src/engine/twenty-orm/exceptions/twenty-orm.exception';
import { buildRowLevelPermissionRecordFilter } from 'src/engine/twenty-orm/utils/build-row-level-permission-record-filter.util';
import { isRecordMatchingRLSRowLevelPermissionPredicate } from 'src/engine/twenty-orm/utils/is-record-matching-rls-row-level-permission-predicate.util';
type ValidateRLSPredicatesForRecordsArgs<T extends ObjectLiteral> = {
records: T[];
objectMetadata: FlatObjectMetadata;
internalContext: WorkspaceInternalContext;
authContext: AuthContext;
shouldBypassPermissionChecks: boolean;
errorMessage?: string;
};
export const validateRLSPredicatesForRecords = <T extends ObjectLiteral>({
records,
objectMetadata,
internalContext,
authContext,
shouldBypassPermissionChecks,
errorMessage = 'Record does not satisfy row-level security constraints of your current role',
}: ValidateRLSPredicatesForRecordsArgs<T>): void => {
if (shouldBypassPermissionChecks) {
return;
}
const roleId = authContext.userWorkspaceId
? internalContext.userWorkspaceRoleMap[authContext.userWorkspaceId]
: undefined;
if (!roleId) {
return;
}
const recordFilter = buildRowLevelPermissionRecordFilter({
flatRowLevelPermissionPredicateMaps:
internalContext.flatRowLevelPermissionPredicateMaps,
flatRowLevelPermissionPredicateGroupMaps:
internalContext.flatRowLevelPermissionPredicateGroupMaps,
flatFieldMetadataMaps: internalContext.flatFieldMetadataMaps,
objectMetadata,
roleId,
authContext,
});
if (!recordFilter || Object.keys(recordFilter).length === 0) {
return;
}
for (const record of records) {
const matchesRLS = isRecordMatchingRLSRowLevelPermissionPredicate({
record,
filter: recordFilter,
flatObjectMetadata: objectMetadata,
flatFieldMetadataMaps: internalContext.flatFieldMetadataMaps,
});
if (!matchesRLS) {
throw new TwentyORMException(
errorMessage,
TwentyORMExceptionCode.RLS_VALIDATION_FAILED,
);
}
}
};