Row level permissions - POC 1 (#16599)

## Context
This PR adds the core structure for RLS implementation:
- RLS data model
- RLS service layer
- RLS WorkspaceMigration and Syncable Entity + cache + Validations
- RLS resolver layer
- ORM layer with RLS Predicate to ORM WHERE clause conversion with
workspaceMember record transposition

Tests are missing though

<!-- CURSOR_SUMMARY -->
---

> [!NOTE]
> Establishes core row-level permissions infrastructure and enforcement
across the stack.
> 
> - Backend: new `rowLevelPermissionPredicate` and
`rowLevelPermissionPredicateGroup` entities, TypeORM migration, feature
flag `IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED`, flat-entity
maps/cache wiring, services and GraphQL resolvers for CRUD, and
inclusion of `workspaceMember` in auth context
> - ORM: applies row-level permission predicates to SELECT, DELETE, and
SOFT DELETE query builders; propagates context through
GlobalWorkspaceOrmManager/EntityManager
> - GraphQL: generated schema/types/queries/mutations for
creating/updating/deleting/fetching predicates and groups
> - Frontend: settings page adds a gated "Record-level" section
(placeholder) and metadata error handler labels for new entities
> 
> <sup>Written by [Cursor
Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit
fe955cc4588a92157afa6795fb574189a4be1e93. This will update automatically
on new commits. Configure
[here](https://cursor.com/dashboard?tab=bugbot).</sup>
<!-- /CURSOR_SUMMARY -->

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
Weiko
2025-12-31 16:15:17 +01:00
committed by GitHub
parent 888a50c3be
commit 15b21570ae
120 changed files with 5649 additions and 29 deletions
@@ -0,0 +1,220 @@
/* @license Enterprise */
import {
Brackets,
NotBrackets,
type ObjectLiteral,
type WhereExpressionBuilder,
} from 'typeorm';
import { type FeatureFlagMap } from 'src/engine/core-modules/feature-flag/interfaces/feature-flag-map.interface';
import { type WorkspaceInternalContext } from 'src/engine/twenty-orm/interfaces/workspace-internal-context.interface';
import { GraphqlQueryFilterFieldParser } from 'src/engine/api/graphql/graphql-query-runner/graphql-query-parsers/graphql-query-filter/graphql-query-filter-field.parser';
import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { FeatureFlagKey } from 'src/engine/core-modules/feature-flag/enums/feature-flag-key.enum';
import { type FlatObjectMetadata } from 'src/engine/metadata-modules/flat-object-metadata/types/flat-object-metadata.type';
import { type WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder';
import { buildRowLevelPermissionRecordFilter } from 'src/engine/twenty-orm/utils/build-row-level-permission-record-filter.util';
type ApplyRowLevelPermissionPredicatesArgs<T extends ObjectLiteral> = {
queryBuilder: WorkspaceSelectQueryBuilder<T>;
objectMetadata: FlatObjectMetadata;
internalContext: WorkspaceInternalContext;
authContext: AuthContext;
featureFlagMap: FeatureFlagMap;
};
export const applyRowLevelPermissionPredicates = <T extends ObjectLiteral>({
queryBuilder,
objectMetadata,
internalContext,
authContext,
featureFlagMap,
}: ApplyRowLevelPermissionPredicatesArgs<T>): void => {
if (
featureFlagMap[
FeatureFlagKey.IS_ROW_LEVEL_PERMISSION_PREDICATES_ENABLED
] !== true
) {
return;
}
const roleId = authContext.userWorkspaceId
? internalContext.userWorkspaceRoleMap[authContext.userWorkspaceId]
: undefined;
const recordFilter = buildRowLevelPermissionRecordFilter({
flatRowLevelPermissionPredicateMaps:
internalContext.flatRowLevelPermissionPredicateMaps,
flatRowLevelPermissionPredicateGroupMaps:
internalContext.flatRowLevelPermissionPredicateGroupMaps,
flatFieldMetadataMaps: internalContext.flatFieldMetadataMaps,
objectMetadata,
roleId,
authContext,
});
if (!recordFilter || Object.keys(recordFilter).length === 0) {
return;
}
applyObjectRecordFilterToQueryBuilder({
queryBuilder,
objectNameSingular: objectMetadata.nameSingular,
recordFilter,
fieldParser: new GraphqlQueryFilterFieldParser(
objectMetadata,
internalContext.flatFieldMetadataMaps,
),
});
};
const applyObjectRecordFilterToQueryBuilder = <T extends ObjectLiteral>({
queryBuilder,
objectNameSingular,
recordFilter,
fieldParser,
}: {
queryBuilder: WorkspaceSelectQueryBuilder<T>;
objectNameSingular: string;
recordFilter: Record<string, unknown>;
fieldParser: GraphqlQueryFilterFieldParser;
}): void => {
if (!recordFilter || Object.keys(recordFilter).length === 0) {
return;
}
const whereCondition = new Brackets((qb) => {
Object.entries(recordFilter).forEach(([key, value], index) => {
parseKeyFilter({
queryBuilder: qb,
objectNameSingular,
key,
value,
isFirst: index === 0,
fieldParser,
});
});
});
if (queryBuilder.expressionMap.wheres.length === 0) {
queryBuilder.where(whereCondition);
} else {
queryBuilder.andWhere(whereCondition);
}
};
const parseKeyFilter = ({
queryBuilder,
objectNameSingular,
key,
value,
isFirst,
fieldParser,
}: {
queryBuilder: WhereExpressionBuilder;
objectNameSingular: string;
key: string;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
value: any;
isFirst: boolean;
fieldParser: GraphqlQueryFilterFieldParser;
}): void => {
switch (key) {
case 'and': {
const andWhereCondition = new Brackets((qb) => {
value.forEach((filter: Record<string, unknown>, index: number) => {
const whereCondition = new Brackets((qb2) => {
Object.entries(filter).forEach(
([subFilterKey, subFilterValue], subIndex) => {
parseKeyFilter({
queryBuilder: qb2,
objectNameSingular,
key: subFilterKey,
value: subFilterValue,
isFirst: subIndex === 0,
fieldParser,
});
},
);
});
if (index === 0) {
qb.where(whereCondition);
} else {
qb.andWhere(whereCondition);
}
});
});
if (isFirst) {
queryBuilder.where(andWhereCondition);
} else {
queryBuilder.andWhere(andWhereCondition);
}
break;
}
case 'or': {
const orWhereCondition = new Brackets((qb) => {
value.forEach((filter: Record<string, unknown>, index: number) => {
const whereCondition = new Brackets((qb2) => {
Object.entries(filter).forEach(
([subFilterKey, subFilterValue], subIndex) => {
parseKeyFilter({
queryBuilder: qb2,
objectNameSingular,
key: subFilterKey,
value: subFilterValue,
isFirst: subIndex === 0,
fieldParser,
});
},
);
});
if (index === 0) {
qb.where(whereCondition);
} else {
qb.orWhere(whereCondition);
}
});
});
if (isFirst) {
queryBuilder.where(orWhereCondition);
} else {
queryBuilder.andWhere(orWhereCondition);
}
break;
}
case 'not': {
const notWhereCondition = new NotBrackets((qb) => {
Object.entries(value).forEach(
([subFilterKey, subFilterValue], subIndex) => {
parseKeyFilter({
queryBuilder: qb,
objectNameSingular,
key: subFilterKey,
value: subFilterValue,
isFirst: subIndex === 0,
fieldParser,
});
},
);
});
if (isFirst) {
queryBuilder.where(notWhereCondition);
} else {
queryBuilder.andWhere(notWhereCondition);
}
break;
}
default:
fieldParser.parse(queryBuilder, objectNameSingular, key, value, isFirst);
break;
}
};
@@ -0,0 +1,201 @@
/* @license Enterprise */
import {
RecordFilterGroupLogicalOperator,
type CompositeFieldSubFieldName,
type PartialFieldMetadataItemOption,
type RecordGqlOperationFilter,
} from 'twenty-shared/types';
import {
computeRecordGqlOperationFilter,
convertViewFilterValueToString,
getFilterTypeFromFieldType,
isDefined,
type RecordFilter,
type RecordFilterGroup,
} from 'twenty-shared/utils';
import { type AuthContext } from 'src/engine/core-modules/auth/types/auth-context.type';
import { isCompositeFieldMetadataType } from 'src/engine/metadata-modules/field-metadata/utils/is-composite-field-metadata-type.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';
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';
type BuildRowLevelPermissionRecordFilterArgs = {
flatRowLevelPermissionPredicateMaps: FlatRowLevelPermissionPredicateMaps;
flatRowLevelPermissionPredicateGroupMaps: FlatRowLevelPermissionPredicateGroupMaps;
flatFieldMetadataMaps: FlatEntityMaps<FlatFieldMetadata>;
objectMetadata: FlatObjectMetadata;
roleId: string | undefined;
authContext: AuthContext;
};
export const buildRowLevelPermissionRecordFilter = ({
flatRowLevelPermissionPredicateMaps,
flatRowLevelPermissionPredicateGroupMaps,
flatFieldMetadataMaps,
objectMetadata,
roleId,
authContext,
}: BuildRowLevelPermissionRecordFilterArgs): RecordGqlOperationFilter | null => {
if (!isDefined(roleId)) {
return null;
}
const predicates = Object.values(flatRowLevelPermissionPredicateMaps.byId)
.filter(isDefined)
.filter(
(predicate) =>
predicate.roleId === roleId &&
predicate.objectMetadataId === objectMetadata.id &&
!isDefined(predicate.deletedAt),
);
if (predicates.length === 0) {
return null;
}
const fieldMetadataMapById = flatFieldMetadataMaps.byId;
const workspaceMember = authContext.workspaceMember;
const recordFilters = predicates
.map((predicate) => {
const fieldMetadata = fieldMetadataMapById[predicate.fieldMetadataId];
if (!isDefined(fieldMetadata)) {
throw new PermissionsException(
`Field metadata not found for row level predicate ${predicate.id}`,
PermissionsExceptionCode.FIELD_METADATA_NOT_FOUND,
);
}
const workspaceMemberFieldMetadataId =
predicate.workspaceMemberFieldMetadataId;
let predicateValue: RowLevelPermissionPredicateValue = predicate.value;
if (isDefined(workspaceMemberFieldMetadataId)) {
const workspaceMemberFieldMetadata =
fieldMetadataMapById[workspaceMemberFieldMetadataId];
if (!isDefined(workspaceMemberFieldMetadata)) {
throw new PermissionsException(
`Workspace member field metadata not found for row level predicate ${predicate.id}`,
PermissionsExceptionCode.FIELD_METADATA_NOT_FOUND,
);
}
if (!isDefined(workspaceMember)) {
return null;
}
const rawWorkspaceMemberValue = Object.entries(workspaceMember).find(
([key]) => key === workspaceMemberFieldMetadata.name,
)?.[1];
const workspaceMemberSubFieldName =
predicate.workspaceMemberSubFieldName;
if (
isDefined(workspaceMemberSubFieldName) &&
isDefined(rawWorkspaceMemberValue) &&
isCompositeFieldMetadataType(workspaceMemberFieldMetadata.type) &&
typeof rawWorkspaceMemberValue === 'object'
) {
predicateValue = rawWorkspaceMemberValue[workspaceMemberSubFieldName];
} else {
predicateValue = rawWorkspaceMemberValue;
}
if (!isDefined(predicateValue)) {
throw new PermissionsException(
`Workspace member data missing for field ${workspaceMemberFieldMetadata.name}`,
PermissionsExceptionCode.INVALID_ARG,
);
}
}
const effectiveSubFieldName = predicate.subFieldName as
| CompositeFieldSubFieldName
| undefined;
return {
id: predicate.id,
fieldMetadataId: predicate.fieldMetadataId,
value: convertViewFilterValueToString(predicateValue),
type: getFilterTypeFromFieldType(fieldMetadata.type),
operand: predicate.operand as unknown as RecordFilter['operand'],
recordFilterGroupId: predicate.rowLevelPermissionPredicateGroupId,
subFieldName: effectiveSubFieldName,
} satisfies RecordFilter;
})
.filter(isDefined);
const predicateGroupsById = flatRowLevelPermissionPredicateGroupMaps.byId;
const relevantGroupIds = new Set<string>();
for (const predicate of predicates) {
if (isDefined(predicate.rowLevelPermissionPredicateGroupId)) {
relevantGroupIds.add(predicate.rowLevelPermissionPredicateGroupId);
let parentGroupId =
predicateGroupsById[predicate.rowLevelPermissionPredicateGroupId]
?.parentRowLevelPermissionPredicateGroupId;
while (isDefined(parentGroupId) && !relevantGroupIds.has(parentGroupId)) {
relevantGroupIds.add(parentGroupId);
parentGroupId =
predicateGroupsById[parentGroupId]
?.parentRowLevelPermissionPredicateGroupId;
}
}
}
const recordFilterGroups: RecordFilterGroup[] = [...relevantGroupIds]
.map((groupId) => predicateGroupsById[groupId])
.filter(isDefined)
.filter(
(predicateGroup) =>
predicateGroup.roleId === roleId &&
!isDefined(predicateGroup.deletedAt),
)
.map((predicateGroup) => ({
id: predicateGroup.id,
logicalOperator:
predicateGroup.logicalOperator ===
RowLevelPermissionPredicateGroupLogicalOperator.OR
? RecordFilterGroupLogicalOperator.OR
: RecordFilterGroupLogicalOperator.AND,
parentRecordFilterGroupId:
predicateGroup.parentRowLevelPermissionPredicateGroupId,
}));
const fieldMetadataItems = predicates
.map((predicate) => fieldMetadataMapById[predicate.fieldMetadataId])
.filter(isDefined)
.map((field) => ({
id: field.id,
name: field.name,
type: field.type,
label: field.label,
options: field.options as PartialFieldMetadataItemOption[],
}));
return computeRecordGqlOperationFilter({
recordFilters,
recordFilterGroups,
fields: fieldMetadataItems,
filterValueDependencies: {
currentWorkspaceMemberId: authContext.workspaceMemberId,
},
});
};