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,5 +1,8 @@
import isEmpty from 'lodash.isempty';
import { type ObjectsPermissionsDeprecated } from 'twenty-shared/types';
import {
type ObjectsPermissions,
type ObjectsPermissionsByRoleId,
} from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import {
type DeleteResult,
@@ -53,14 +56,16 @@ import {
} from 'src/engine/twenty-orm/repository/permissions.utils';
import { WorkspaceSelectQueryBuilder } from 'src/engine/twenty-orm/repository/workspace-select-query-builder';
import { WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
import { computePermissionIntersection } from 'src/engine/twenty-orm/utils/compute-permission-intersection.util';
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';
type PermissionOptions = {
shouldBypassPermissionChecks?: boolean;
objectRecordsPermissions?: ObjectsPermissionsDeprecated;
objectRecordsPermissions?: ObjectsPermissions;
};
export class WorkspaceEntityManager extends EntityManager {
@@ -83,35 +88,62 @@ export class WorkspaceEntityManager extends EntityManager {
return this.connection.featureFlagMap;
}
private getPermissionsForRole(
roleId: string,
permissionsPerRoleId: ObjectsPermissionsByRoleId,
): ObjectsPermissions {
if (!isDefined(permissionsPerRoleId?.[roleId])) {
throw new PermissionsException(
`No permissions found for role in datasource (roleId: ${roleId})`,
PermissionsExceptionCode.NO_PERMISSIONS_FOUND_IN_DATASOURCE,
);
}
return permissionsPerRoleId[roleId];
}
override getRepository<Entity extends ObjectLiteral>(
target: EntityTarget<Entity>,
permissionOptions?: {
shouldBypassPermissionChecks?: boolean;
roleId?: string;
},
rolePermissionConfig?: RolePermissionConfig,
authContext?: AuthContext,
): WorkspaceRepository<Entity> {
const dataSource = this.connection;
let objectPermissions = {};
let shouldBypassPermissionChecks = false;
const objectPermissionsByRoleId = dataSource.permissionsPerRoleId;
if (permissionOptions?.roleId) {
const objectPermissionsByRoleId = dataSource.permissionsPerRoleId;
if (
rolePermissionConfig &&
'shouldBypassPermissionChecks' in rolePermissionConfig
) {
shouldBypassPermissionChecks =
rolePermissionConfig.shouldBypassPermissionChecks;
}
if (!isDefined(objectPermissionsByRoleId?.[permissionOptions.roleId])) {
throw new PermissionsException(
`No permissions found for role in datasource (missing ${
!isDefined(objectPermissionsByRoleId)
? 'objectPermissionsByRoleId object'
: `roleId in objectPermissionsByRoleId object (${permissionOptions.roleId})`
})`,
PermissionsExceptionCode.NO_PERMISSIONS_FOUND_IN_DATASOURCE,
if (rolePermissionConfig && 'unionOf' in rolePermissionConfig) {
if (rolePermissionConfig.unionOf.length === 1) {
objectPermissions = this.getPermissionsForRole(
rolePermissionConfig.unionOf[0],
objectPermissionsByRoleId,
);
} else {
objectPermissions = objectPermissionsByRoleId[permissionOptions.roleId];
// TODO: Implement union logic for combining permissions across multiple roles
throw new Error(
'Union permission logic for multiple roles not yet implemented',
);
}
}
if (rolePermissionConfig && 'intersectionOf' in rolePermissionConfig) {
const allRolePermissions = rolePermissionConfig.intersectionOf.map(
(roleId: string) =>
this.getPermissionsForRole(roleId, objectPermissionsByRoleId),
);
objectPermissions = computePermissionIntersection(allRolePermissions);
}
const newRepository = new WorkspaceRepository<Entity>(
this.internalContext,
target,
@@ -119,7 +151,7 @@ export class WorkspaceEntityManager extends EntityManager {
dataSource.featureFlagMap,
this.queryRunner,
objectPermissions,
permissionOptions?.shouldBypassPermissionChecks,
shouldBypassPermissionChecks,
authContext,
);
@@ -132,7 +164,7 @@ export class WorkspaceEntityManager extends EntityManager {
queryRunner?: QueryRunner,
options: {
shouldBypassPermissionChecks?: boolean;
objectRecordsPermissions?: ObjectsPermissionsDeprecated;
objectRecordsPermissions?: ObjectsPermissions;
} = {
shouldBypassPermissionChecks: false,
objectRecordsPermissions: {},
@@ -200,7 +232,7 @@ export class WorkspaceEntityManager extends EntityManager {
conflictPathsOrOptions: string[] | UpsertOptions<Entity>,
permissionOptions?: {
shouldBypassPermissionChecks?: boolean;
objectRecordsPermissions?: ObjectsPermissionsDeprecated;
objectRecordsPermissions?: ObjectsPermissions;
},
selectedColumns: string[] | '*' = '*',
): Promise<InsertResult> {
@@ -389,7 +421,7 @@ export class WorkspaceEntityManager extends EntityManager {
operationType: OperationType;
permissionOptions?: {
shouldBypassPermissionChecks?: boolean;
objectRecordsPermissions?: ObjectsPermissionsDeprecated;
objectRecordsPermissions?: ObjectsPermissions;
};
selectedColumns: string[];
updatedColumns?: string[];