RLS FE implementation (#17062)

## Summary
This PR introduces row-level security (RLS) permissions for roles in the
frontend, allowing fine-grained access control at the record level.
Users can now define permission rules that determine which specific
records a role can access based on dynamic conditions and filters.
## What's Changed
Implemented UI for configuring record-level permissions on object
permissions screens
Added support for defining permission predicates using filter conditions
(similar to advanced filters)
Introduced variable picker for dynamic permission rules (e.g., "me"
context for user-specific access)
Built predicate conversion layer to sync UI state with backend
permission structure
Extended GraphQL schema with mutations for upserting row-level
permission predicates
Fixed handling of orphaned RLS groups to prevent data inconsistencies
Added enterprise key validation for RLS features
The implementation enables scenarios like "users can only see their own
records" or "users can access records associated with their team."

<img width="687" height="702" alt="Screenshot 2026-01-09 at 23 07 02"
src="https://github.com/user-attachments/assets/33fe736e-6cbf-40bd-b2eb-c8a90c8d21bc"
/>

---------

Co-authored-by: Charles Bochet <charles@twenty.com>
This commit is contained in:
Weiko
2026-01-12 18:39:17 +01:00
committed by GitHub
parent 493f0575ea
commit c795b8c52a
63 changed files with 3520 additions and 69 deletions
@@ -0,0 +1,273 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
import { validateEnumValueCompatibility } from 'src/engine/twenty-orm/utils/validate-enum-value-compatibility.util';
describe('validateEnumValueCompatibility', () => {
const createMockFieldMetadata = (
type: FieldMetadataType,
options?: Array<{ value: string; label: string }>,
): FlatFieldMetadata => {
return {
id: 'mock-id',
name: 'mockField',
type,
options: options || [],
} as FlatFieldMetadata;
};
describe('when both fields are enum types', () => {
it('should return true when value exists in target field options', () => {
const workspaceMemberField = createMockFieldMetadata(
FieldMetadataType.SELECT,
[
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
],
);
const targetField = createMockFieldMetadata(FieldMetadataType.SELECT, [
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
{ value: 'option3', label: 'Option 3' },
]);
const result = validateEnumValueCompatibility({
workspaceMemberFieldMetadata: workspaceMemberField,
targetFieldMetadata: targetField,
predicateValue: 'option1',
});
expect(result).toBe(true);
});
it('should return false when value does not exist in target field options', () => {
const workspaceMemberField = createMockFieldMetadata(
FieldMetadataType.SELECT,
[
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
],
);
const targetField = createMockFieldMetadata(FieldMetadataType.SELECT, [
{ value: 'option3', label: 'Option 3' },
{ value: 'option4', label: 'Option 4' },
]);
const result = validateEnumValueCompatibility({
workspaceMemberFieldMetadata: workspaceMemberField,
targetFieldMetadata: targetField,
predicateValue: 'option1',
});
expect(result).toBe(false);
});
it('should validate array values for MULTI_SELECT fields', () => {
const workspaceMemberField = createMockFieldMetadata(
FieldMetadataType.MULTI_SELECT,
[
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
],
);
const targetField = createMockFieldMetadata(
FieldMetadataType.MULTI_SELECT,
[
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
{ value: 'option3', label: 'Option 3' },
],
);
const result = validateEnumValueCompatibility({
workspaceMemberFieldMetadata: workspaceMemberField,
targetFieldMetadata: targetField,
predicateValue: ['option1', 'option2'],
});
expect(result).toBe(true);
});
it('should return false when any value in array is invalid', () => {
const workspaceMemberField = createMockFieldMetadata(
FieldMetadataType.MULTI_SELECT,
[
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
],
);
const targetField = createMockFieldMetadata(
FieldMetadataType.MULTI_SELECT,
[
{ value: 'option1', label: 'Option 1' },
{ value: 'option3', label: 'Option 3' },
],
);
const result = validateEnumValueCompatibility({
workspaceMemberFieldMetadata: workspaceMemberField,
targetFieldMetadata: targetField,
predicateValue: ['option1', 'option2'],
});
expect(result).toBe(false);
});
it('should return true when target field has no options', () => {
const workspaceMemberField = createMockFieldMetadata(
FieldMetadataType.SELECT,
[{ value: 'option1', label: 'Option 1' }],
);
const targetField = createMockFieldMetadata(FieldMetadataType.SELECT, []);
const result = validateEnumValueCompatibility({
workspaceMemberFieldMetadata: workspaceMemberField,
targetFieldMetadata: targetField,
predicateValue: 'option1',
});
expect(result).toBe(true);
});
it('should handle SELECT to MULTI_SELECT comparison', () => {
const workspaceMemberField = createMockFieldMetadata(
FieldMetadataType.SELECT,
[{ value: 'option1', label: 'Option 1' }],
);
const targetField = createMockFieldMetadata(
FieldMetadataType.MULTI_SELECT,
[
{ value: 'option1', label: 'Option 1' },
{ value: 'option2', label: 'Option 2' },
],
);
const result = validateEnumValueCompatibility({
workspaceMemberFieldMetadata: workspaceMemberField,
targetFieldMetadata: targetField,
predicateValue: 'option1',
});
expect(result).toBe(true);
});
});
describe('when fields are not both enum types', () => {
it('should return true when workspace member field is not enum', () => {
const workspaceMemberField = createMockFieldMetadata(
FieldMetadataType.TEXT,
);
const targetField = createMockFieldMetadata(FieldMetadataType.SELECT, [
{ value: 'option1', label: 'Option 1' },
]);
const result = validateEnumValueCompatibility({
workspaceMemberFieldMetadata: workspaceMemberField,
targetFieldMetadata: targetField,
predicateValue: 'some text',
});
expect(result).toBe(true);
});
it('should return true when target field is not enum', () => {
const workspaceMemberField = createMockFieldMetadata(
FieldMetadataType.SELECT,
[{ value: 'option1', label: 'Option 1' }],
);
const targetField = createMockFieldMetadata(FieldMetadataType.TEXT);
const result = validateEnumValueCompatibility({
workspaceMemberFieldMetadata: workspaceMemberField,
targetFieldMetadata: targetField,
predicateValue: 'option1',
});
expect(result).toBe(true);
});
it('should return true when neither field is enum', () => {
const workspaceMemberField = createMockFieldMetadata(
FieldMetadataType.TEXT,
);
const targetField = createMockFieldMetadata(FieldMetadataType.TEXT);
const result = validateEnumValueCompatibility({
workspaceMemberFieldMetadata: workspaceMemberField,
targetFieldMetadata: targetField,
predicateValue: 'some text',
});
expect(result).toBe(true);
});
});
describe('edge cases', () => {
it('should handle undefined value', () => {
const workspaceMemberField = createMockFieldMetadata(
FieldMetadataType.SELECT,
[{ value: 'option1', label: 'Option 1' }],
);
const targetField = createMockFieldMetadata(FieldMetadataType.SELECT, [
{ value: 'option1', label: 'Option 1' },
]);
const result = validateEnumValueCompatibility({
workspaceMemberFieldMetadata: workspaceMemberField,
targetFieldMetadata: targetField,
predicateValue: undefined,
});
expect(result).toBe(false);
});
it('should handle null value', () => {
const workspaceMemberField = createMockFieldMetadata(
FieldMetadataType.SELECT,
[{ value: 'option1', label: 'Option 1' }],
);
const targetField = createMockFieldMetadata(FieldMetadataType.SELECT, [
{ value: 'option1', label: 'Option 1' },
]);
const result = validateEnumValueCompatibility({
workspaceMemberFieldMetadata: workspaceMemberField,
targetFieldMetadata: targetField,
predicateValue: null,
});
expect(result).toBe(false);
});
it('should handle empty array', () => {
const workspaceMemberField = createMockFieldMetadata(
FieldMetadataType.MULTI_SELECT,
[{ value: 'option1', label: 'Option 1' }],
);
const targetField = createMockFieldMetadata(
FieldMetadataType.MULTI_SELECT,
[{ value: 'option1', label: 'Option 1' }],
);
const result = validateEnumValueCompatibility({
workspaceMemberFieldMetadata: workspaceMemberField,
targetFieldMetadata: targetField,
predicateValue: [],
});
expect(result).toBe(true);
});
});
});
@@ -59,6 +59,11 @@ export const applyRowLevelPermissionPredicates = <T extends ObjectLiteral>({
return;
}
const isUpdateOrDeleteQuery =
queryBuilder.expressionMap.queryType === 'update' ||
queryBuilder.expressionMap.queryType === 'soft-delete' ||
queryBuilder.expressionMap.queryType === 'delete';
applyObjectRecordFilterToQueryBuilder({
queryBuilder,
objectNameSingular: objectMetadata.nameSingular,
@@ -67,6 +72,7 @@ export const applyRowLevelPermissionPredicates = <T extends ObjectLiteral>({
objectMetadata,
internalContext.flatFieldMetadataMaps,
),
useDirectTableReference: isUpdateOrDeleteQuery,
});
};
@@ -75,11 +81,13 @@ const applyObjectRecordFilterToQueryBuilder = <T extends ObjectLiteral>({
objectNameSingular,
recordFilter,
fieldParser,
useDirectTableReference = false,
}: {
queryBuilder: WorkspaceSelectQueryBuilder<T>;
objectNameSingular: string;
recordFilter: Record<string, unknown>;
fieldParser: GraphqlQueryFilterFieldParser;
useDirectTableReference?: boolean;
}): void => {
if (!recordFilter || Object.keys(recordFilter).length === 0) {
return;
@@ -94,6 +102,7 @@ const applyObjectRecordFilterToQueryBuilder = <T extends ObjectLiteral>({
value,
isFirst: index === 0,
fieldParser,
useDirectTableReference,
});
});
});
@@ -112,6 +121,7 @@ const parseKeyFilter = ({
value,
isFirst,
fieldParser,
useDirectTableReference = false,
}: {
queryBuilder: WhereExpressionBuilder;
objectNameSingular: string;
@@ -120,6 +130,7 @@ const parseKeyFilter = ({
value: any;
isFirst: boolean;
fieldParser: GraphqlQueryFilterFieldParser;
useDirectTableReference?: boolean;
}): void => {
switch (key) {
case 'and': {
@@ -135,6 +146,7 @@ const parseKeyFilter = ({
value: subFilterValue,
isFirst: subIndex === 0,
fieldParser,
useDirectTableReference,
});
},
);
@@ -168,6 +180,7 @@ const parseKeyFilter = ({
value: subFilterValue,
isFirst: subIndex === 0,
fieldParser,
useDirectTableReference,
});
},
);
@@ -200,6 +213,7 @@ const parseKeyFilter = ({
value: subFilterValue,
isFirst: subIndex === 0,
fieldParser,
useDirectTableReference,
});
},
);
@@ -214,7 +228,14 @@ const parseKeyFilter = ({
break;
}
default:
fieldParser.parse(queryBuilder, objectNameSingular, key, value, isFirst);
fieldParser.parse(
queryBuilder,
objectNameSingular,
key,
value,
isFirst,
useDirectTableReference,
);
break;
}
};
@@ -1,6 +1,7 @@
/* @license Enterprise */
import {
FieldMetadataType,
RecordFilterGroupLogicalOperator,
type CompositeFieldSubFieldName,
type PartialFieldMetadataItemOption,
@@ -28,6 +29,7 @@ import { RowLevelPermissionPredicateGroupLogicalOperator } from 'src/engine/meta
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 = {
flatRowLevelPermissionPredicateMaps: FlatRowLevelPermissionPredicateMaps;
@@ -116,10 +118,29 @@ export const buildRowLevelPermissionRecordFilter = ({
}
if (!isDefined(predicateValue)) {
throw new PermissionsException(
`Workspace member data missing for field ${workspaceMemberFieldMetadata.name}`,
PermissionsExceptionCode.INVALID_ARG,
);
return null;
}
// Validate that workspace member enum value is compatible with target field enum options
const isEnumValueCompatible = validateEnumValueCompatibility({
workspaceMemberFieldMetadata,
targetFieldMetadata: fieldMetadata,
predicateValue,
});
if (!isEnumValueCompatible) {
return null;
}
// When workspace member field is SELECT or MULTI_SELECT and value is a string,
// wrap it in an array to match the frontend format (which uses multi-select UI)
if (
(workspaceMemberFieldMetadata.type === FieldMetadataType.SELECT ||
workspaceMemberFieldMetadata.type ===
FieldMetadataType.MULTI_SELECT) &&
typeof predicateValue === 'string'
) {
predicateValue = [predicateValue];
}
}
@@ -127,10 +148,12 @@ export const buildRowLevelPermissionRecordFilter = ({
| CompositeFieldSubFieldName
| undefined;
let filterValue = convertViewFilterValueToString(predicateValue);
return {
id: predicate.id,
fieldMetadataId: predicate.fieldMetadataId,
value: convertViewFilterValueToString(predicateValue),
value: filterValue,
type: getFilterTypeFromFieldType(fieldMetadata.type),
operand: predicate.operand as unknown as RecordFilter['operand'],
recordFilterGroupId: predicate.rowLevelPermissionPredicateGroupId,
@@ -0,0 +1,47 @@
import { FieldMetadataType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { type FlatFieldMetadata } from 'src/engine/metadata-modules/flat-field-metadata/types/flat-field-metadata.type';
type ValidateEnumValueCompatibilityArgs = {
workspaceMemberFieldMetadata: FlatFieldMetadata;
targetFieldMetadata: FlatFieldMetadata;
predicateValue: unknown;
};
export const validateEnumValueCompatibility = ({
workspaceMemberFieldMetadata,
targetFieldMetadata,
predicateValue,
}: ValidateEnumValueCompatibilityArgs): boolean => {
const isWorkspaceMemberFieldEnum =
workspaceMemberFieldMetadata.type === FieldMetadataType.SELECT ||
workspaceMemberFieldMetadata.type === FieldMetadataType.MULTI_SELECT;
const isTargetFieldEnum =
targetFieldMetadata.type === FieldMetadataType.SELECT ||
targetFieldMetadata.type === FieldMetadataType.MULTI_SELECT;
if (!isWorkspaceMemberFieldEnum || !isTargetFieldEnum) {
return true;
}
const targetFieldOptions = targetFieldMetadata.options || [];
const validTargetValues = new Set(
targetFieldOptions.map((option) => option.value),
);
if (validTargetValues.size === 0) {
return true;
}
const valuesToCheck = Array.isArray(predicateValue)
? predicateValue
: [predicateValue];
const allValuesAreValid = valuesToCheck.every(
(value) => isDefined(value) && validTargetValues.has(String(value)),
);
return allValuesAreValid;
};