Compare commits

...

2 Commits

Author SHA1 Message Date
Weiko 24b074f436 fix 2026-03-11 15:32:57 +01:00
Weiko 181b48105e RLS: Allow Relation predicates other than workspace member 2026-03-11 14:16:53 +01:00
11 changed files with 250 additions and 111 deletions
@@ -13,6 +13,7 @@ import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/uti
import { buildRecordInputFromFilter } from '@/object-record/record-table/utils/buildRecordInputFromFilter';
import { buildCompositeValueFromSubField } from '@/object-record/record-table/utils/buildValueFromFilter';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { FieldMetadataType } from 'twenty-shared/types';
import { isUndefined } from '@sniptt/guards';
import { useAtomStateValue } from '@/ui/utilities/state/jotai/hooks/useAtomStateValue';
import { isDefined, isPlainObject } from 'twenty-shared/utils';
@@ -76,8 +77,19 @@ export const useBuildRecordInputFromRLSPredicates = ({
);
}
let workspaceMemberFieldValue =
currentWorkspaceMemberRecord?.[workspaceMemberFieldMetadataItem.name];
let workspaceMemberFieldValue: unknown;
if (workspaceMemberFieldMetadataItem.type === FieldMetadataType.RELATION) {
const joinColumnName =
workspaceMemberFieldMetadataItem.settings?.joinColumnName ??
`${workspaceMemberFieldMetadataItem.name}Id`;
workspaceMemberFieldValue =
currentWorkspaceMemberRecord?.[joinColumnName];
} else {
workspaceMemberFieldValue =
currentWorkspaceMemberRecord?.[workspaceMemberFieldMetadataItem.name];
}
if (isCompositeFieldType(workspaceMemberFieldMetadataItem.type)) {
if (!workspaceMemberSubFieldName) {
@@ -3,7 +3,6 @@
import { useLingui } from '@lingui/react/macro';
import { getFilterTypeFromFieldType } from 'twenty-shared/utils';
import { CoreObjectNameSingular, FieldMetadataType } from 'twenty-shared/types';
import { type FieldMetadataItem } from '@/object-metadata/types/FieldMetadataItem';
import { AdvancedFilterFieldSelectSearchInput } from '@/object-record/advanced-filter/components/AdvancedFilterFieldSelectSearchInput';
import { useAdvancedFilterFieldSelectDropdown } from '@/object-record/advanced-filter/hooks/useAdvancedFilterFieldSelectDropdown';
@@ -60,12 +59,9 @@ export const SettingsRolePermissionsObjectLevelRecordLevelPermissionFieldSelectF
fieldMetadataItem.label
.toLocaleLowerCase()
.includes(objectFilterDropdownSearchInput.toLocaleLowerCase()) &&
(RECORD_LEVEL_PERMISSION_PREDICATE_FIELD_TYPES.includes(
RECORD_LEVEL_PERMISSION_PREDICATE_FIELD_TYPES.includes(
fieldMetadataItem.type,
) ||
(fieldMetadataItem.type === FieldMetadataType.RELATION &&
fieldMetadataItem.relation?.targetObjectMetadata.nameSingular ===
CoreObjectNameSingular.WorkspaceMember)),
),
)
.sort((a, b) => a.label.localeCompare(b.label));
@@ -137,6 +137,17 @@ export const SettingsRolePermissionsObjectLevelRecordLevelPermissionMeValueSelec
}
if (field.type === targetFieldType) {
if (
targetFieldType === FieldMetadataType.RELATION &&
isDefined(selectedFieldMetadataItem)
) {
return (
field.relation?.targetObjectMetadata.nameSingular ===
selectedFieldMetadataItem.relation?.targetObjectMetadata
.nameSingular
);
}
return true;
}
@@ -20,6 +20,7 @@ import { getCompositeSubFieldType } from '@/object-record/object-filter-dropdown
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
import { FormFieldInput } from '@/object-record/record-field/ui/components/FormFieldInput';
import { FormMultiSelectFieldInput } from '@/object-record/record-field/ui/form-types/components/FormMultiSelectFieldInput';
import { FormRelationToOneFieldInput } from '@/object-record/record-field/ui/form-types/components/FormRelationToOneFieldInput';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { type CompositeFieldType } from '@/settings/data-model/types/CompositeFieldType';
import { createRecordLevelPermissionVariablePicker } from '@/settings/roles/role-permissions/object-level-permissions/record-level-permissions/components/SettingsRolePermissionsObjectLevelRecordLevelPermissionVariablePicker';
@@ -318,6 +319,58 @@ export const SettingsRolePermissionsObjectLevelRecordLevelPermissionValueInput =
);
}
if (fieldMetadataItem.type === FieldMetadataType.RELATION) {
const targetObjectNameSingular =
fieldMetadataItem.relation?.targetObjectMetadata.nameSingular;
let defaultRecordId: string | undefined;
if (isDefined(recordFilter.value) && recordFilter.value !== '') {
try {
const parsed = JSON.parse(recordFilter.value);
if (
isDefined(parsed) &&
Array.isArray(parsed.selectedRecordIds) &&
parsed.selectedRecordIds.length > 0
) {
defaultRecordId = parsed.selectedRecordIds[0];
}
} catch {
// value may not be valid JSON yet
}
}
const handleRelationChange = (value: JsonValue) => {
if (!isDefined(value) || value === '') {
applyObjectFilterDropdownFilterValue('');
return;
}
const filterValue = JSON.stringify({
isCurrentWorkspaceMemberSelected: false,
selectedRecordIds: [value],
});
applyObjectFilterDropdownFilterValue(filterValue);
};
return (
<StyledContainer>
<StyledFormFieldInputWrapper>
<FormRelationToOneFieldInput
label=""
objectNameSingular={targetObjectNameSingular}
defaultValue={defaultRecordId}
onChange={handleRelationChange}
VariablePicker={RecordLevelPermissionPicker}
/>
</StyledFormFieldInputWrapper>
</StyledContainer>
);
}
return (
<StyledContainer>
<StyledFormFieldInputWrapper>
@@ -14,4 +14,5 @@ export const RECORD_LEVEL_PERMISSION_PREDICATE_FIELD_TYPES = [
FieldMetadataType.EMAILS,
FieldMetadataType.FULL_NAME,
FieldMetadataType.ADDRESS,
FieldMetadataType.RELATION,
];
@@ -1,10 +1,6 @@
/* @license Enterprise */
import {
FieldMetadataType,
RecordFilterGroupLogicalOperator,
CoreObjectNameSingular,
} from 'twenty-shared/types';
import { RecordFilterGroupLogicalOperator } from 'twenty-shared/types';
import { getFilterTypeFromFieldType, isDefined } from 'twenty-shared/utils';
import { v4 } from 'uuid';
@@ -60,13 +56,8 @@ export const useRecordLevelPermissionFilterActions = ({
const getDefaultFieldMetadataItemForRLS = () => {
const availableFieldMetadataItemsForRls =
availableFieldMetadataItemsForFilter.filter((fieldMetadataItem) => {
return (
RECORD_LEVEL_PERMISSION_PREDICATE_FIELD_TYPES.includes(
fieldMetadataItem.type,
) ||
(fieldMetadataItem.type === FieldMetadataType.RELATION &&
fieldMetadataItem.relation?.targetObjectMetadata.nameSingular ===
CoreObjectNameSingular.WorkspaceMember)
return RECORD_LEVEL_PERMISSION_PREDICATE_FIELD_TYPES.includes(
fieldMetadataItem.type,
);
});
@@ -48,18 +48,25 @@ export const applyRowLevelPermissionPredicates = <T extends ObjectLiteral>({
? internalContext.userWorkspaceRoleMap[userWorkspaceId]
: undefined;
const recordFilter = buildRowLevelPermissionRecordFilter({
flatRowLevelPermissionPredicateMaps:
internalContext.flatRowLevelPermissionPredicateMaps,
flatRowLevelPermissionPredicateGroupMaps:
internalContext.flatRowLevelPermissionPredicateGroupMaps,
flatFieldMetadataMaps: internalContext.flatFieldMetadataMaps,
objectMetadata,
roleId,
workspaceMember: isUserAuthContext(authContext)
? authContext.workspaceMember
: undefined,
});
const { filter: recordFilter, hasUnresolvablePredicate } =
buildRowLevelPermissionRecordFilter({
flatRowLevelPermissionPredicateMaps:
internalContext.flatRowLevelPermissionPredicateMaps,
flatRowLevelPermissionPredicateGroupMaps:
internalContext.flatRowLevelPermissionPredicateGroupMaps,
flatFieldMetadataMaps: internalContext.flatFieldMetadataMaps,
objectMetadata,
roleId,
workspaceMember: isUserAuthContext(authContext)
? authContext.workspaceMember
: undefined,
});
if (hasUnresolvablePredicate) {
queryBuilder.andWhere('1 = 0');
return;
}
if (!recordFilter || Object.keys(recordFilter).length === 0) {
return;
@@ -41,6 +41,11 @@ type BuildRowLevelPermissionRecordFilterArgs = {
workspaceMember?: UserWorkspaceAuthContext['workspaceMember'];
};
type BuildRowLevelPermissionRecordFilterResult = {
filter: RecordGqlOperationFilter | null;
hasUnresolvablePredicate: boolean;
};
export const buildRowLevelPermissionRecordFilter = ({
flatRowLevelPermissionPredicateMaps,
flatRowLevelPermissionPredicateGroupMaps,
@@ -48,9 +53,9 @@ export const buildRowLevelPermissionRecordFilter = ({
objectMetadata,
roleId,
workspaceMember,
}: BuildRowLevelPermissionRecordFilterArgs): RecordGqlOperationFilter | null => {
}: BuildRowLevelPermissionRecordFilterArgs): BuildRowLevelPermissionRecordFilterResult => {
if (!isDefined(roleId)) {
return null;
return { filter: null, hasUnresolvablePredicate: false };
}
const predicates = Object.values(
@@ -65,9 +70,11 @@ export const buildRowLevelPermissionRecordFilter = ({
);
if (predicates.length === 0) {
return null;
return { filter: null, hasUnresolvablePredicate: false };
}
let hasUnresolvablePredicate = false;
const recordFilters = predicates
.map((predicate) => {
const fieldMetadata = findFlatEntityByIdInFlatEntityMaps({
@@ -105,51 +112,84 @@ export const buildRowLevelPermissionRecordFilter = ({
return null;
}
const rawWorkspaceMemberValue = Object.entries(workspaceMember).find(
([key]) => key === workspaceMemberFieldMetadata.name,
)?.[1];
if (workspaceMemberFieldMetadata.type === FieldMetadataType.RELATION) {
const joinColumnName = (
workspaceMemberFieldMetadata.settings as {
joinColumnName?: string;
} | null
)?.joinColumnName;
const workspaceMemberSubFieldName =
predicate.workspaceMemberSubFieldName;
if (!isDefined(joinColumnName)) {
return null;
}
if (!isDefined(rawWorkspaceMemberValue)) {
return null;
}
const workspaceMemberJoinColumnValue = Object.entries(
workspaceMember,
).find(([key]) => key === joinColumnName)?.[1];
if (
isDefined(workspaceMemberSubFieldName) &&
isCompositeFieldMetadataType(workspaceMemberFieldMetadata.type) &&
typeof rawWorkspaceMemberValue === 'object'
) {
predicateValue = rawWorkspaceMemberValue[workspaceMemberSubFieldName];
if (
!isDefined(workspaceMemberJoinColumnValue) ||
typeof workspaceMemberJoinColumnValue !== 'string'
) {
hasUnresolvablePredicate = true;
return null;
}
predicateValue = {
selectedRecordIds: [workspaceMemberJoinColumnValue],
};
} else {
predicateValue = rawWorkspaceMemberValue;
}
const rawWorkspaceMemberValue = Object.entries(workspaceMember).find(
([key]) => key === workspaceMemberFieldMetadata.name,
)?.[1];
if (!isDefined(predicateValue)) {
return null;
}
const workspaceMemberSubFieldName =
predicate.workspaceMemberSubFieldName;
// Validate that workspace member enum value is compatible with target field enum options
const isEnumValueCompatible = validateEnumValueCompatibility({
workspaceMemberFieldMetadata,
targetFieldMetadata: fieldMetadata,
predicateValue,
});
if (!isDefined(rawWorkspaceMemberValue)) {
hasUnresolvablePredicate = true;
if (!isEnumValueCompatible) {
return null;
}
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];
if (
isDefined(workspaceMemberSubFieldName) &&
isCompositeFieldMetadataType(workspaceMemberFieldMetadata.type) &&
typeof rawWorkspaceMemberValue === 'object'
) {
predicateValue =
rawWorkspaceMemberValue[workspaceMemberSubFieldName];
} else {
predicateValue = rawWorkspaceMemberValue;
}
if (!isDefined(predicateValue)) {
hasUnresolvablePredicate = true;
return null;
}
const isEnumValueCompatible = validateEnumValueCompatibility({
workspaceMemberFieldMetadata,
targetFieldMetadata: fieldMetadata,
predicateValue,
});
if (!isEnumValueCompatible) {
hasUnresolvablePredicate = true;
return null;
}
if (
(workspaceMemberFieldMetadata.type === FieldMetadataType.SELECT ||
workspaceMemberFieldMetadata.type ===
FieldMetadataType.MULTI_SELECT) &&
typeof predicateValue === 'string'
) {
predicateValue = [predicateValue];
}
}
}
@@ -232,12 +272,15 @@ export const buildRowLevelPermissionRecordFilter = ({
options: field.options as PartialFieldMetadataItemOption[],
}));
return computeRecordGqlOperationFilter({
recordFilters,
recordFilterGroups,
fields: fieldMetadataItems,
filterValueDependencies: {
currentWorkspaceMemberId: workspaceMember?.id,
},
});
return {
filter: computeRecordGqlOperationFilter({
recordFilters,
recordFilterGroups,
fields: fieldMetadataItems,
filterValueDependencies: {
currentWorkspaceMemberId: workspaceMember?.id,
},
}),
hasUnresolvablePredicate,
};
};
@@ -47,18 +47,26 @@ export const validateRLSPredicatesForRecords = <T extends ObjectLiteral>({
return;
}
const recordFilter = buildRowLevelPermissionRecordFilter({
flatRowLevelPermissionPredicateMaps:
internalContext.flatRowLevelPermissionPredicateMaps,
flatRowLevelPermissionPredicateGroupMaps:
internalContext.flatRowLevelPermissionPredicateGroupMaps,
flatFieldMetadataMaps: internalContext.flatFieldMetadataMaps,
objectMetadata,
roleId,
workspaceMember: isUserAuthContext(authContext)
? authContext.workspaceMember
: undefined,
});
const { filter: recordFilter, hasUnresolvablePredicate } =
buildRowLevelPermissionRecordFilter({
flatRowLevelPermissionPredicateMaps:
internalContext.flatRowLevelPermissionPredicateMaps,
flatRowLevelPermissionPredicateGroupMaps:
internalContext.flatRowLevelPermissionPredicateGroupMaps,
flatFieldMetadataMaps: internalContext.flatFieldMetadataMaps,
objectMetadata,
roleId,
workspaceMember: isUserAuthContext(authContext)
? authContext.workspaceMember
: undefined,
});
if (hasUnresolvablePredicate) {
throw new TwentyORMException(
errorMessage,
TwentyORMExceptionCode.RLS_VALIDATION_FAILED,
);
}
if (!recordFilter || Object.keys(recordFilter).length === 0) {
return;
@@ -265,7 +265,10 @@ describe('WorkspaceEventEmitterService', () => {
}),
};
(buildRowLevelPermissionRecordFilter as jest.Mock).mockReturnValue({});
(buildRowLevelPermissionRecordFilter as jest.Mock).mockReturnValue({
filter: {},
hasUnresolvablePredicate: false,
});
(
isRecordMatchingRLSRowLevelPermissionPredicate as jest.Mock
).mockReturnValue(true);
@@ -752,9 +755,10 @@ describe('WorkspaceEventEmitterService', () => {
it('should combine query filter with RLS filter', async () => {
const rlsFilter: RecordGqlOperationFilter = { status: { eq: 'active' } };
(buildRowLevelPermissionRecordFilter as jest.Mock).mockReturnValue(
rlsFilter,
);
(buildRowLevelPermissionRecordFilter as jest.Mock).mockReturnValue({
filter: rlsFilter,
hasUnresolvablePredicate: false,
});
const streamDataWithFilter: EventStreamData = {
...mockStreamData,
@@ -1020,9 +1024,10 @@ describe('WorkspaceEventEmitterService', () => {
assigneeId: { eq: workspaceMemberId },
};
(buildRowLevelPermissionRecordFilter as jest.Mock).mockReturnValue(
rlsFilter,
);
(buildRowLevelPermissionRecordFilter as jest.Mock).mockReturnValue({
filter: rlsFilter,
hasUnresolvablePredicate: false,
});
const streamDataWithWorkspaceMember: EventStreamData = {
authContext: {
@@ -1104,9 +1109,10 @@ describe('WorkspaceEventEmitterService', () => {
locale: { eq: 'en' },
};
(buildRowLevelPermissionRecordFilter as jest.Mock).mockReturnValue(
rlsFilter,
);
(buildRowLevelPermissionRecordFilter as jest.Mock).mockReturnValue({
filter: rlsFilter,
hasUnresolvablePredicate: false,
});
const streamDataWithWorkspaceMember: EventStreamData = {
authContext: {
@@ -1169,7 +1175,10 @@ describe('WorkspaceEventEmitterService', () => {
idByUserId: {},
};
(buildRowLevelPermissionRecordFilter as jest.Mock).mockReturnValue({});
(buildRowLevelPermissionRecordFilter as jest.Mock).mockReturnValue({
filter: {},
hasUnresolvablePredicate: false,
});
const streamDataWithWorkspaceMember: EventStreamData = {
authContext: {
@@ -307,13 +307,18 @@ export class WorkspaceEventEmitterService {
const objectNameSingular = workspaceEventBatch.objectMetadata.nameSingular;
const subscriberRLSFilter = this.buildSubscriberRLSFilter(
streamData.authContext,
roleId,
workspaceEventBatch.objectMetadata,
permissionsContext,
flatWorkspaceMemberMaps,
);
const { filter: subscriberRLSFilter, hasUnresolvablePredicate } =
this.buildSubscriberRLSFilter(
streamData.authContext,
roleId,
workspaceEventBatch.objectMetadata,
permissionsContext,
flatWorkspaceMemberMaps,
);
if (hasUnresolvablePredicate) {
return;
}
const restrictedFields = objectPermissions.restrictedFields;
@@ -486,7 +491,10 @@ export class WorkspaceEventEmitterService {
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
},
flatWorkspaceMemberMaps: FlatWorkspaceMemberMaps,
): RecordGqlOperationFilter | null {
): {
filter: RecordGqlOperationFilter | null;
hasUnresolvablePredicate: boolean;
} {
const workspaceMember = isDefined(subscriberAuthContext.workspaceMemberId)
? flatWorkspaceMemberMaps.byId[subscriberAuthContext.workspaceMemberId]
: undefined;