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
File diff suppressed because one or more lines are too long
@@ -3023,6 +3023,8 @@ export type ObjectPermission = {
canUpdateObjectRecords?: Maybe<Scalars['Boolean']>;
objectMetadataId: Scalars['UUID'];
restrictedFields?: Maybe<Scalars['JSON']>;
rowLevelPermissionPredicateGroups?: Maybe<Array<RowLevelPermissionPredicateGroup>>;
rowLevelPermissionPredicates?: Maybe<Array<RowLevelPermissionPredicate>>;
};
export type ObjectPermissionInput = {
@@ -38,6 +38,8 @@ describe('mapObjectMetadataToGraphQLQuery', () => {
canDestroyObjectRecords: true,
objectMetadataId: personObjectMetadataItem.id,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
},
},
});
@@ -54,7 +56,7 @@ describe('mapObjectMetadataToGraphQLQuery', () => {
primaryEmail
additionalEmails
}
phone
phone
{
primaryPhoneNumber
primaryPhoneCountryCode
@@ -142,6 +144,8 @@ describe('mapObjectMetadataToGraphQLQuery', () => {
canDestroyObjectRecords: true,
objectMetadataId: personObjectMetadataItem.id,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
},
},
});
@@ -22,6 +22,8 @@ export const getObjectPermissionsForObject = (
canDestroyObjectRecords: true,
restrictedFields: {},
objectMetadataId,
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
};
}
@@ -33,5 +35,9 @@ export const getObjectPermissionsForObject = (
canDestroyObjectRecords: objectPermissions.canDestroyObjectRecords ?? true,
restrictedFields: objectPermissions.restrictedFields ?? {},
objectMetadataId,
rowLevelPermissionPredicates:
objectPermissions.rowLevelPermissionPredicates ?? [],
rowLevelPermissionPredicateGroups:
objectPermissions.rowLevelPermissionPredicateGroups ?? [],
};
};
@@ -0,0 +1,121 @@
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { convertPredicateToRecordFilter } from '@/settings/roles/role-permissions/object-level-permissions/record-level-permissions/utils/recordLevelPermissionPredicateConversion';
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { useObjectMetadataItem } from '@/object-metadata/hooks/useObjectMetadataItem';
import { CoreObjectNameSingular } from '@/object-metadata/types/CoreObjectNameSingular';
import { getObjectPermissionsForObject } from '@/object-metadata/utils/getObjectPermissionsForObject';
import { useFindOneRecord } from '@/object-record/hooks/useFindOneRecord';
import { useObjectPermissions } from '@/object-record/hooks/useObjectPermissions';
import { buildRecordInputFromFilter } from '@/object-record/record-table/utils/buildRecordInputFromFilter';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { isUndefined } from '@sniptt/guards';
import { useRecoilValue } from 'recoil';
import { RelationType } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
export const useBuildRecordInputFromRLSPredicates = ({
objectMetadataItem,
}: {
objectMetadataItem: ObjectMetadataItem;
}) => {
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const { record: currentWorkspaceMemberRecord } = useFindOneRecord({
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
objectRecordId: currentWorkspaceMember?.id,
});
const { objectMetadataItem: workspaceMemberObjectMetadataItem } =
useObjectMetadataItem({
objectNameSingular: CoreObjectNameSingular.WorkspaceMember,
});
const { objectPermissionsByObjectMetadataId } = useObjectPermissions();
const objectPermissions = getObjectPermissionsForObject(
objectPermissionsByObjectMetadataId,
objectMetadataItem.id,
);
const buildRecordInputFromRLSPredicates = (): Partial<ObjectRecord> => {
const recordInput: Partial<ObjectRecord> = {};
const rlsPredicates = objectPermissions.rowLevelPermissionPredicates.filter(
(predicate) => predicate.objectMetadataId === objectMetadataItem.id,
);
const fieldMetadataItemMap = new Map(
objectMetadataItem.fields.map((field) => [field.id, field]),
);
const rlsPredicatesAsRecordFilters = rlsPredicates
.map((predicate) =>
convertPredicateToRecordFilter(
predicate,
fieldMetadataItemMap.get(predicate.fieldMetadataId),
),
)
.filter(isDefined);
rlsPredicatesAsRecordFilters.forEach((filter) => {
const fieldMetadataItem = fieldMetadataItemMap.get(
filter.fieldMetadataId,
);
if (!isDefined(fieldMetadataItem)) {
return;
}
if (isDefined(filter.rlsDynamicValue)) {
const workspaceMemberFieldMetadataItem =
workspaceMemberObjectMetadataItem?.fields.find(
(field) =>
field.id ===
filter.rlsDynamicValue?.workspaceMemberFieldMetadataId,
);
if (!isDefined(workspaceMemberFieldMetadataItem)) {
throw new Error(
`Workspace member field metadata item not found for id: ${filter.rlsDynamicValue?.workspaceMemberFieldMetadataId}`,
);
}
const recordInputField =
fieldMetadataItem.type === 'RELATION' &&
fieldMetadataItem.settings?.relationType === RelationType.MANY_TO_ONE
? `${fieldMetadataItem.name}Id`
: fieldMetadataItem.name;
const currentWorkspaceMemberFieldValue =
currentWorkspaceMemberRecord?.[workspaceMemberFieldMetadataItem.name];
if (isUndefined(currentWorkspaceMemberFieldValue)) {
throw new Error(
`Current workspace member field value not found for field: ${workspaceMemberFieldMetadataItem.name}`,
);
}
recordInput[recordInputField] = currentWorkspaceMemberFieldValue;
}
});
// Only process filters without rlsDynamicValue in buildRecordInputFromFilter
// Filters with rlsDynamicValue are already handled above
const staticFilters = rlsPredicatesAsRecordFilters.filter(
(filter) => !isDefined(filter.rlsDynamicValue),
);
const recordInputFromFilters = buildRecordInputFromFilter({
currentRecordFilters: staticFilters,
objectMetadataItem,
currentWorkspaceMember: currentWorkspaceMember ?? undefined,
});
return {
...recordInput,
...recordInputFromFilters,
};
};
return { buildRecordInputFromRLSPredicates };
};
@@ -122,6 +122,8 @@ const meta: Meta<typeof RecordCalendarMonth> = {
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: true,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
},
}}
>
@@ -1,20 +1,8 @@
import { isObject } from '@sniptt/guards';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { isMatchingArrayFilter } from '@/object-record/record-filter/utils/isMatchingArrayFilter';
import { isMatchingBooleanFilter } from '@/object-record/record-filter/utils/isMatchingBooleanFilter';
import { isMatchingCurrencyFilter } from '@/object-record/record-filter/utils/isMatchingCurrencyFilter';
import { isMatchingDateFilter } from '@/object-record/record-filter/utils/isMatchingDateFilter';
import { isMatchingFloatFilter } from '@/object-record/record-filter/utils/isMatchingFloatFilter';
import { isMatchingMultiSelectFilter } from '@/object-record/record-filter/utils/isMatchingMultiSelectFilter';
import { isMatchingRatingFilter } from '@/object-record/record-filter/utils/isMatchingRatingFilter';
import { isMatchingRawJsonFilter } from '@/object-record/record-filter/utils/isMatchingRawJsonFilter';
import { isMatchingRichTextV2Filter } from '@/object-record/record-filter/utils/isMatchingRichTextV2Filter';
import { isMatchingSelectFilter } from '@/object-record/record-filter/utils/isMatchingSelectFilter';
import { isMatchingStringFilter } from '@/object-record/record-filter/utils/isMatchingStringFilter';
import { isMatchingTSVectorFilter } from '@/object-record/record-filter/utils/isMatchingTSVectorFilter';
import { isMatchingUUIDFilter } from '@/object-record/record-filter/utils/isMatchingUUIDFilter';
import {
FieldMetadataType,
type ActorFilter,
type AddressFilter,
type AndObjectRecordFilter,
@@ -40,8 +28,23 @@ import {
type TSVectorFilter,
type UUIDFilter,
} from 'twenty-shared/types';
import { isDefined, isEmptyObject } from 'twenty-shared/utils';
import { FieldMetadataType } from '~/generated-metadata/graphql';
import {
isDefined,
isEmptyObject,
isMatchingArrayFilter,
isMatchingBooleanFilter,
isMatchingCurrencyFilter,
isMatchingDateFilter,
isMatchingFloatFilter,
isMatchingMultiSelectFilter,
isMatchingRatingFilter,
isMatchingRawJsonFilter,
isMatchingRichTextV2Filter,
isMatchingSelectFilter,
isMatchingStringFilter,
isMatchingTSVectorFilter,
isMatchingUUIDFilter,
} from 'twenty-shared/utils';
const isLeafFilter = (
filter: RecordGqlOperationFilter,
@@ -49,6 +49,8 @@ const makeObjectPermissions = (
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: true,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
...overrides,
};
};
@@ -1,12 +1,11 @@
import { currentWorkspaceMemberState } from '@/auth/states/currentWorkspaceMemberState';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { currentRecordFiltersComponentState } from '@/object-record/record-filter/states/currentRecordFiltersComponentState';
import { buildValueFromFilter } from '@/object-record/record-table/utils/buildRecordInputFromFilter';
import { buildRecordInputFromFilter } from '@/object-record/record-table/utils/buildRecordInputFromFilter';
import { type ObjectRecord } from '@/object-record/types/ObjectRecord';
import { useRecoilComponentValue } from '@/ui/utilities/state/component-state/hooks/useRecoilComponentValue';
import { useRecoilValue } from 'recoil';
import { isDefined } from 'twenty-shared/utils';
export const useBuildRecordInputFromFilters = ({
objectMetadataItem,
@@ -21,37 +20,11 @@ export const useBuildRecordInputFromFilters = ({
const currentWorkspaceMember = useRecoilValue(currentWorkspaceMemberState);
const buildRecordInputFromFilters = (): Partial<ObjectRecord> => {
const recordInput: Partial<ObjectRecord> = {};
currentRecordFilters.forEach((filter) => {
const fieldMetadataItem = objectMetadataItem.fields.find(
(field) => field.id === filter.fieldMetadataId,
);
if (!isDefined(fieldMetadataItem)) {
return;
}
if (fieldMetadataItem.type === 'RELATION') {
const value = buildValueFromFilter({
filter,
options: fieldMetadataItem.options ?? undefined,
relationType: fieldMetadataItem.relation?.type,
currentWorkspaceMember: currentWorkspaceMember ?? undefined,
label: filter.label,
});
if (!isDefined(value)) {
return;
}
recordInput[`${fieldMetadataItem.name}Id`] = value;
} else {
recordInput[fieldMetadataItem.name] = buildValueFromFilter({
filter,
options: fieldMetadataItem.options ?? undefined,
});
}
return buildRecordInputFromFilter({
currentRecordFilters,
objectMetadataItem,
currentWorkspaceMember: currentWorkspaceMember ?? undefined,
});
return recordInput;
};
return { buildRecordInputFromFilters };
@@ -2,6 +2,7 @@ import { useCommandMenu } from '@/command-menu/hooks/useCommandMenu';
import { useOpenRecordInCommandMenu } from '@/command-menu/hooks/useOpenRecordInCommandMenu';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { getLabelIdentifierFieldMetadataItem } from '@/object-metadata/utils/getLabelIdentifierFieldMetadataItem';
import { useBuildRecordInputFromRLSPredicates } from '@/object-record/hooks/useBuildRecordInputFromRLSPredicates';
import { useCreateOneRecord } from '@/object-record/hooks/useCreateOneRecord';
import { recordGroupDefinitionsComponentSelector } from '@/object-record/record-group/states/selectors/recordGroupDefinitionsComponentSelector';
import { recordIndexGroupFieldMetadataItemComponentState } from '@/object-record/record-index/states/recordIndexGroupFieldMetadataComponentState';
@@ -63,10 +64,17 @@ export const useCreateNewIndexRecord = ({
objectMetadataItem,
});
const { buildRecordInputFromRLSPredicates } =
useBuildRecordInputFromRLSPredicates({
objectMetadataItem,
});
const createNewIndexRecord = useRecoilCallback(
({ snapshot, set }) =>
async (recordInput?: Partial<ObjectRecord>) => {
const recordId = v4();
const recordInputFromRLSPredicates =
buildRecordInputFromRLSPredicates();
const recordInputFromFilters = buildRecordInputFromFilters();
const recordIndexOpenRecordIn = snapshot
@@ -75,6 +83,7 @@ export const useCreateNewIndexRecord = ({
const createdRecord = await createOneRecord({
id: recordId,
...recordInputFromRLSPredicates,
...recordInputFromFilters,
...recordInput,
});
@@ -163,6 +172,7 @@ export const useCreateNewIndexRecord = ({
return createdRecord;
},
[
buildRecordInputFromRLSPredicates,
buildRecordInputFromFilters,
createOneRecord,
navigate,
@@ -1,12 +1,12 @@
import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem';
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { buildValueFromFilter } from '@/object-record/record-table/utils/buildValueFromFilter';
import { type ColorScheme } from '@/workspace-member/types/WorkspaceMember';
import {
type FilterableFieldType,
ViewFilterOperand,
} from 'twenty-shared/types';
import { RelationType } from '~/generated-metadata/graphql';
import { buildValueFromFilter } from './buildRecordInputFromFilter';
// TODO: fix the dates, and test the not supported types
const mockDate = new Date('2024-03-20T12:00:00Z');
@@ -1,335 +1,48 @@
import { type CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState';
import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem';
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
import { type ObjectMetadataItem } from '@/object-metadata/types/ObjectMetadataItem';
import { type RecordFilter } from '@/object-record/record-filter/types/RecordFilter';
import { buildValueFromFilter } from '@/object-record/record-table/utils/buildValueFromFilter';
import { type ObjectRecord } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
type RecordFilter,
type RecordFilterToRecordInputOperand,
} from '@/object-record/record-filter/types/RecordFilter';
import { FILTER_OPERANDS_MAP } from '@/object-record/record-filter/utils/getRecordFilterOperands';
import { ViewFilterOperand } from 'twenty-shared/types';
import { assertUnreachable, parseJson } from 'twenty-shared/utils';
import { RelationType } from '~/generated-metadata/graphql';
export const buildValueFromFilter = ({
filter,
options,
relationType,
export const buildRecordInputFromFilter = ({
currentRecordFilters,
objectMetadataItem,
currentWorkspaceMember,
label,
}: {
filter: RecordFilter;
options?: FieldMetadataItemOption[];
relationType?: RelationType;
currentRecordFilters: RecordFilter[];
objectMetadataItem: ObjectMetadataItem;
currentWorkspaceMember?: CurrentWorkspaceMember;
label?: string;
}) => {
if (isCompositeFieldType(filter.type)) {
return;
}
}): Partial<ObjectRecord> => {
const recordInput: Partial<ObjectRecord> = {};
if (filter.type === 'RAW_JSON') {
return;
}
currentRecordFilters.forEach((filter) => {
const fieldMetadataItem = objectMetadataItem.fields.find(
(field) => field.id === filter.fieldMetadataId,
);
const operands = FILTER_OPERANDS_MAP[filter.type];
if (!operands.some((operand) => operand === filter.operand)) {
throw new Error('Operand not supported for this field type');
}
switch (filter.type) {
case 'TEXT': {
return computeValueFromFilterText(
filter.operand as (typeof FILTER_OPERANDS_MAP)['TEXT'][number],
filter.value,
);
if (!isDefined(fieldMetadataItem)) {
return;
}
case 'RATING':
return computeValueFromFilterRating(
filter.operand as (typeof FILTER_OPERANDS_MAP)['RATING'][number],
filter.value,
options,
);
case 'DATE_TIME':
case 'DATE':
return computeValueFromFilterDate(
filter.operand as (typeof FILTER_OPERANDS_MAP)['DATE_TIME'][number],
filter.value,
);
case 'NUMBER':
return computeValueFromFilterNumber(
filter.operand as (typeof FILTER_OPERANDS_MAP)['NUMBER'][number],
filter.value,
);
case 'BOOLEAN':
return computeValueFromFilterBoolean(
filter.operand as (typeof FILTER_OPERANDS_MAP)['BOOLEAN'][number],
filter.value,
);
case 'TS_VECTOR':
return computeValueFromFilterTSVector(
filter.operand as (typeof FILTER_OPERANDS_MAP)['TS_VECTOR'][number],
filter.value,
);
case 'ARRAY':
return computeValueFromFilterArray(
filter.operand as (typeof FILTER_OPERANDS_MAP)['ARRAY'][number],
filter.value,
);
case 'SELECT':
return computeValueFromFilterSelect(
filter.operand as (typeof FILTER_OPERANDS_MAP)['SELECT'][number],
filter.value,
options,
);
case 'MULTI_SELECT':
return computeValueFromFilterMultiSelect(
filter.operand as (typeof FILTER_OPERANDS_MAP)['MULTI_SELECT'][number],
filter.value,
);
case 'RELATION': {
return computeValueFromFilterRelation(
filter.operand as (typeof FILTER_OPERANDS_MAP)['RELATION'][number],
filter.value,
relationType,
currentWorkspaceMember,
label,
);
}
case 'UUID':
return computeValueFromFilterUUID(
filter.operand as (typeof FILTER_OPERANDS_MAP)['UUID'][number],
filter.value,
);
default:
assertUnreachable(filter.type);
}
};
const computeValueFromFilterText = (
operand: RecordFilterToRecordInputOperand<'TEXT'>,
value: string,
) => {
switch (operand) {
case ViewFilterOperand.CONTAINS:
return value;
case ViewFilterOperand.IS_NOT_EMPTY:
return value;
case ViewFilterOperand.IS_EMPTY:
case ViewFilterOperand.DOES_NOT_CONTAIN:
return undefined;
default:
assertUnreachable(operand);
}
};
// TODO: fix this with Temporal
const computeValueFromFilterDate = (
operand: RecordFilterToRecordInputOperand<'DATE_TIME'>,
value: string,
) => {
switch (operand) {
case ViewFilterOperand.IS:
case ViewFilterOperand.IS_AFTER:
case ViewFilterOperand.IS_BEFORE:
return new Date(value);
case ViewFilterOperand.IS_TODAY:
case ViewFilterOperand.IS_NOT_EMPTY:
case ViewFilterOperand.IS_IN_PAST:
case ViewFilterOperand.IS_IN_FUTURE:
case ViewFilterOperand.IS_RELATIVE:
return new Date();
case ViewFilterOperand.IS_EMPTY:
return undefined;
default:
assertUnreachable(operand);
}
};
const computeValueFromFilterNumber = (
operand: RecordFilterToRecordInputOperand<'NUMBER'>,
value: string,
) => {
switch (operand) {
//TODO: we shouln't create values from those filters as it makes no sense for the user
case ViewFilterOperand.GREATER_THAN_OR_EQUAL:
return Number(value) + 1;
case ViewFilterOperand.LESS_THAN_OR_EQUAL:
return Number(value) - 1;
case ViewFilterOperand.IS_NOT_EMPTY:
return Number(value);
case ViewFilterOperand.IS:
return Number(value);
case ViewFilterOperand.IS_NOT:
return undefined;
case ViewFilterOperand.IS_EMPTY:
return undefined;
default:
assertUnreachable(operand);
}
};
const computeValueFromFilterBoolean = (
operand: RecordFilterToRecordInputOperand<'BOOLEAN'>,
value: string,
) => {
switch (operand) {
case ViewFilterOperand.IS:
return value === 'true';
default:
assertUnreachable(operand);
}
};
const computeValueFromFilterArray = (
operand: RecordFilterToRecordInputOperand<'ARRAY'>,
value: string,
) => {
switch (operand) {
case ViewFilterOperand.CONTAINS:
case ViewFilterOperand.IS_NOT_EMPTY:
return value;
case ViewFilterOperand.DOES_NOT_CONTAIN:
case ViewFilterOperand.IS_EMPTY:
return undefined;
default:
assertUnreachable(operand);
}
};
const computeValueFromFilterRating = (
operand: RecordFilterToRecordInputOperand<'RATING'>,
value: string,
options?: FieldMetadataItemOption[],
) => {
const option = options?.find((option) => option.label === value);
if (!option) {
return undefined;
}
switch (operand) {
case ViewFilterOperand.IS:
case ViewFilterOperand.IS_NOT_EMPTY:
return option.value;
case ViewFilterOperand.GREATER_THAN_OR_EQUAL: {
const plusOne = options?.find(
(opt) => opt.position === option.position + 1,
)?.value;
return plusOne ? plusOne : option.value;
}
case ViewFilterOperand.LESS_THAN_OR_EQUAL: {
const minusOne = options?.find(
(opt) => opt.position === option.position - 1,
)?.value;
return minusOne ? minusOne : option.value;
}
case ViewFilterOperand.IS_EMPTY:
return undefined;
default:
assertUnreachable(operand);
}
};
const computeValueFromFilterSelect = (
operand: RecordFilterToRecordInputOperand<'SELECT'>,
value: string,
options?: FieldMetadataItemOption[],
) => {
switch (operand) {
case ViewFilterOperand.IS:
case ViewFilterOperand.IS_NOT_EMPTY:
try {
const valueParsed = parseJson<string[]>(value)?.[0];
const option = options?.find((option) => option.value === valueParsed);
if (!option) {
return undefined;
}
return option.value;
} catch {
return undefined;
if (fieldMetadataItem.type === 'RELATION') {
const value = buildValueFromFilter({
filter,
options: fieldMetadataItem.options ?? undefined,
relationType: fieldMetadataItem.relation?.type,
currentWorkspaceMember: currentWorkspaceMember ?? undefined,
label: filter.label,
});
if (!isDefined(value)) {
return;
}
case ViewFilterOperand.IS_NOT:
case ViewFilterOperand.IS_EMPTY:
return undefined;
default:
assertUnreachable(operand);
}
};
const computeValueFromFilterMultiSelect = (
operand: RecordFilterToRecordInputOperand<'MULTI_SELECT'>,
value: string,
) => {
switch (operand) {
case ViewFilterOperand.CONTAINS:
case ViewFilterOperand.IS_NOT_EMPTY:
try {
const parsedValue = parseJson<string[]>(value);
return parsedValue ? parsedValue : undefined;
} catch {
return undefined;
}
case ViewFilterOperand.DOES_NOT_CONTAIN:
case ViewFilterOperand.IS_EMPTY:
return undefined;
default:
assertUnreachable(operand);
}
};
const computeValueFromFilterRelation = (
operand: RecordFilterToRecordInputOperand<'RELATION'>,
value: string,
relationType?: RelationType,
currentWorkspaceMember?: CurrentWorkspaceMember,
label?: string,
) => {
switch (operand) {
case ViewFilterOperand.IS: {
const parsedValue = parseJson<{
isCurrentWorkspaceMemberSelected: boolean;
selectedRecordIds: string[];
}>(value);
if (relationType === RelationType.MANY_TO_ONE) {
if (label === 'Assignee') {
return parsedValue?.isCurrentWorkspaceMemberSelected
? currentWorkspaceMember?.id
: undefined;
} else {
return parsedValue?.selectedRecordIds?.[0];
}
}
return undefined; //todo
recordInput[`${fieldMetadataItem.name}Id`] = value;
} else {
recordInput[fieldMetadataItem.name] = buildValueFromFilter({
filter,
options: fieldMetadataItem.options ?? undefined,
});
}
case ViewFilterOperand.IS_NOT:
case ViewFilterOperand.IS_NOT_EMPTY: // todo
case ViewFilterOperand.IS_EMPTY:
return undefined;
default:
assertUnreachable(operand);
}
};
});
const computeValueFromFilterTSVector = (
operand: RecordFilterToRecordInputOperand<'TS_VECTOR'>,
value: string,
) => {
switch (operand) {
case ViewFilterOperand.VECTOR_SEARCH:
return value;
default:
assertUnreachable(operand);
}
};
const computeValueFromFilterUUID = (
operand: RecordFilterToRecordInputOperand<'UUID'>,
value: string,
) => {
switch (operand) {
case ViewFilterOperand.IS:
return value;
default:
assertUnreachable(operand);
}
return recordInput;
};
@@ -0,0 +1,335 @@
import { type CurrentWorkspaceMember } from '@/auth/states/currentWorkspaceMemberState';
import { type FieldMetadataItemOption } from '@/object-metadata/types/FieldMetadataItem';
import { isCompositeFieldType } from '@/object-record/object-filter-dropdown/utils/isCompositeFieldType';
import {
type RecordFilter,
type RecordFilterToRecordInputOperand,
} from '@/object-record/record-filter/types/RecordFilter';
import { FILTER_OPERANDS_MAP } from '@/object-record/record-filter/utils/getRecordFilterOperands';
import { ViewFilterOperand } from 'twenty-shared/types';
import { assertUnreachable, parseJson } from 'twenty-shared/utils';
import { RelationType } from '~/generated-metadata/graphql';
export const buildValueFromFilter = ({
filter,
options,
relationType,
currentWorkspaceMember,
label,
}: {
filter: RecordFilter;
options?: FieldMetadataItemOption[];
relationType?: RelationType;
currentWorkspaceMember?: CurrentWorkspaceMember;
label?: string;
}) => {
if (isCompositeFieldType(filter.type)) {
return;
}
if (filter.type === 'RAW_JSON') {
return;
}
const operands = FILTER_OPERANDS_MAP[filter.type];
if (!operands.some((operand) => operand === filter.operand)) {
throw new Error('Operand not supported for this field type');
}
switch (filter.type) {
case 'TEXT': {
return computeValueFromFilterText(
filter.operand as (typeof FILTER_OPERANDS_MAP)['TEXT'][number],
filter.value,
);
}
case 'RATING':
return computeValueFromFilterRating(
filter.operand as (typeof FILTER_OPERANDS_MAP)['RATING'][number],
filter.value,
options,
);
case 'DATE_TIME':
case 'DATE':
return computeValueFromFilterDate(
filter.operand as (typeof FILTER_OPERANDS_MAP)['DATE_TIME'][number],
filter.value,
);
case 'NUMBER':
return computeValueFromFilterNumber(
filter.operand as (typeof FILTER_OPERANDS_MAP)['NUMBER'][number],
filter.value,
);
case 'BOOLEAN':
return computeValueFromFilterBoolean(
filter.operand as (typeof FILTER_OPERANDS_MAP)['BOOLEAN'][number],
filter.value,
);
case 'TS_VECTOR':
return computeValueFromFilterTSVector(
filter.operand as (typeof FILTER_OPERANDS_MAP)['TS_VECTOR'][number],
filter.value,
);
case 'ARRAY':
return computeValueFromFilterArray(
filter.operand as (typeof FILTER_OPERANDS_MAP)['ARRAY'][number],
filter.value,
);
case 'SELECT':
return computeValueFromFilterSelect(
filter.operand as (typeof FILTER_OPERANDS_MAP)['SELECT'][number],
filter.value,
options,
);
case 'MULTI_SELECT':
return computeValueFromFilterMultiSelect(
filter.operand as (typeof FILTER_OPERANDS_MAP)['MULTI_SELECT'][number],
filter.value,
);
case 'RELATION': {
return computeValueFromFilterRelation(
filter.operand as (typeof FILTER_OPERANDS_MAP)['RELATION'][number],
filter.value,
relationType,
currentWorkspaceMember,
label,
);
}
case 'UUID':
return computeValueFromFilterUUID(
filter.operand as (typeof FILTER_OPERANDS_MAP)['UUID'][number],
filter.value,
);
default:
assertUnreachable(filter.type);
}
};
const computeValueFromFilterText = (
operand: RecordFilterToRecordInputOperand<'TEXT'>,
value: string,
) => {
switch (operand) {
case ViewFilterOperand.CONTAINS:
return value;
case ViewFilterOperand.IS_NOT_EMPTY:
return value;
case ViewFilterOperand.IS_EMPTY:
case ViewFilterOperand.DOES_NOT_CONTAIN:
return undefined;
default:
assertUnreachable(operand);
}
};
// TODO: fix this with Temporal
const computeValueFromFilterDate = (
operand: RecordFilterToRecordInputOperand<'DATE_TIME'>,
value: string,
) => {
switch (operand) {
case ViewFilterOperand.IS:
case ViewFilterOperand.IS_AFTER:
case ViewFilterOperand.IS_BEFORE:
return new Date(value);
case ViewFilterOperand.IS_TODAY:
case ViewFilterOperand.IS_NOT_EMPTY:
case ViewFilterOperand.IS_IN_PAST:
case ViewFilterOperand.IS_IN_FUTURE:
case ViewFilterOperand.IS_RELATIVE:
return new Date();
case ViewFilterOperand.IS_EMPTY:
return undefined;
default:
assertUnreachable(operand);
}
};
const computeValueFromFilterNumber = (
operand: RecordFilterToRecordInputOperand<'NUMBER'>,
value: string,
) => {
switch (operand) {
//TODO: we shouln't create values from those filters as it makes no sense for the user
case ViewFilterOperand.GREATER_THAN_OR_EQUAL:
return Number(value) + 1;
case ViewFilterOperand.LESS_THAN_OR_EQUAL:
return Number(value) - 1;
case ViewFilterOperand.IS_NOT_EMPTY:
return Number(value);
case ViewFilterOperand.IS:
return Number(value);
case ViewFilterOperand.IS_NOT:
return undefined;
case ViewFilterOperand.IS_EMPTY:
return undefined;
default:
assertUnreachable(operand);
}
};
const computeValueFromFilterBoolean = (
operand: RecordFilterToRecordInputOperand<'BOOLEAN'>,
value: string,
) => {
switch (operand) {
case ViewFilterOperand.IS:
return value === 'true';
default:
assertUnreachable(operand);
}
};
const computeValueFromFilterArray = (
operand: RecordFilterToRecordInputOperand<'ARRAY'>,
value: string,
) => {
switch (operand) {
case ViewFilterOperand.CONTAINS:
case ViewFilterOperand.IS_NOT_EMPTY:
return value;
case ViewFilterOperand.DOES_NOT_CONTAIN:
case ViewFilterOperand.IS_EMPTY:
return undefined;
default:
assertUnreachable(operand);
}
};
const computeValueFromFilterRating = (
operand: RecordFilterToRecordInputOperand<'RATING'>,
value: string,
options?: FieldMetadataItemOption[],
) => {
const option = options?.find((option) => option.label === value);
if (!option) {
return undefined;
}
switch (operand) {
case ViewFilterOperand.IS:
case ViewFilterOperand.IS_NOT_EMPTY:
return option.value;
case ViewFilterOperand.GREATER_THAN_OR_EQUAL: {
const plusOne = options?.find(
(opt) => opt.position === option.position + 1,
)?.value;
return plusOne ? plusOne : option.value;
}
case ViewFilterOperand.LESS_THAN_OR_EQUAL: {
const minusOne = options?.find(
(opt) => opt.position === option.position - 1,
)?.value;
return minusOne ? minusOne : option.value;
}
case ViewFilterOperand.IS_EMPTY:
return undefined;
default:
assertUnreachable(operand);
}
};
const computeValueFromFilterSelect = (
operand: RecordFilterToRecordInputOperand<'SELECT'>,
value: string,
options?: FieldMetadataItemOption[],
) => {
switch (operand) {
case ViewFilterOperand.IS:
case ViewFilterOperand.IS_NOT_EMPTY:
try {
const valueParsed = parseJson<string[]>(value)?.[0];
const option = options?.find((option) => option.value === valueParsed);
if (!option) {
return undefined;
}
return option.value;
} catch {
return undefined;
}
case ViewFilterOperand.IS_NOT:
case ViewFilterOperand.IS_EMPTY:
return undefined;
default:
assertUnreachable(operand);
}
};
const computeValueFromFilterMultiSelect = (
operand: RecordFilterToRecordInputOperand<'MULTI_SELECT'>,
value: string,
) => {
switch (operand) {
case ViewFilterOperand.CONTAINS:
case ViewFilterOperand.IS_NOT_EMPTY:
try {
const parsedValue = parseJson<string[]>(value);
return parsedValue ? parsedValue : undefined;
} catch {
return undefined;
}
case ViewFilterOperand.DOES_NOT_CONTAIN:
case ViewFilterOperand.IS_EMPTY:
return undefined;
default:
assertUnreachable(operand);
}
};
const computeValueFromFilterRelation = (
operand: RecordFilterToRecordInputOperand<'RELATION'>,
value: string,
relationType?: RelationType,
currentWorkspaceMember?: CurrentWorkspaceMember,
label?: string,
) => {
switch (operand) {
case ViewFilterOperand.IS: {
const parsedValue = parseJson<{
isCurrentWorkspaceMemberSelected: boolean;
selectedRecordIds: string[];
}>(value);
if (relationType === RelationType.MANY_TO_ONE) {
if (label === 'Assignee') {
return parsedValue?.isCurrentWorkspaceMemberSelected
? currentWorkspaceMember?.id
: undefined;
} else {
return parsedValue?.selectedRecordIds?.[0];
}
}
return undefined; //todo
}
case ViewFilterOperand.IS_NOT:
case ViewFilterOperand.IS_NOT_EMPTY: // todo
case ViewFilterOperand.IS_EMPTY:
return undefined;
default:
assertUnreachable(operand);
}
};
const computeValueFromFilterTSVector = (
operand: RecordFilterToRecordInputOperand<'TS_VECTOR'>,
value: string,
) => {
switch (operand) {
case ViewFilterOperand.VECTOR_SEARCH:
return value;
default:
assertUnreachable(operand);
}
};
const computeValueFromFilterUUID = (
operand: RecordFilterToRecordInputOperand<'UUID'>,
value: string,
) => {
switch (operand) {
case ViewFilterOperand.IS:
return value;
default:
assertUnreachable(operand);
}
};
@@ -1,5 +1,5 @@
import { type ObjectPermissions } from 'twenty-shared/types';
import { checkFieldPermissions } from '@/page-layout/utils/checkFieldPermissions';
import { type ObjectPermissions } from 'twenty-shared/types';
describe('checkFieldPermissions', () => {
const mockObjectPermissions: ObjectPermissions = {
@@ -8,6 +8,8 @@ describe('checkFieldPermissions', () => {
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: true,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
};
it('should return true when no fields are restricted', () => {
@@ -1537,6 +1537,8 @@ export const Catalog: CatalogStory<Story, typeof WidgetRenderer> = {
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
},
],
});
@@ -1552,6 +1554,8 @@ export const Catalog: CatalogStory<Story, typeof WidgetRenderer> = {
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: true,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
},
],
});
@@ -1,3 +1,5 @@
import { ROW_LEVEL_PERMISSION_PREDICATE_FRAGMENT } from '@/settings/roles/graphql/fragments/rowLevelPermissionPredicateFragment';
import { ROW_LEVEL_PERMISSION_PREDICATE_GROUP_FRAGMENT } from '@/settings/roles/graphql/fragments/rowLevelPermissionPredicateGroupFragment';
import { gql } from '@apollo/client';
export const OBJECT_PERMISSION_FRAGMENT = gql`
@@ -8,5 +10,13 @@ export const OBJECT_PERMISSION_FRAGMENT = gql`
canSoftDeleteObjectRecords
canDestroyObjectRecords
restrictedFields
rowLevelPermissionPredicates {
...RowLevelPermissionPredicateFragment
}
rowLevelPermissionPredicateGroups {
...RowLevelPermissionPredicateGroupFragment
}
}
${ROW_LEVEL_PERMISSION_PREDICATE_FRAGMENT}
${ROW_LEVEL_PERMISSION_PREDICATE_GROUP_FRAGMENT}
`;
@@ -19,6 +19,8 @@ export const getObjectPermissionsFromMapByObjectMetadataId = ({
canCreateObjectRecords: true,
canDeleteObjectRecords: true,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
}
);
};
@@ -199,6 +199,8 @@ export const mockedUserData: MockedUser = {
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: true,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
})),
},
locale: 'en',
@@ -229,6 +231,8 @@ export const mockedLimitedPermissionsUserData: MockedUser = {
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: true,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
})),
},
};
@@ -259,6 +263,8 @@ export const mockedOnboardingUserData = (
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: true,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
})),
},
locale: 'en',
@@ -284,6 +284,12 @@ export const ALL_METADATA_RELATIONS = {
objectPermissions: null,
permissionFlags: null,
fieldPermissions: null,
rowLevelPermissionPredicates: {
metadataName: 'rowLevelPermissionPredicate',
},
rowLevelPermissionPredicateGroups: {
metadataName: 'rowLevelPermissionPredicateGroup',
},
},
},
roleTarget: {
@@ -62,6 +62,8 @@ exports[`getMetadataRelatedMetadataNames should return related metadata names fo
exports[`getMetadataRelatedMetadataNames should return related metadata names for role 1`] = `
[
"roleTarget",
"rowLevelPermissionPredicate",
"rowLevelPermissionPredicateGroup",
]
`;
@@ -54,5 +54,7 @@ export const fromCreateRoleInputToFlatRoleToCreate = ({
objectPermissionIds: [],
permissionFlagIds: [],
fieldPermissionIds: [],
rowLevelPermissionPredicateIds: [],
rowLevelPermissionPredicateGroupIds: [],
};
};
@@ -27,5 +27,10 @@ export const fromRoleEntityToFlatRole = (role: RoleEntity): FlatRole => {
objectPermissionIds: role.objectPermissions.map((op) => op.id),
permissionFlagIds: role.permissionFlags.map((pf) => pf.id),
fieldPermissionIds: role.fieldPermissions.map((fp) => fp.id),
rowLevelPermissionPredicateIds: role.rowLevelPermissionPredicates.map(
(rp) => rp.id,
),
rowLevelPermissionPredicateGroupIds:
role.rowLevelPermissionPredicateGroups.map((rp) => rp.id),
};
};
@@ -4,6 +4,8 @@ import GraphQLJSON from 'graphql-type-json';
import { RestrictedFieldsPermissions } from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { RowLevelPermissionPredicateGroupDTO } from 'src/engine/metadata-modules/row-level-permission-predicate/dtos/row-level-permission-predicate-group.dto';
import { RowLevelPermissionPredicateDTO } from 'src/engine/metadata-modules/row-level-permission-predicate/dtos/row-level-permission-predicate.dto';
@ObjectType('ObjectPermission')
export class ObjectPermissionDTO {
@@ -26,4 +28,10 @@ export class ObjectPermissionDTO {
nullable: true,
})
restrictedFields?: RestrictedFieldsPermissions;
@Field(() => [RowLevelPermissionPredicateDTO], { nullable: true })
rowLevelPermissionPredicates?: RowLevelPermissionPredicateDTO[];
@Field(() => [RowLevelPermissionPredicateGroupDTO], { nullable: true })
rowLevelPermissionPredicateGroups?: RowLevelPermissionPredicateGroupDTO[];
}
@@ -61,6 +61,8 @@ describe('FieldPermissionService', () => {
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
},
[fieldRelationMock.objectMetadataId]: {
canReadObjectRecords: true,
@@ -68,6 +70,8 @@ describe('FieldPermissionService', () => {
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
},
},
};
@@ -13,6 +13,8 @@ import { FieldPermissionEntity } from 'src/engine/metadata-modules/object-permis
import { ObjectPermissionEntity } from 'src/engine/metadata-modules/object-permission/object-permission.entity';
import { PermissionFlagEntity } from 'src/engine/metadata-modules/permission-flag/permission-flag.entity';
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
import { RowLevelPermissionPredicateGroupEntity } from 'src/engine/metadata-modules/row-level-permission-predicate/entities/row-level-permission-predicate-group.entity';
import { RowLevelPermissionPredicateEntity } from 'src/engine/metadata-modules/row-level-permission-predicate/entities/row-level-permission-predicate.entity';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
@Entity('role')
@@ -92,4 +94,21 @@ export class RoleEntity extends SyncableEntity implements Required<RoleEntity> {
(fieldPermission: FieldPermissionEntity) => fieldPermission.role,
)
fieldPermissions: Relation<FieldPermissionEntity[]>;
@OneToMany(
() => RowLevelPermissionPredicateEntity,
(rowLevelPermissionPredicate: RowLevelPermissionPredicateEntity) =>
rowLevelPermissionPredicate.role,
)
rowLevelPermissionPredicates: Relation<RowLevelPermissionPredicateEntity[]>;
@OneToMany(
() => RowLevelPermissionPredicateGroupEntity,
(
rowLevelPermissionPredicateGroup: RowLevelPermissionPredicateGroupEntity,
) => rowLevelPermissionPredicateGroup.role,
)
rowLevelPermissionPredicateGroups: Relation<
RowLevelPermissionPredicateGroupEntity[]
>;
}
@@ -17,13 +17,15 @@ import { ObjectPermissionModule } from 'src/engine/metadata-modules/object-permi
import { PermissionFlagEntity } from 'src/engine/metadata-modules/permission-flag/permission-flag.entity';
import { PermissionFlagModule } from 'src/engine/metadata-modules/permission-flag/permission-flag.module';
import { PermissionsModule } from 'src/engine/metadata-modules/permissions/permissions.module';
import { RowLevelPermissionModule } from 'src/engine/metadata-modules/row-level-permission-predicate/row-level-permission.module';
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { RoleResolver } from 'src/engine/metadata-modules/role/role.resolver';
import { RoleService } from 'src/engine/metadata-modules/role/role.service';
import { WorkspaceFlatRoleMapCacheService } from 'src/engine/metadata-modules/role/services/workspace-flat-role-map-cache.service';
import { WorkspaceRolesPermissionsCacheService } from 'src/engine/metadata-modules/role/services/workspace-roles-permissions-cache.service';
import { RowLevelPermissionPredicateGroupEntity } from 'src/engine/metadata-modules/row-level-permission-predicate/entities/row-level-permission-predicate-group.entity';
import { RowLevelPermissionPredicateEntity } from 'src/engine/metadata-modules/row-level-permission-predicate/entities/row-level-permission-predicate.entity';
import { RowLevelPermissionModule } from 'src/engine/metadata-modules/row-level-permission-predicate/row-level-permission.module';
import { UserRoleModule } from 'src/engine/metadata-modules/user-role/user-role.module';
import { WorkspaceCacheModule } from 'src/engine/workspace-cache/workspace-cache.module';
import { WorkspaceMigrationBuilderGraphqlApiExceptionInterceptor } from 'src/engine/workspace-manager/workspace-migration/interceptors/workspace-migration-builder-graphql-api-exception.interceptor';
@@ -39,6 +41,8 @@ import { WorkspaceMigrationModule } from 'src/engine/workspace-manager/workspace
FieldPermissionEntity,
UserWorkspaceEntity,
ObjectMetadataEntity,
RowLevelPermissionPredicateEntity,
RowLevelPermissionPredicateGroupEntity,
]),
UserRoleModule,
AiAgentRoleModule,
@@ -14,6 +14,8 @@ import { ObjectPermissionEntity } from 'src/engine/metadata-modules/object-permi
import { PermissionFlagEntity } from 'src/engine/metadata-modules/permission-flag/permission-flag.entity';
import { RoleTargetEntity } from 'src/engine/metadata-modules/role-target/role-target.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { RowLevelPermissionPredicateGroupEntity } from 'src/engine/metadata-modules/row-level-permission-predicate/entities/row-level-permission-predicate-group.entity';
import { RowLevelPermissionPredicateEntity } from 'src/engine/metadata-modules/row-level-permission-predicate/entities/row-level-permission-predicate.entity';
import { WorkspaceCache } from 'src/engine/workspace-cache/decorators/workspace-cache.decorator';
import { regroupEntitiesByRelatedEntityId } from 'src/engine/workspace-cache/utils/regroup-entities-by-related-entity-id';
import { addFlatEntityToFlatEntityMapsThroughMutationOrThrow } from 'src/engine/workspace-manager/workspace-migration/utils/add-flat-entity-to-flat-entity-maps-through-mutation-or-throw.util';
@@ -34,6 +36,10 @@ export class WorkspaceFlatRoleMapCacheService extends WorkspaceCacheProvider<
private readonly permissionFlagRepository: Repository<PermissionFlagEntity>,
@InjectRepository(FieldPermissionEntity)
private readonly fieldPermissionRepository: Repository<FieldPermissionEntity>,
@InjectRepository(RowLevelPermissionPredicateEntity)
private readonly rowLevelPermissionPredicateRepository: Repository<RowLevelPermissionPredicateEntity>,
@InjectRepository(RowLevelPermissionPredicateGroupEntity)
private readonly rowLevelPermissionPredicateGroupRepository: Repository<RowLevelPermissionPredicateGroupEntity>,
) {
super();
}
@@ -47,6 +53,8 @@ export class WorkspaceFlatRoleMapCacheService extends WorkspaceCacheProvider<
objectPermissions,
permissionFlags,
fieldPermissions,
rowLevelPermissionPredicates,
rowLevelPermissionPredicateGroups,
] = await Promise.all([
this.roleRepository.find({
where: { workspaceId },
@@ -72,6 +80,16 @@ export class WorkspaceFlatRoleMapCacheService extends WorkspaceCacheProvider<
select: ['id', 'roleId'],
withDeleted: true,
}),
this.rowLevelPermissionPredicateRepository.find({
where: { workspaceId },
select: ['id', 'roleId'],
withDeleted: true,
}),
this.rowLevelPermissionPredicateGroupRepository.find({
where: { workspaceId },
select: ['id', 'roleId'],
withDeleted: true,
}),
]);
const [
@@ -79,6 +97,8 @@ export class WorkspaceFlatRoleMapCacheService extends WorkspaceCacheProvider<
objectPermissionsByRoleId,
permissionFlagsByRoleId,
fieldPermissionsByRoleId,
rowLevelPermissionPredicatesByRoleId,
rowLevelPermissionPredicateGroupsByRoleId,
] = (
[
{
@@ -97,6 +117,14 @@ export class WorkspaceFlatRoleMapCacheService extends WorkspaceCacheProvider<
entities: fieldPermissions,
foreignKey: 'roleId',
},
{
entities: rowLevelPermissionPredicates,
foreignKey: 'roleId',
},
{
entities: rowLevelPermissionPredicateGroups,
foreignKey: 'roleId',
},
] as const
).map(regroupEntitiesByRelatedEntityId);
@@ -109,6 +137,10 @@ export class WorkspaceFlatRoleMapCacheService extends WorkspaceCacheProvider<
objectPermissions: objectPermissionsByRoleId.get(roleEntity.id) || [],
permissionFlags: permissionFlagsByRoleId.get(roleEntity.id) || [],
fieldPermissions: fieldPermissionsByRoleId.get(roleEntity.id) || [],
rowLevelPermissionPredicates:
rowLevelPermissionPredicatesByRoleId.get(roleEntity.id) || [],
rowLevelPermissionPredicateGroups:
rowLevelPermissionPredicateGroupsByRoleId.get(roleEntity.id) || [],
} as RoleEntity);
addFlatEntityToFlatEntityMapsThroughMutationOrThrow({
@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { PermissionFlagType } from 'twenty-shared/constants';
import { STANDARD_OBJECT_IDS } from 'twenty-shared/metadata';
import {
type ObjectsPermissions,
@@ -9,7 +10,6 @@ import {
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { Repository } from 'typeorm';
import { PermissionFlagType } from 'twenty-shared/constants';
import { WorkspaceCacheProvider } from 'src/engine/workspace-cache/interfaces/workspace-cache-provider.service';
@@ -42,7 +42,13 @@ export class WorkspaceRolesPermissionsCacheService extends WorkspaceCacheProvide
where: {
workspaceId,
},
relations: ['objectPermissions', 'permissionFlags', 'fieldPermissions'],
relations: [
'objectPermissions',
'permissionFlags',
'fieldPermissions',
'rowLevelPermissionPredicates',
'rowLevelPermissionPredicateGroups',
],
});
const workspaceObjectMetadataCollection =
@@ -132,6 +138,18 @@ export class WorkspaceRolesPermissionsCacheService extends WorkspaceCacheProvide
canSoftDeleteObjectRecords: canSoftDelete,
canDestroyObjectRecords: canDestroy,
restrictedFields,
rowLevelPermissionPredicates:
role.rowLevelPermissionPredicates.filter(
(rowLevelPermissionPredicate) =>
rowLevelPermissionPredicate.objectMetadataId ===
objectMetadataId,
),
rowLevelPermissionPredicateGroups:
role.rowLevelPermissionPredicateGroups.filter(
(rowLevelPermissionPredicateGroup) =>
rowLevelPermissionPredicateGroup.objectMetadataId ===
objectMetadataId,
),
};
}
@@ -15,6 +15,9 @@ export const fromUserWorkspacePermissionsToUserWorkspacePermissionsDto = ({
canSoftDeleteObjectRecords: permissions.canSoftDeleteObjectRecords,
canDestroyObjectRecords: permissions.canDestroyObjectRecords,
restrictedFields: permissions.restrictedFields,
rowLevelPermissionPredicates: permissions.rowLevelPermissionPredicates,
rowLevelPermissionPredicateGroups:
permissions.rowLevelPermissionPredicateGroups,
}),
);
@@ -2,7 +2,7 @@
import { Field, InputType } from '@nestjs/graphql';
import { RowLevelPermissionPredicateGroupLogicalOperator } from 'src/engine/metadata-modules/row-level-permission-predicate/enums/row-level-permission-predicate-group-logical-operator.enum';
import { RowLevelPermissionPredicateGroupLogicalOperator } from 'twenty-shared/types';
@InputType()
export class CreateRowLevelPermissionPredicateGroupInput {
@@ -3,9 +3,10 @@
import { Field, InputType } from '@nestjs/graphql';
import GraphQLJSON from 'graphql-type-json';
import { RowLevelPermissionPredicateOperand } from 'src/engine/metadata-modules/row-level-permission-predicate/enums/row-level-permission-predicate-operand';
import { RowLevelPermissionPredicateValue } from 'src/engine/metadata-modules/row-level-permission-predicate/types/row-level-permission-predicate-value.type';
import {
RowLevelPermissionPredicateOperand,
RowLevelPermissionPredicateValue,
} from 'twenty-shared/types';
@InputType()
export class CreateRowLevelPermissionPredicateInput {
@@ -2,7 +2,7 @@
import { Field, InputType } from '@nestjs/graphql';
import { RowLevelPermissionPredicateGroupLogicalOperator } from 'src/engine/metadata-modules/row-level-permission-predicate/enums/row-level-permission-predicate-group-logical-operator.enum';
import { RowLevelPermissionPredicateGroupLogicalOperator } from 'twenty-shared/types';
@InputType()
export class UpdateRowLevelPermissionPredicateGroupInput {
@@ -3,9 +3,10 @@
import { Field, InputType } from '@nestjs/graphql';
import GraphQLJSON from 'graphql-type-json';
import { RowLevelPermissionPredicateOperand } from 'src/engine/metadata-modules/row-level-permission-predicate/enums/row-level-permission-predicate-operand';
import { RowLevelPermissionPredicateValue } from 'src/engine/metadata-modules/row-level-permission-predicate/types/row-level-permission-predicate-value.type';
import {
RowLevelPermissionPredicateOperand,
RowLevelPermissionPredicateValue,
} from 'twenty-shared/types';
@InputType()
export class UpdateRowLevelPermissionPredicateInput {
@@ -11,11 +11,13 @@ import {
ValidateNested,
} from 'class-validator';
import GraphQLJSON from 'graphql-type-json';
import {
RowLevelPermissionPredicateGroupLogicalOperator,
RowLevelPermissionPredicateOperand,
RowLevelPermissionPredicateValue,
} from 'twenty-shared/types';
import { UUIDScalarType } from 'src/engine/api/graphql/workspace-schema-builder/graphql-types/scalars';
import { RowLevelPermissionPredicateGroupLogicalOperator } from 'src/engine/metadata-modules/row-level-permission-predicate/enums/row-level-permission-predicate-group-logical-operator.enum';
import { RowLevelPermissionPredicateOperand } from 'src/engine/metadata-modules/row-level-permission-predicate/enums/row-level-permission-predicate-operand';
import { RowLevelPermissionPredicateValue } from 'src/engine/metadata-modules/row-level-permission-predicate/types/row-level-permission-predicate-value.type';
@InputType()
export class RowLevelPermissionPredicateInput {
@@ -2,7 +2,7 @@
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import { RowLevelPermissionPredicateGroupLogicalOperator } from 'src/engine/metadata-modules/row-level-permission-predicate/enums/row-level-permission-predicate-group-logical-operator.enum';
import { RowLevelPermissionPredicateGroupLogicalOperator } from 'twenty-shared/types';
registerEnumType(RowLevelPermissionPredicateGroupLogicalOperator, {
name: 'RowLevelPermissionPredicateGroupLogicalOperator',
@@ -3,9 +3,10 @@
import { Field, ObjectType, registerEnumType } from '@nestjs/graphql';
import GraphQLJSON from 'graphql-type-json';
import { RowLevelPermissionPredicateOperand } from 'src/engine/metadata-modules/row-level-permission-predicate/enums/row-level-permission-predicate-operand';
import { RowLevelPermissionPredicateValue } from 'src/engine/metadata-modules/row-level-permission-predicate/types/row-level-permission-predicate-value.type';
import {
RowLevelPermissionPredicateOperand,
RowLevelPermissionPredicateValue,
} from 'twenty-shared/types';
registerEnumType(RowLevelPermissionPredicateOperand, {
name: 'RowLevelPermissionPredicateOperand',
@@ -1,5 +1,9 @@
/* @license Enterprise */
import {
RowLevelPermissionPredicateGroup,
RowLevelPermissionPredicateGroupLogicalOperator,
} from 'twenty-shared/types';
import {
Column,
CreateDateColumn,
@@ -17,7 +21,6 @@ import {
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { RowLevelPermissionPredicateEntity } from 'src/engine/metadata-modules/row-level-permission-predicate/entities/row-level-permission-predicate.entity';
import { RowLevelPermissionPredicateGroupLogicalOperator } from 'src/engine/metadata-modules/row-level-permission-predicate/enums/row-level-permission-predicate-group-logical-operator.enum';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
@Entity({ name: 'rowLevelPermissionPredicateGroup', schema: 'core' })
@@ -28,7 +31,9 @@ import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-enti
])
export class RowLevelPermissionPredicateGroupEntity
extends SyncableEntity
implements Required<RowLevelPermissionPredicateGroupEntity>
implements
Required<RowLevelPermissionPredicateGroupEntity>,
RowLevelPermissionPredicateGroup
{
@PrimaryGeneratedColumn('uuid')
id: string;
@@ -1,5 +1,10 @@
/* @license Enterprise */
import {
RowLevelPermissionPredicate,
RowLevelPermissionPredicateOperand,
RowLevelPermissionPredicateValue,
} from 'twenty-shared/types';
import {
Column,
CreateDateColumn,
@@ -17,8 +22,6 @@ import { FieldMetadataEntity } from 'src/engine/metadata-modules/field-metadata/
import { ObjectMetadataEntity } from 'src/engine/metadata-modules/object-metadata/object-metadata.entity';
import { RoleEntity } from 'src/engine/metadata-modules/role/role.entity';
import { RowLevelPermissionPredicateGroupEntity } from 'src/engine/metadata-modules/row-level-permission-predicate/entities/row-level-permission-predicate-group.entity';
import { RowLevelPermissionPredicateOperand } from 'src/engine/metadata-modules/row-level-permission-predicate/enums/row-level-permission-predicate-operand';
import { type RowLevelPermissionPredicateValue } from 'src/engine/metadata-modules/row-level-permission-predicate/types/row-level-permission-predicate-value.type';
import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-entity.interface';
@Entity({ name: 'rowLevelPermissionPredicate', schema: 'core' })
@@ -31,7 +34,9 @@ import { SyncableEntity } from 'src/engine/workspace-manager/types/syncable-enti
@Index('IDX_RLPP_GROUP_ID', ['rowLevelPermissionPredicateGroupId'])
export class RowLevelPermissionPredicateEntity
extends SyncableEntity
implements Required<RowLevelPermissionPredicateEntity>
implements
Required<RowLevelPermissionPredicateEntity>,
RowLevelPermissionPredicate
{
@PrimaryGeneratedColumn('uuid')
id: string;
@@ -3,6 +3,8 @@
import { UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { RowLevelPermissionPredicateGroupLogicalOperator } from 'twenty-shared/types';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
@@ -11,7 +13,6 @@ import { CreateRowLevelPermissionPredicateGroupInput } from 'src/engine/metadata
import { DeleteRowLevelPermissionPredicateGroupInput } from 'src/engine/metadata-modules/row-level-permission-predicate/dtos/inputs/delete-row-level-permission-predicate-group.input';
import { UpdateRowLevelPermissionPredicateGroupInput } from 'src/engine/metadata-modules/row-level-permission-predicate/dtos/inputs/update-row-level-permission-predicate-group.input';
import { RowLevelPermissionPredicateGroupDTO } from 'src/engine/metadata-modules/row-level-permission-predicate/dtos/row-level-permission-predicate-group.dto';
import { RowLevelPermissionPredicateGroupLogicalOperator } from 'src/engine/metadata-modules/row-level-permission-predicate/enums/row-level-permission-predicate-group-logical-operator.enum';
import { RowLevelPermissionPredicateGroupService } from 'src/engine/metadata-modules/row-level-permission-predicate/services/row-level-permission-predicate-group.service';
@Resolver(() => RowLevelPermissionPredicateGroupDTO)
@@ -3,6 +3,8 @@
import { UseGuards } from '@nestjs/common';
import { Args, Mutation, Query, Resolver } from '@nestjs/graphql';
import { RowLevelPermissionPredicateOperand } from 'twenty-shared/types';
import { WorkspaceEntity } from 'src/engine/core-modules/workspace/workspace.entity';
import { AuthWorkspace } from 'src/engine/decorators/auth/auth-workspace.decorator';
import { NoPermissionGuard } from 'src/engine/guards/no-permission.guard';
@@ -11,7 +13,6 @@ import { CreateRowLevelPermissionPredicateInput } from 'src/engine/metadata-modu
import { DeleteRowLevelPermissionPredicateInput } from 'src/engine/metadata-modules/row-level-permission-predicate/dtos/inputs/delete-row-level-permission-predicate.input';
import { UpdateRowLevelPermissionPredicateInput } from 'src/engine/metadata-modules/row-level-permission-predicate/dtos/inputs/update-row-level-permission-predicate.input';
import { RowLevelPermissionPredicateDTO } from 'src/engine/metadata-modules/row-level-permission-predicate/dtos/row-level-permission-predicate.dto';
import { RowLevelPermissionPredicateOperand } from 'src/engine/metadata-modules/row-level-permission-predicate/enums/row-level-permission-predicate-operand';
import { RowLevelPermissionPredicateService } from 'src/engine/metadata-modules/row-level-permission-predicate/services/row-level-permission-predicate.service';
@Resolver(() => RowLevelPermissionPredicateDTO)
@@ -18,19 +18,16 @@ import { type DestroyRowLevelPermissionPredicateGroupInput } from 'src/engine/me
import { type UpdateRowLevelPermissionPredicateGroupInput } from 'src/engine/metadata-modules/row-level-permission-predicate/dtos/inputs/update-row-level-permission-predicate-group.input';
import { RowLevelPermissionPredicateGroupDTO } from 'src/engine/metadata-modules/row-level-permission-predicate/dtos/row-level-permission-predicate-group.dto';
import { type FlatRowLevelPermissionPredicateGroup } from 'src/engine/metadata-modules/row-level-permission-predicate/types/flat-row-level-permission-predicate-group.type';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
const RLP_CACHE_KEYS = [
'flatRowLevelPermissionPredicateMaps',
'flatRowLevelPermissionPredicateGroupMaps',
] as const;
@Injectable()
export class RowLevelPermissionPredicateGroupService {
constructor(
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {}
async createOne({
@@ -290,9 +287,9 @@ export class RowLevelPermissionPredicateGroupService {
);
}
await this.flatEntityMapsCacheService.invalidateFlatEntityMaps({
workspaceId,
flatMapsKeys: [...RLP_CACHE_KEYS],
});
await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [
'rolesPermissions',
'flatRowLevelPermissionPredicateMaps',
]);
}
}
@@ -28,6 +28,7 @@ import { RowLevelPermissionPredicateGroupDTO } from 'src/engine/metadata-modules
import { RowLevelPermissionPredicateDTO } from 'src/engine/metadata-modules/row-level-permission-predicate/dtos/row-level-permission-predicate.dto';
import { type FlatRowLevelPermissionPredicateGroup } from 'src/engine/metadata-modules/row-level-permission-predicate/types/flat-row-level-permission-predicate-group.type';
import { type FlatRowLevelPermissionPredicate } from 'src/engine/metadata-modules/row-level-permission-predicate/types/flat-row-level-permission-predicate.type';
import { WorkspaceCacheService } from 'src/engine/workspace-cache/services/workspace-cache.service';
import { WorkspaceMigrationBuilderException } from 'src/engine/workspace-manager/workspace-migration/exceptions/workspace-migration-builder-exception';
import { WorkspaceMigrationValidateBuildAndRunService } from 'src/engine/workspace-manager/workspace-migration/services/workspace-migration-validate-build-and-run-service';
@@ -36,6 +37,7 @@ export class RowLevelPermissionPredicateService {
constructor(
private readonly workspaceMigrationValidateBuildAndRunService: WorkspaceMigrationValidateBuildAndRunService,
private readonly flatEntityMapsCacheService: WorkspaceManyOrAllFlatEntityMapsCacheService,
private readonly workspaceCacheService: WorkspaceCacheService,
) {}
async createOne({
@@ -607,6 +609,10 @@ export class RowLevelPermissionPredicateService {
'Validation errors occurred while upserting row level permission predicates',
);
}
await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [
'rolesPermissions',
]);
}
private async runMigration({
@@ -641,5 +647,10 @@ export class RowLevelPermissionPredicateService {
'Validation errors occurred while applying row level permission predicate mutation',
);
}
await this.workspaceCacheService.invalidateAndRecompute(workspaceId, [
'rolesPermissions',
'flatRowLevelPermissionPredicateGroupMaps',
]);
}
}
@@ -257,6 +257,8 @@ describe('WorkspaceEntityManager', () => {
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
},
},
};
@@ -25,6 +25,8 @@ export enum TwentyORMExceptionCode {
TOO_MANY_RECORDS_TO_UPDATE = 'TOO_MANY_RECORDS_TO_UPDATE',
INVALID_INPUT = 'INVALID_INPUT',
ORM_EVENT_DATA_CORRUPTED = 'ORM_EVENT_DATA_CORRUPTED',
RLS_VALIDATION_FAILED = 'RLS_VALIDATION_FAILED',
NO_ROLE_FOUND_FOR_USER_WORKSPACE = 'NO_ROLE_FOUND_FOR_USER_WORKSPACE',
}
const getTwentyORMExceptionUserFriendlyMessage = (
@@ -65,9 +67,13 @@ const getTwentyORMExceptionUserFriendlyMessage = (
return msg`Too many records to update at once.`;
case TwentyORMExceptionCode.INVALID_INPUT:
return msg`Invalid input provided.`;
case TwentyORMExceptionCode.RLS_VALIDATION_FAILED:
return msg`Record does not satisfy security constraints.`;
case TwentyORMExceptionCode.ENUM_TYPE_NAME_NOT_FOUND:
case TwentyORMExceptionCode.ORM_EVENT_DATA_CORRUPTED:
return STANDARD_ERROR_MESSAGE;
case TwentyORMExceptionCode.NO_ROLE_FOUND_FOR_USER_WORKSPACE:
return msg`No role found for user.`;
default:
assertUnreachable(code);
}
@@ -145,6 +145,8 @@ describe('WorkspaceRepository', () => {
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
restrictedFields: {},
rowLevelPermissionPredicates: [],
rowLevelPermissionPredicateGroups: [],
},
};
mockQueryRunner = {} as QueryRunner;
@@ -31,6 +31,7 @@ import { formatData } from 'src/engine/twenty-orm/utils/format-data.util';
import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
import { formatTwentyOrmEventToDatabaseBatchEvent } from 'src/engine/twenty-orm/utils/format-twenty-orm-event-to-database-batch-event.util';
import { getObjectMetadataFromEntityTarget } from 'src/engine/twenty-orm/utils/get-object-metadata-from-entity-target.util';
import { validateRLSPredicatesForRecords } from 'src/engine/twenty-orm/utils/validate-rls-predicates-for-records.util';
export class WorkspaceInsertQueryBuilder<
T extends ObjectLiteral,
@@ -178,6 +179,8 @@ export class WorkspaceInsertQueryBuilder<
this.expressionMap.valuesSet = updatedValues;
}
this.validateRLSPredicatesForInsert();
const result = await super.execute();
const eventSelectQueryBuilder = (
this.connection.manager as WorkspaceEntityManager
@@ -269,6 +272,26 @@ export class WorkspaceInsertQueryBuilder<
}
}
private validateRLSPredicatesForInsert(): void {
const mainAliasTarget = this.getMainAliasTarget();
const objectMetadata = getObjectMetadataFromEntityTarget(
mainAliasTarget,
this.internalContext,
);
const valuesToInsert = Array.isArray(this.expressionMap.valuesSet)
? this.expressionMap.valuesSet
: [this.expressionMap.valuesSet];
validateRLSPredicatesForRecords({
records: valuesToInsert,
objectMetadata,
internalContext: this.internalContext,
authContext: this.authContext,
shouldBypassPermissionChecks: this.shouldBypassPermissionChecks,
});
}
private getMainAliasTarget(): EntityTarget<T> {
const mainAliasTarget = this.expressionMap.mainAlias?.target;
@@ -37,6 +37,7 @@ import { formatData } from 'src/engine/twenty-orm/utils/format-data.util';
import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
import { formatTwentyOrmEventToDatabaseBatchEvent } from 'src/engine/twenty-orm/utils/format-twenty-orm-event-to-database-batch-event.util';
import { getObjectMetadataFromEntityTarget } from 'src/engine/twenty-orm/utils/get-object-metadata-from-entity-target.util';
import { validateRLSPredicatesForRecords } from 'src/engine/twenty-orm/utils/validate-rls-predicates-for-records.util';
import { computeTableName } from 'src/engine/utils/compute-table-name.util';
export class WorkspaceUpdateQueryBuilder<
@@ -175,6 +176,21 @@ export class WorkspaceUpdateQueryBuilder<
this.applyRowLevelPermissionPredicates();
const valuesSet = this.expressionMap.valuesSet ?? {};
const updatedRecords: T[] = formattedBefore.map(
(record, index) =>
({
...record,
...(Array.isArray(valuesSet)
? (valuesSet[index] ?? valuesSet[0] ?? {})
: valuesSet),
}) as T,
);
this.validateRLSPredicatesForUpdate({
updatedRecords,
});
const result = await super.execute();
const after = await eventSelectQueryBuilder.getMany();
@@ -323,12 +339,34 @@ export class WorkspaceUpdateQueryBuilder<
}));
}
const beforeRecordById = new Map<string, T>();
for (const beforeRecord of formattedBefore) {
if (isDefined(beforeRecord.id)) {
beforeRecordById.set(beforeRecord.id, beforeRecord);
}
}
for (const input of this.manyInputs) {
this.expressionMap.valuesSet = input.partialEntity;
this.where({ id: input.criteria });
this.applyRowLevelPermissionPredicates();
const beforeRecord = beforeRecordById.get(input.criteria);
const updatedRecords = beforeRecord
? [
{
...beforeRecord,
...input.partialEntity,
} as T,
]
: [];
this.validateRLSPredicatesForUpdate({
updatedRecords,
});
const result = await super.execute();
results.push(result);
@@ -521,4 +559,26 @@ export class WorkspaceUpdateQueryBuilder<
featureFlagMap: this.featureFlagMap,
});
}
private validateRLSPredicatesForUpdate({
updatedRecords,
}: {
updatedRecords: T[];
}): void {
const mainAliasTarget = this.getMainAliasTarget();
const objectMetadata = getObjectMetadataFromEntityTarget(
mainAliasTarget,
this.internalContext,
);
validateRLSPredicatesForRecords({
records: updatedRecords,
objectMetadata,
internalContext: this.internalContext,
authContext: this.authContext,
shouldBypassPermissionChecks: this.shouldBypassPermissionChecks,
errorMessage:
'Updated record does not satisfy row-level security constraints of your current role',
});
}
}
@@ -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,
);
}
}
};
@@ -74,5 +74,7 @@ export const createStandardRoleFlatMetadata = ({
fieldPermissionIds: [],
objectPermissionIds: [],
roleTargetIds: [],
rowLevelPermissionPredicateIds: [],
rowLevelPermissionPredicateGroupIds: [],
};
};
@@ -1,4 +1,6 @@
import { type RestrictedFieldsPermissions } from './RestrictedFieldsPermissions';
import { type RowLevelPermissionPredicate } from './RowLevelPermissionPredicate';
import { type RowLevelPermissionPredicateGroup } from './RowLevelPermissionPredicateGroup';
export type ObjectPermissions = {
canReadObjectRecords: boolean;
@@ -6,4 +8,6 @@ export type ObjectPermissions = {
canSoftDeleteObjectRecords: boolean;
canDestroyObjectRecords: boolean;
restrictedFields: RestrictedFieldsPermissions;
rowLevelPermissionPredicates: RowLevelPermissionPredicate[];
rowLevelPermissionPredicateGroups: RowLevelPermissionPredicateGroup[];
};
@@ -0,0 +1,14 @@
import { type RowLevelPermissionPredicateOperand } from '@/types/RowLevelPermissionPredicateOperand';
import { type RowLevelPermissionPredicateValue } from '@/types/RowLevelPermissionPredicateValue';
export type RowLevelPermissionPredicate = {
id: string;
fieldMetadataId: string;
objectMetadataId: string;
operand: RowLevelPermissionPredicateOperand;
value: RowLevelPermissionPredicateValue;
subFieldName: string | null;
workspaceMemberFieldMetadataId: string | null;
workspaceMemberSubFieldName: string | null;
roleId: string;
};
@@ -0,0 +1,10 @@
import { type RowLevelPermissionPredicateGroupLogicalOperator } from '@/types/RowLevelPermissionPredicateGroupLogicalOperator';
export type RowLevelPermissionPredicateGroup = {
id: string;
logicalOperator: RowLevelPermissionPredicateGroupLogicalOperator;
objectMetadataId: string;
parentRowLevelPermissionPredicateGroupId: string | null;
positionInRowLevelPermissionPredicateGroup: number | null;
roleId: string;
};
@@ -188,6 +188,14 @@ export { RelationType } from './RelationType';
export type { RelationUpdatePayload } from './RelationUpdatePayload';
export type { RestrictedFieldPermissions } from './RestrictedFieldPermissions';
export type { RestrictedFieldsPermissions } from './RestrictedFieldsPermissions';
export type { RowLevelPermissionPredicate } from './RowLevelPermissionPredicate';
export type { RowLevelPermissionPredicateGroup } from './RowLevelPermissionPredicateGroup';
export { RowLevelPermissionPredicateGroupLogicalOperator } from './RowLevelPermissionPredicateGroupLogicalOperator';
export { RowLevelPermissionPredicateOperand } from './RowLevelPermissionPredicateOperand';
export type {
RelationPredicateValue,
RowLevelPermissionPredicateValue,
} from './RowLevelPermissionPredicateValue';
export { SettingsPath } from './SettingsPath';
export type { Sources } from './SourcesType';
export type {
@@ -15,6 +15,19 @@ export * from './utils/generateILikeFiltersForCompositeFields';
export * from './utils/getEmptyRecordGqlOperationFilter';
export * from './utils/getFilterTypeFromFieldType';
export * from './utils/isExpectedSubFieldName';
export * from './utils/isMatchingArrayFilter';
export * from './utils/isMatchingBooleanFilter';
export * from './utils/isMatchingCurrencyFilter';
export * from './utils/isMatchingDateFilter';
export * from './utils/isMatchingFloatFilter';
export * from './utils/isMatchingMultiSelectFilter';
export * from './utils/isMatchingRatingFilter';
export * from './utils/isMatchingRawJsonFilter';
export * from './utils/isMatchingRichTextV2Filter';
export * from './utils/isMatchingSelectFilter';
export * from './utils/isMatchingStringFilter';
export * from './utils/isMatchingTSVectorFilter';
export * from './utils/isMatchingUUIDFilter';
export * from './utils/validation-schemas/arrayOfStringsOrVariablesSchema';
export * from './utils/validation-schemas/arrayOfUuidsOrVariablesSchema';
export * from './utils/validation-schemas/jsonRelationFilterValueSchema';
@@ -1,4 +1,4 @@
import { isMatchingArrayFilter } from '@/object-record/record-filter/utils/isMatchingArrayFilter';
import { isMatchingArrayFilter } from '@/utils/filter/utils/isMatchingArrayFilter';
describe('isMatchingArrayFilter', () => {
describe('is filter', () => {
@@ -1,4 +1,4 @@
import { isMatchingBooleanFilter } from '@/object-record/record-filter/utils/isMatchingBooleanFilter';
import { isMatchingBooleanFilter } from '@/utils/filter/utils/isMatchingBooleanFilter';
describe('isMatchingBooleanFilter', () => {
describe('eq', () => {
@@ -1,5 +1,5 @@
import { isMatchingCurrencyFilter } from '@/object-record/record-filter/utils/isMatchingCurrencyFilter';
import { type CurrencyFilter } from 'twenty-shared/types';
import { type CurrencyFilter } from '@/types';
import { isMatchingCurrencyFilter } from '@/utils/filter/utils/isMatchingCurrencyFilter';
describe('isMatchingCurrencyFilter', () => {
describe('amountMicros', () => {
@@ -1,4 +1,4 @@
import { isMatchingDateFilter } from '@/object-record/record-filter/utils/isMatchingDateFilter';
import { isMatchingDateFilter } from '@/utils/filter/utils/isMatchingDateFilter';
describe('isMatchingDateFilter', () => {
const testDate = '2023-12-19T12:15:29.810Z';
@@ -1,4 +1,4 @@
import { isMatchingFloatFilter } from '@/object-record/record-filter/utils/isMatchingFloatFilter';
import { isMatchingFloatFilter } from '@/utils/filter/utils/isMatchingFloatFilter';
describe('isMatchingFloatFilter', () => {
describe('eq', () => {
@@ -1,4 +1,4 @@
import { isMatchingStringFilter } from '@/object-record/record-filter/utils/isMatchingStringFilter';
import { isMatchingStringFilter } from '@/utils/filter/utils/isMatchingStringFilter';
describe('isMatchingStringFilter', () => {
describe('eq', () => {
@@ -1,4 +1,4 @@
import { isMatchingTSVectorFilter } from '@/object-record/record-filter/utils/isMatchingTSVectorFilter';
import { isMatchingTSVectorFilter } from '@/utils/filter/utils/isMatchingTSVectorFilter';
describe('isMatchingTSVectorFilter', () => {
describe('search', () => {
@@ -1,4 +1,4 @@
import { isMatchingUUIDFilter } from '@/object-record/record-filter/utils/isMatchingUUIDFilter';
import { isMatchingUUIDFilter } from '@/utils/filter/utils/isMatchingUUIDFilter';
describe('isMatchingUUIDFilter', () => {
const testUUID = '123e4567-e89b-12d3-a456-426655440000';
@@ -1,4 +1,4 @@
import { type ArrayFilter } from 'twenty-shared/types';
import { type ArrayFilter } from '@/types';
export const isMatchingArrayFilter = ({
arrayFilter,
@@ -1,4 +1,4 @@
import { type BooleanFilter } from 'twenty-shared/types';
import { type BooleanFilter } from '@/types';
export const isMatchingBooleanFilter = ({
booleanFilter,
@@ -1,6 +1,6 @@
import { type CurrencyFilter } from '@/types';
import { isDefined } from '@/utils';
import { isNonEmptyString } from '@sniptt/guards';
import { type CurrencyFilter } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
const isMatchingCurrencyCodeFilter = (
currencyCodeFilter: CurrencyFilter['currencyCode'],
@@ -1,5 +1,5 @@
import { type DateFilter } from '@/types';
import { isAfter, isBefore, isEqual, parseISO } from 'date-fns';
import { type DateFilter } from 'twenty-shared/types';
export const isMatchingDateFilter = ({
dateFilter,
@@ -1,4 +1,4 @@
import { type FloatFilter } from 'twenty-shared/types';
import { type FloatFilter } from '@/types';
export const isMatchingFloatFilter = ({
floatFilter,
@@ -1,4 +1,4 @@
import { type MultiSelectFilter } from 'twenty-shared/types';
import { type MultiSelectFilter } from '@/types';
export const isMatchingMultiSelectFilter = ({
multiSelectFilter,
@@ -1,4 +1,4 @@
import { type RatingFilter } from 'twenty-shared/types';
import { type RatingFilter } from '@/types';
export const isMatchingRatingFilter = ({
ratingFilter,
@@ -1,4 +1,4 @@
import { type RawJsonFilter } from 'twenty-shared/types';
import { type RawJsonFilter } from '@/types';
export const isMatchingRawJsonFilter = ({
rawJsonFilter,
@@ -1,5 +1,5 @@
import { type RichTextV2Filter } from '@/types';
import escapeRegExp from 'lodash.escaperegexp';
import { type RichTextV2Filter } from 'twenty-shared/types';
export const isMatchingRichTextV2Filter = ({
richTextV2Filter,
@@ -1,4 +1,4 @@
import { type SelectFilter } from 'twenty-shared/types';
import { type SelectFilter } from '@/types';
export const isMatchingSelectFilter = ({
selectFilter,
@@ -1,5 +1,5 @@
import { type StringFilter } from '@/types';
import escapeRegExp from 'lodash.escaperegexp';
import { type StringFilter } from 'twenty-shared/types';
export const isMatchingStringFilter = ({
stringFilter,
@@ -1,4 +1,4 @@
import { type TSVectorFilter } from 'twenty-shared/types';
import { type TSVectorFilter } from '@/types';
export const isMatchingTSVectorFilter = ({
tsVectorFilter,
@@ -1,4 +1,4 @@
import { type UUIDFilter, type UUIDFilterValue } from 'twenty-shared/types';
import { type UUIDFilter, type UUIDFilterValue } from '@/types';
export const isMatchingUUIDFilter = ({
uuidFilter,
+13
View File
@@ -103,6 +103,19 @@ export { generateILikeFiltersForCompositeFields } from './filter/utils/generateI
export { getEmptyRecordGqlOperationFilter } from './filter/utils/getEmptyRecordGqlOperationFilter';
export { getFilterTypeFromFieldType } from './filter/utils/getFilterTypeFromFieldType';
export { isExpectedSubFieldName } from './filter/utils/isExpectedSubFieldName';
export { isMatchingArrayFilter } from './filter/utils/isMatchingArrayFilter';
export { isMatchingBooleanFilter } from './filter/utils/isMatchingBooleanFilter';
export { isMatchingCurrencyFilter } from './filter/utils/isMatchingCurrencyFilter';
export { isMatchingDateFilter } from './filter/utils/isMatchingDateFilter';
export { isMatchingFloatFilter } from './filter/utils/isMatchingFloatFilter';
export { isMatchingMultiSelectFilter } from './filter/utils/isMatchingMultiSelectFilter';
export { isMatchingRatingFilter } from './filter/utils/isMatchingRatingFilter';
export { isMatchingRawJsonFilter } from './filter/utils/isMatchingRawJsonFilter';
export { isMatchingRichTextV2Filter } from './filter/utils/isMatchingRichTextV2Filter';
export { isMatchingSelectFilter } from './filter/utils/isMatchingSelectFilter';
export { isMatchingStringFilter } from './filter/utils/isMatchingStringFilter';
export { isMatchingTSVectorFilter } from './filter/utils/isMatchingTSVectorFilter';
export { isMatchingUUIDFilter } from './filter/utils/isMatchingUUIDFilter';
export { arrayOfStringsOrVariablesSchema } from './filter/utils/validation-schemas/arrayOfStringsOrVariablesSchema';
export { arrayOfUuidOrVariableSchema } from './filter/utils/validation-schemas/arrayOfUuidsOrVariablesSchema';
export {