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