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;
}
};