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:
+54
-22
@@ -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[];
|
||||
|
||||
Reference in New Issue
Block a user