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:
+12
-1
@@ -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;
|
||||
|
||||
|
||||
+26
-1
@@ -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',
|
||||
|
||||
+35
-2
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+26
-1
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
+29
-2
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user