feat: multi role permission intersection (#15150)

Implements permission intersection (AND logic) to prevent permission
escalation when agents act on behalf of users.

### Changes:
- **Permission Intersection**: Operations requiring both user AND agent
permissions
- **RoleContext Type**: Unified type supporting single `roleId` or
multiple `roleIds` for intersection
- **CRUD Services**: Updated to accept `roleContext` for granular
permission control
- **Agent Integration**: Chat agents now use user + agent role
intersection for all operations
- **ORM Layer**: Enhanced `getRepository` to support multi-role
permission checks

### Related:
- Part 2 of ["Acting on behalf of user" concept
PR](https://github.com/twentyhq/twenty/pull/15103)

[Closes #1661](https://github.com/twentyhq/core-team-issues/issues/1661)

---------

Co-authored-by: Félix Malfait <felix.malfait@gmail.com>
This commit is contained in:
Abdul Rahman
2025-10-19 13:00:05 +05:30
committed by GitHub
parent f6d133f285
commit 3bec43696f
75 changed files with 909 additions and 489 deletions
@@ -1,4 +1,4 @@
import { type ObjectsPermissionsDeprecated } from 'twenty-shared/types';
import { type ObjectsPermissions } from 'twenty-shared/types';
import {
type DeepPartial,
type FindManyOptions,
@@ -20,7 +20,7 @@ describe('WorkspaceRepository', () => {
let mockEntityManager: jest.Mocked<WorkspaceEntityManager>;
let mockInternalContext: WorkspaceInternalContext;
let mockFeatureFlagMap: FeatureFlagMap;
let mockObjectRecordsPermissions: ObjectsPermissionsDeprecated;
let mockObjectRecordsPermissions: ObjectsPermissions;
let mockQueryRunner: QueryRunner;
beforeEach(() => {
@@ -71,10 +71,10 @@ describe('WorkspaceRepository', () => {
);
mockObjectRecordsPermissions = {
'test-entity': {
canRead: true,
canUpdate: false,
canSoftDelete: false,
canDestroy: false,
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
restrictedFields: {},
},
};
@@ -1,7 +1,7 @@
import { isNonEmptyString } from '@sniptt/guards';
import isEmpty from 'lodash.isempty';
import {
type ObjectsPermissionsDeprecated,
type ObjectsPermissions,
type RestrictedFieldsPermissions,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
@@ -38,7 +38,7 @@ export type OperationType =
type ValidateOperationIsPermittedOrThrowArgs = {
entityName: string;
operationType: OperationType;
objectsPermissions: ObjectsPermissionsDeprecated;
objectsPermissions: ObjectsPermissions;
objectMetadataMaps: ObjectMetadataMaps;
selectedColumns: string[] | '*';
allFieldsSelected: boolean;
@@ -86,7 +86,7 @@ export const validateOperationIsPermittedOrThrow = ({
switch (operationType) {
case 'select':
if (!permissionsForEntity?.canRead) {
if (!permissionsForEntity?.canReadObjectRecords) {
throw new PermissionsException(
PermissionsExceptionMessage.PERMISSION_DENIED,
PermissionsExceptionCode.PERMISSION_DENIED,
@@ -102,7 +102,7 @@ export const validateOperationIsPermittedOrThrow = ({
break;
case 'insert':
case 'update':
if (!permissionsForEntity?.canUpdate) {
if (!permissionsForEntity?.canUpdateObjectRecords) {
throw new PermissionsException(
PermissionsExceptionMessage.PERMISSION_DENIED,
PermissionsExceptionCode.PERMISSION_DENIED,
@@ -124,7 +124,7 @@ export const validateOperationIsPermittedOrThrow = ({
}
break;
case 'delete':
if (!permissionsForEntity?.canDestroy) {
if (!permissionsForEntity?.canDestroyObjectRecords) {
throw new PermissionsException(
PermissionsExceptionMessage.PERMISSION_DENIED,
PermissionsExceptionCode.PERMISSION_DENIED,
@@ -139,7 +139,7 @@ export const validateOperationIsPermittedOrThrow = ({
break;
case 'restore':
case 'soft-delete':
if (!permissionsForEntity?.canSoftDelete) {
if (!permissionsForEntity?.canSoftDeleteObjectRecords) {
throw new PermissionsException(
PermissionsExceptionMessage.PERMISSION_DENIED,
PermissionsExceptionCode.PERMISSION_DENIED,
@@ -166,7 +166,7 @@ export const validateOperationIsPermittedOrThrow = ({
type ValidateQueryIsPermittedOrThrowArgs = {
expressionMap: QueryExpressionMap;
objectsPermissions: ObjectsPermissionsDeprecated;
objectsPermissions: ObjectsPermissions;
objectMetadataMaps: ObjectMetadataMaps;
shouldBypassPermissionChecks: boolean;
};
@@ -1,4 +1,4 @@
import { type ObjectsPermissionsDeprecated } from 'twenty-shared/types';
import { type ObjectsPermissions } from 'twenty-shared/types';
import {
DeleteQueryBuilder,
type DeleteResult,
@@ -25,21 +25,21 @@ import { type WorkspaceSoftDeleteQueryBuilder } from 'src/engine/twenty-orm/repo
import { type WorkspaceUpdateQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-update-query-builder';
import { applyTableAliasOnWhereCondition } from 'src/engine/twenty-orm/utils/apply-table-alias-on-where-condition';
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 { computeTableName } from 'src/engine/utils/compute-table-name.util';
import { formatTwentyOrmEventToDatabaseBatchEvent } from 'src/engine/twenty-orm/utils/format-twenty-orm-event-to-database-batch-event.util';
export class WorkspaceDeleteQueryBuilder<
T extends ObjectLiteral,
> extends DeleteQueryBuilder<T> {
private objectRecordsPermissions: ObjectsPermissionsDeprecated;
private objectRecordsPermissions: ObjectsPermissions;
private shouldBypassPermissionChecks: boolean;
private internalContext: WorkspaceInternalContext;
private authContext?: AuthContext;
private featureFlagMap?: FeatureFlagMap;
constructor(
queryBuilder: DeleteQueryBuilder<T>,
objectRecordsPermissions: ObjectsPermissionsDeprecated,
objectRecordsPermissions: ObjectsPermissions,
internalContext: WorkspaceInternalContext,
shouldBypassPermissionChecks: boolean,
authContext?: AuthContext,
@@ -1,4 +1,4 @@
import { type ObjectsPermissionsDeprecated } from 'twenty-shared/types';
import { type ObjectsPermissions } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
type EntityTarget,
@@ -29,13 +29,13 @@ import { type WorkspaceSoftDeleteQueryBuilder } from 'src/engine/twenty-orm/repo
import { type WorkspaceUpdateQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-update-query-builder';
import { formatData } from 'src/engine/twenty-orm/utils/format-data.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';
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';
export class WorkspaceInsertQueryBuilder<
T extends ObjectLiteral,
> extends InsertQueryBuilder<T> {
private objectRecordsPermissions: ObjectsPermissionsDeprecated;
private objectRecordsPermissions: ObjectsPermissions;
private shouldBypassPermissionChecks: boolean;
private internalContext: WorkspaceInternalContext;
private authContext?: AuthContext;
@@ -47,7 +47,7 @@ export class WorkspaceInsertQueryBuilder<
constructor(
queryBuilder: InsertQueryBuilder<T>,
objectRecordsPermissions: ObjectsPermissionsDeprecated,
objectRecordsPermissions: ObjectsPermissions,
internalContext: WorkspaceInternalContext,
shouldBypassPermissionChecks: boolean,
authContext?: AuthContext,
@@ -1,4 +1,4 @@
import { type ObjectsPermissionsDeprecated } from 'twenty-shared/types';
import { type ObjectsPermissions } from 'twenty-shared/types';
import {
type EntityTarget,
type ObjectLiteral,
@@ -30,14 +30,14 @@ import { getObjectMetadataFromEntityTarget } from 'src/engine/twenty-orm/utils/g
export class WorkspaceSelectQueryBuilder<
T extends ObjectLiteral,
> extends SelectQueryBuilder<T> {
objectRecordsPermissions: ObjectsPermissionsDeprecated;
objectRecordsPermissions: ObjectsPermissions;
shouldBypassPermissionChecks: boolean;
internalContext: WorkspaceInternalContext;
authContext?: AuthContext;
featureFlagMap?: FeatureFlagMap;
constructor(
queryBuilder: SelectQueryBuilder<T>,
objectRecordsPermissions: ObjectsPermissionsDeprecated,
objectRecordsPermissions: ObjectsPermissions,
internalContext: WorkspaceInternalContext,
shouldBypassPermissionChecks: boolean,
authContext?: AuthContext,
@@ -1,4 +1,4 @@
import { type ObjectsPermissionsDeprecated } from 'twenty-shared/types';
import { type ObjectsPermissions } from 'twenty-shared/types';
import {
type EntityTarget,
type InsertQueryBuilder,
@@ -22,13 +22,13 @@ import { type WorkspaceDeleteQueryBuilder } from 'src/engine/twenty-orm/reposito
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 { 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';
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';
export class WorkspaceSoftDeleteQueryBuilder<
T extends ObjectLiteral,
> extends SoftDeleteQueryBuilder<T> {
private objectRecordsPermissions: ObjectsPermissionsDeprecated;
private objectRecordsPermissions: ObjectsPermissions;
private shouldBypassPermissionChecks: boolean;
private internalContext: WorkspaceInternalContext;
private authContext?: AuthContext;
@@ -36,7 +36,7 @@ export class WorkspaceSoftDeleteQueryBuilder<
constructor(
queryBuilder: SoftDeleteQueryBuilder<T>,
objectRecordsPermissions: ObjectsPermissionsDeprecated,
objectRecordsPermissions: ObjectsPermissions,
internalContext: WorkspaceInternalContext,
shouldBypassPermissionChecks: boolean,
authContext?: AuthContext,
@@ -1,4 +1,4 @@
import { type ObjectsPermissionsDeprecated } from 'twenty-shared/types';
import { type ObjectsPermissions } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
type EntityTarget,
@@ -28,13 +28,13 @@ import { WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/wo
import { type WorkspaceSoftDeleteQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-soft-delete-query-builder';
import { formatData } from 'src/engine/twenty-orm/utils/format-data.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';
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';
export class WorkspaceUpdateQueryBuilder<
T extends ObjectLiteral,
> extends UpdateQueryBuilder<T> {
private objectRecordsPermissions: ObjectsPermissionsDeprecated;
private objectRecordsPermissions: ObjectsPermissions;
private shouldBypassPermissionChecks: boolean;
private internalContext: WorkspaceInternalContext;
private authContext?: AuthContext;
@@ -50,7 +50,7 @@ export class WorkspaceUpdateQueryBuilder<
constructor(
queryBuilder: UpdateQueryBuilder<T>,
objectRecordsPermissions: ObjectsPermissionsDeprecated,
objectRecordsPermissions: ObjectsPermissions,
internalContext: WorkspaceInternalContext,
shouldBypassPermissionChecks: boolean,
authContext?: AuthContext,
@@ -1,4 +1,4 @@
import { type ObjectsPermissionsDeprecated } from 'twenty-shared/types';
import { type ObjectsPermissions } from 'twenty-shared/types';
import {
type DeepPartial,
type DeleteResult,
@@ -40,7 +40,7 @@ export class WorkspaceRepository<
private readonly internalContext: WorkspaceInternalContext;
private shouldBypassPermissionChecks: boolean;
private featureFlagMap: FeatureFlagMap;
public readonly objectRecordsPermissions?: ObjectsPermissionsDeprecated;
public readonly objectRecordsPermissions?: ObjectsPermissions;
private authContext?: AuthContext;
declare manager: WorkspaceEntityManager;
@@ -50,7 +50,7 @@ export class WorkspaceRepository<
manager: WorkspaceEntityManager,
featureFlagMap: FeatureFlagMap,
queryRunner?: QueryRunner,
objectRecordsPermissions?: ObjectsPermissionsDeprecated,
objectRecordsPermissions?: ObjectsPermissions,
shouldBypassPermissionChecks = false,
authContext?: AuthContext,
) {