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
@@ -118,8 +118,19 @@ describe('WorkspaceRepository', () => {
idByUniversalIdentifier: {},
universalIdentifiersByApplicationId: {},
},
flatRowLevelPermissionPredicateMaps: {
byId: {},
idByUniversalIdentifier: {},
universalIdentifiersByApplicationId: {},
},
flatRowLevelPermissionPredicateGroupMaps: {
byId: {},
idByUniversalIdentifier: {},
universalIdentifiersByApplicationId: {},
},
objectIdByNameSingular: {},
featureFlagsMap: {},
featureFlagsMap: {} as FeatureFlagMap,
userWorkspaceRoleMap: {},
eventEmitterService: {} as unknown,
} as unknown as WorkspaceInternalContext;
@@ -23,6 +23,7 @@ import { validateQueryIsPermittedOrThrow } from 'src/engine/twenty-orm/repositor
import { type WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder';
import { type WorkspaceSoftDeleteQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-soft-delete-query-builder';
import { type WorkspaceUpdateQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-update-query-builder';
import { applyRowLevelPermissionPredicates } from 'src/engine/twenty-orm/utils/apply-row-level-permission-predicates.util';
import { applyTableAliasOnWhereCondition } from 'src/engine/twenty-orm/utils/apply-table-alias-on-where-condition';
import { computeEventSelectQueryBuilder } from 'src/engine/twenty-orm/utils/compute-event-select-query-builder.util';
import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
@@ -57,7 +58,7 @@ export class WorkspaceDeleteQueryBuilder<
override clone(): this {
const clonedQueryBuilder = super.clone();
return new WorkspaceDeleteQueryBuilder(
const workspaceDeleteQueryBuilder = new WorkspaceDeleteQueryBuilder(
clonedQueryBuilder,
this.objectRecordsPermissions,
this.internalContext,
@@ -65,10 +66,13 @@ export class WorkspaceDeleteQueryBuilder<
this.authContext,
this.featureFlagMap,
) as this;
return workspaceDeleteQueryBuilder;
}
override async execute(): Promise<DeleteResult & { generatedMaps: T[] }> {
try {
this.applyRowLevelPermissionPredicates();
validateQueryIsPermittedOrThrow({
expressionMap: this.expressionMap,
objectsPermissions: this.objectRecordsPermissions,
@@ -157,6 +161,27 @@ export class WorkspaceDeleteQueryBuilder<
return mainAliasTarget;
}
private applyRowLevelPermissionPredicates(): void {
if (this.shouldBypassPermissionChecks) {
return;
}
const mainAliasTarget = this.getMainAliasTarget();
const objectMetadata = getObjectMetadataFromEntityTarget(
mainAliasTarget,
this.internalContext,
);
applyRowLevelPermissionPredicates({
queryBuilder: this as unknown as WorkspaceSelectQueryBuilder<T>,
objectMetadata,
internalContext: this.internalContext,
authContext: this.authContext,
featureFlagMap: this.featureFlagMap,
});
}
override select(): WorkspaceSelectQueryBuilder<T> {
throw new TwentyORMException(
'This builder cannot morph into a select builder',
@@ -24,6 +24,7 @@ import { WorkspaceDeleteQueryBuilder } from 'src/engine/twenty-orm/repository/wo
import { WorkspaceInsertQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-insert-query-builder';
import { WorkspaceSoftDeleteQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-soft-delete-query-builder';
import { WorkspaceUpdateQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-update-query-builder';
import { applyRowLevelPermissionPredicates } from 'src/engine/twenty-orm/utils/apply-row-level-permission-predicates.util';
import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
import { getObjectMetadataFromEntityTarget } from 'src/engine/twenty-orm/utils/get-object-metadata-from-entity-target.util';
@@ -58,7 +59,7 @@ export class WorkspaceSelectQueryBuilder<
override clone(): this {
const clonedQueryBuilder = super.clone();
return new WorkspaceSelectQueryBuilder(
const workspaceSelectQueryBuilder = new WorkspaceSelectQueryBuilder(
clonedQueryBuilder,
this.objectRecordsPermissions,
this.internalContext,
@@ -66,6 +67,8 @@ export class WorkspaceSelectQueryBuilder<
this.authContext,
this.featureFlagMap,
) as this;
return workspaceSelectQueryBuilder;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -327,6 +330,7 @@ export class WorkspaceSelectQueryBuilder<
}
private validatePermissions(): void {
this.applyRowLevelPermissionPredicates();
validateQueryIsPermittedOrThrow({
expressionMap: this.expressionMap,
objectsPermissions: this.objectRecordsPermissions,
@@ -338,7 +342,9 @@ export class WorkspaceSelectQueryBuilder<
}
private getMainAliasTarget(): EntityTarget<T> {
const mainAliasTarget = this.expressionMap.mainAlias?.target;
const mainAlias = this.expressionMap.mainAlias;
const mainAliasTarget = mainAlias?.target;
if (!mainAliasTarget) {
throw new TwentyORMException(
@@ -349,4 +355,31 @@ export class WorkspaceSelectQueryBuilder<
return mainAliasTarget;
}
private applyRowLevelPermissionPredicates(): void {
if (this.shouldBypassPermissionChecks) {
return;
}
// Subqueries don't have entity metadata, skip permission predicates
// Permissions are already applied on the original entity query
if (this.expressionMap.mainAlias?.subQuery) {
return;
}
const mainAliasTarget = this.getMainAliasTarget();
const objectMetadata = getObjectMetadataFromEntityTarget(
mainAliasTarget,
this.internalContext,
);
applyRowLevelPermissionPredicates({
queryBuilder: this,
objectMetadata,
internalContext: this.internalContext,
authContext: this.authContext,
featureFlagMap: this.featureFlagMap,
});
}
}
@@ -22,6 +22,7 @@ import { validateQueryIsPermittedOrThrow } from 'src/engine/twenty-orm/repositor
import { type WorkspaceDeleteQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-delete-query-builder';
import { type WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder';
import { type WorkspaceUpdateQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-update-query-builder';
import { applyRowLevelPermissionPredicates } from 'src/engine/twenty-orm/utils/apply-row-level-permission-predicates.util';
import { applyTableAliasOnWhereCondition } from 'src/engine/twenty-orm/utils/apply-table-alias-on-where-condition';
import { computeEventSelectQueryBuilder } from 'src/engine/twenty-orm/utils/compute-event-select-query-builder.util';
import { formatResult } from 'src/engine/twenty-orm/utils/format-result.util';
@@ -57,7 +58,7 @@ export class WorkspaceSoftDeleteQueryBuilder<
override clone(): this {
const clonedQueryBuilder = super.clone();
return new WorkspaceSoftDeleteQueryBuilder(
const workspaceSoftDeleteQueryBuilder = new WorkspaceSoftDeleteQueryBuilder(
clonedQueryBuilder,
this.objectRecordsPermissions,
this.internalContext,
@@ -65,10 +66,13 @@ export class WorkspaceSoftDeleteQueryBuilder<
this.authContext,
this.featureFlagMap,
) as this;
return workspaceSoftDeleteQueryBuilder;
}
override async execute(): Promise<UpdateResult> {
try {
this.applyRowLevelPermissionPredicates();
validateQueryIsPermittedOrThrow({
expressionMap: this.expressionMap,
objectsPermissions: this.objectRecordsPermissions,
@@ -184,4 +188,25 @@ export class WorkspaceSoftDeleteQueryBuilder<
return mainAliasTarget;
}
private applyRowLevelPermissionPredicates(): void {
if (this.shouldBypassPermissionChecks) {
return;
}
const mainAliasTarget = this.getMainAliasTarget();
const objectMetadata = getObjectMetadataFromEntityTarget(
mainAliasTarget,
this.internalContext,
);
applyRowLevelPermissionPredicates({
queryBuilder: this as unknown as WorkspaceSelectQueryBuilder<T>,
objectMetadata,
internalContext: this.internalContext,
authContext: this.authContext,
featureFlagMap: this.featureFlagMap,
});
}
}
@@ -30,6 +30,7 @@ import { validateQueryIsPermittedOrThrow } from 'src/engine/twenty-orm/repositor
import { type WorkspaceDeleteQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-delete-query-builder';
import { WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder';
import { type WorkspaceSoftDeleteQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-soft-delete-query-builder';
import { applyRowLevelPermissionPredicates } from 'src/engine/twenty-orm/utils/apply-row-level-permission-predicates.util';
import { applyTableAliasOnWhereCondition } from 'src/engine/twenty-orm/utils/apply-table-alias-on-where-condition';
import { computeEventSelectQueryBuilder } from 'src/engine/twenty-orm/utils/compute-event-select-query-builder.util';
import { formatData } from 'src/engine/twenty-orm/utils/format-data.util';
@@ -77,7 +78,7 @@ export class WorkspaceUpdateQueryBuilder<
override clone(): this {
const clonedQueryBuilder = super.clone();
return new WorkspaceUpdateQueryBuilder(
const workspaceUpdateQueryBuilder = new WorkspaceUpdateQueryBuilder(
clonedQueryBuilder as UpdateQueryBuilder<T>,
this.objectRecordsPermissions,
this.internalContext,
@@ -85,6 +86,8 @@ export class WorkspaceUpdateQueryBuilder<
this.authContext,
this.featureFlagMap,
) as this;
return workspaceUpdateQueryBuilder;
}
override async execute(): Promise<UpdateResult> {
@@ -92,7 +95,6 @@ export class WorkspaceUpdateQueryBuilder<
if (this.manyInputs) {
return this.executeMany();
}
validateQueryIsPermittedOrThrow({
expressionMap: this.expressionMap,
objectsPermissions: this.objectRecordsPermissions,
@@ -171,6 +173,8 @@ export class WorkspaceUpdateQueryBuilder<
this.internalContext.flatFieldMetadataMaps,
);
this.applyRowLevelPermissionPredicates();
const result = await super.execute();
const after = await eventSelectQueryBuilder.getMany();
@@ -323,6 +327,8 @@ export class WorkspaceUpdateQueryBuilder<
this.expressionMap.valuesSet = input.partialEntity;
this.where({ id: input.criteria });
this.applyRowLevelPermissionPredicates();
const result = await super.execute();
results.push(result);
@@ -494,4 +500,25 @@ export class WorkspaceUpdateQueryBuilder<
return this;
}
private applyRowLevelPermissionPredicates(): void {
if (this.shouldBypassPermissionChecks) {
return;
}
const mainAliasTarget = this.getMainAliasTarget();
const objectMetadata = getObjectMetadataFromEntityTarget(
mainAliasTarget,
this.internalContext,
);
applyRowLevelPermissionPredicates({
queryBuilder: this as unknown as WorkspaceSelectQueryBuilder<T>,
objectMetadata,
internalContext: this.internalContext,
authContext: this.authContext,
featureFlagMap: this.featureFlagMap,
});
}
}