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,6 +1,6 @@
import { type Entity } from '@microsoft/microsoft-graph-types';
import { isDefined } from 'class-validator';
import { type ObjectsPermissionsByRoleIdDeprecated } from 'twenty-shared/types';
import { type ObjectsPermissionsByRoleId } from 'twenty-shared/types';
import {
DataSource,
type DataSourceOptions,
@@ -23,6 +23,7 @@ import {
import { WorkspaceEntityManager } from 'src/engine/twenty-orm/entity-manager/workspace-entity-manager';
import { type WorkspaceQueryRunner } from 'src/engine/twenty-orm/query-runner/workspace-query-runner';
import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
type CreateQueryBuilderOptions = {
calledByWorkspaceEntityManager?: boolean;
@@ -34,7 +35,7 @@ export class WorkspaceDataSource extends DataSource {
featureFlagMapVersion: string;
featureFlagMap: FeatureFlagMap;
rolesPermissionsVersion: string;
permissionsPerRoleId: ObjectsPermissionsByRoleIdDeprecated;
permissionsPerRoleId: ObjectsPermissionsByRoleId;
dataSourceWithOverridenCreateQueryBuilder: WorkspaceDataSource;
isPoolSharingEnabled: boolean;
@@ -44,7 +45,7 @@ export class WorkspaceDataSource extends DataSource {
featureFlagMapVersion: string,
featureFlagMap: FeatureFlagMap,
rolesPermissionsVersion: string,
permissionsPerRoleId: ObjectsPermissionsByRoleIdDeprecated,
permissionsPerRoleId: ObjectsPermissionsByRoleId,
isPoolSharingEnabled: boolean,
) {
super(options);
@@ -60,31 +61,10 @@ export class WorkspaceDataSource extends DataSource {
override getRepository<Entity extends ObjectLiteral>(
target: EntityTarget<Entity>,
shouldBypassPermissionChecks = false,
roleId?: string,
permissionOptions?: RolePermissionConfig,
authContext?: AuthContext,
): WorkspaceRepository<Entity> {
if (shouldBypassPermissionChecks === true) {
return this.manager.getRepository(
target,
{
shouldBypassPermissionChecks: true,
},
authContext,
);
}
if (roleId) {
return this.manager.getRepository(
target,
{
roleId,
},
authContext,
);
}
return this.manager.getRepository(target, undefined, authContext);
return this.manager.getRepository(target, permissionOptions, authContext);
}
override createEntityManager(
@@ -238,9 +218,7 @@ export class WorkspaceDataSource extends DataSource {
this.rolesPermissionsVersion = rolesPermissionsVersion;
}
setRolesPermissions(
permissionsPerRoleId: ObjectsPermissionsByRoleIdDeprecated,
) {
setRolesPermissions(permissionsPerRoleId: ObjectsPermissionsByRoleId) {
this.permissionsPerRoleId = permissionsPerRoleId;
}
@@ -1,4 +1,4 @@
import { type ObjectsPermissionsDeprecated } from 'twenty-shared/types';
import { type ObjectsPermissions } from 'twenty-shared/types';
import { EntityManager } from 'typeorm';
import { EntityPersistExecutor } from 'typeorm/persistence/EntityPersistExecutor';
import { PlainObjectToDatabaseEntityTransformer } from 'typeorm/query-builder/transformer/PlainObjectToDatabaseEntityTransformer';
@@ -75,7 +75,7 @@ describe('WorkspaceEntityManager', () => {
let mockDataSource: WorkspaceDataSource;
let mockPermissionOptions: {
shouldBypassPermissionChecks: boolean;
objectRecordsPermissions?: ObjectsPermissionsDeprecated;
objectRecordsPermissions?: ObjectsPermissions;
};
beforeEach(() => {
@@ -184,10 +184,10 @@ describe('WorkspaceEntityManager', () => {
shouldBypassPermissionChecks: false,
objectRecordsPermissions: {
'test-entity': {
canRead: true,
canUpdate: false,
canSoftDelete: false,
canDestroy: false,
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
restrictedFields: {},
},
},
@@ -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[];
@@ -1,7 +1,7 @@
import { Injectable, Logger } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { type ObjectsPermissionsByRoleIdDeprecated } from 'twenty-shared/types';
import { type ObjectsPermissionsByRoleId } from 'twenty-shared/types';
import { isDefined } from 'twenty-shared/utils';
import { EntitySchema, Repository } from 'typeorm';
@@ -55,7 +55,7 @@ export class WorkspaceDatasourceFactory {
private readonly workspaceEventEmitter: WorkspaceEventEmitter,
private readonly getFromCacheWithRecomputeService: GetDataFromCacheWithRecomputeService<
string,
ObjectsPermissionsByRoleIdDeprecated
ObjectsPermissionsByRoleId
>,
) {}
@@ -239,7 +239,7 @@ export class WorkspaceDatasourceFactory {
workspaceId,
}: {
workspaceId: string;
}): Promise<CacheResult<string, ObjectsPermissionsByRoleIdDeprecated>> {
}): Promise<CacheResult<string, ObjectsPermissionsByRoleId>> {
return this.getFromCacheWithRecomputeService.getFromCacheWithRecompute({
workspaceId,
getCacheData: () =>
@@ -292,7 +292,7 @@ export class WorkspaceDatasourceFactory {
}: {
workspaceDataSource: WorkspaceDataSource;
cachedRolesPermissionsVersion: string;
cachedRolesPermissions: ObjectsPermissionsByRoleIdDeprecated;
cachedRolesPermissions: ObjectsPermissionsByRoleId;
}): Promise<void> {
this.updateWorkspaceDataSourceIfNeeded({
workspaceDataSource,
@@ -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,
) {
@@ -4,6 +4,7 @@ import { type ObjectLiteral } from 'typeorm';
import { WorkspaceDatasourceFactory } from 'src/engine/twenty-orm/factories/workspace-datasource.factory';
import { type WorkspaceRepository } from 'src/engine/twenty-orm/repository/workspace.repository';
import { type RolePermissionConfig } from 'src/engine/twenty-orm/types/role-permission-config';
import { convertClassNameToObjectMetadataName } from 'src/engine/workspace-manager/workspace-sync-metadata/utils/convert-class-to-object-metadata-name.util';
@Injectable()
@@ -15,30 +16,19 @@ export class TwentyORMGlobalManager {
async getRepositoryForWorkspace<T extends ObjectLiteral>(
workspaceId: string,
workspaceEntity: Type<T>,
options?: {
shouldBypassPermissionChecks?: boolean;
roleId?: string;
},
options?: RolePermissionConfig,
): Promise<WorkspaceRepository<T>>;
async getRepositoryForWorkspace<T extends ObjectLiteral>(
workspaceId: string,
objectMetadataName: string,
options?: {
shouldBypassPermissionChecks?: boolean;
roleId?: string;
},
options?: RolePermissionConfig,
): Promise<WorkspaceRepository<T>>;
async getRepositoryForWorkspace<T extends ObjectLiteral>(
workspaceId: string,
workspaceEntityOrObjectMetadataName: Type<T> | string,
options: {
shouldBypassPermissionChecks?: boolean;
roleId?: string;
} = {
shouldBypassPermissionChecks: false,
},
options?: RolePermissionConfig,
): Promise<WorkspaceRepository<T>> {
let objectMetadataName: string;
@@ -55,8 +45,7 @@ export class TwentyORMGlobalManager {
const repository = workspaceDataSource.getRepository<T>(
objectMetadataName,
options.shouldBypassPermissionChecks,
options.roleId,
options,
);
return repository;
@@ -74,8 +74,7 @@ export class TwentyORMManager {
return workspaceDataSource.getRepository<T>(
objectMetadataName,
false,
roleId,
roleId ? { unionOf: [roleId] } : undefined,
);
}
@@ -0,0 +1,6 @@
type RoleId = string;
export type RolePermissionConfig =
| { shouldBypassPermissionChecks: true }
| { unionOf: RoleId[] }
| { intersectionOf: RoleId[] };
@@ -0,0 +1,340 @@
import { type ObjectsPermissions } from 'twenty-shared/types';
import { computePermissionIntersection } from 'src/engine/twenty-orm/utils/compute-permission-intersection.util';
describe('computePermissionIntersection', () => {
const objectMetadataId1 = 'object-1';
const objectMetadataId2 = 'object-2';
describe('edge cases', () => {
it('should return empty object for empty array', () => {
const result = computePermissionIntersection([]);
expect(result).toEqual({});
});
it('should return same permissions for single role', () => {
const permissions: ObjectsPermissions = {
[objectMetadataId1]: {
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
restrictedFields: {},
},
};
const result = computePermissionIntersection([permissions]);
expect(result).toEqual(permissions);
});
});
describe('intersection logic (AND)', () => {
it('should require ALL roles to have permission (true AND true = true)', () => {
const role1Permissions: ObjectsPermissions = {
[objectMetadataId1]: {
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: true,
restrictedFields: {},
},
};
const role2Permissions: ObjectsPermissions = {
[objectMetadataId1]: {
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: true,
restrictedFields: {},
},
};
const result = computePermissionIntersection([
role1Permissions,
role2Permissions,
]);
expect(result[objectMetadataId1]).toEqual({
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: true,
restrictedFields: {},
});
});
it('should deny if ANY role lacks permission (true AND false = false)', () => {
const role1Permissions: ObjectsPermissions = {
[objectMetadataId1]: {
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: true,
restrictedFields: {},
},
};
const role2Permissions: ObjectsPermissions = {
[objectMetadataId1]: {
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: true,
restrictedFields: {},
},
};
const result = computePermissionIntersection([
role1Permissions,
role2Permissions,
]);
expect(result[objectMetadataId1]).toEqual({
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: true,
restrictedFields: {},
});
});
it('should deny all permissions if role lacks access to object entirely', () => {
const role1Permissions: ObjectsPermissions = {
[objectMetadataId1]: {
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: true,
restrictedFields: {},
},
};
const role2Permissions: ObjectsPermissions = {};
const result = computePermissionIntersection([
role1Permissions,
role2Permissions,
]);
expect(result[objectMetadataId1]).toEqual({
canReadObjectRecords: false,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
restrictedFields: {},
});
});
});
describe('multiple objects', () => {
it('should compute intersection independently for each object', () => {
const role1Permissions: ObjectsPermissions = {
[objectMetadataId1]: {
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
restrictedFields: {},
},
[objectMetadataId2]: {
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
restrictedFields: {},
},
};
const role2Permissions: ObjectsPermissions = {
[objectMetadataId1]: {
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: false,
restrictedFields: {},
},
[objectMetadataId2]: {
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
restrictedFields: {},
},
};
const result = computePermissionIntersection([
role1Permissions,
role2Permissions,
]);
expect(result[objectMetadataId1]).toEqual({
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
restrictedFields: {},
});
expect(result[objectMetadataId2]).toEqual({
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: false,
restrictedFields: {},
});
});
});
describe('restricted fields', () => {
it('should compute intersection for restricted fields', () => {
const role1Permissions: ObjectsPermissions = {
[objectMetadataId1]: {
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: true,
restrictedFields: {
email: {
canRead: null,
canUpdate: false,
},
salary: {
canRead: false,
canUpdate: null,
},
},
},
};
const role2Permissions: ObjectsPermissions = {
[objectMetadataId1]: {
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: true,
restrictedFields: {
email: {
canRead: null,
canUpdate: null,
},
salary: {
canRead: null,
canUpdate: false,
},
},
},
};
const result = computePermissionIntersection([
role1Permissions,
role2Permissions,
]);
expect(result[objectMetadataId1].restrictedFields).toEqual({
email: {
canRead: null,
canUpdate: false,
},
salary: {
canRead: false,
canUpdate: false,
},
});
});
it('should handle fields that only exist in some roles', () => {
const role1Permissions: ObjectsPermissions = {
[objectMetadataId1]: {
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: true,
restrictedFields: {
email: {
canRead: false,
canUpdate: false,
},
},
},
};
const role2Permissions: ObjectsPermissions = {
[objectMetadataId1]: {
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: true,
restrictedFields: {
salary: {
canRead: false,
canUpdate: false,
},
},
},
};
const result = computePermissionIntersection([
role1Permissions,
role2Permissions,
]);
expect(result[objectMetadataId1].restrictedFields).toEqual({
email: {
canRead: false,
canUpdate: false,
},
salary: {
canRead: false,
canUpdate: false,
},
});
});
});
describe('three or more roles', () => {
it('should compute intersection across all roles', () => {
const role1: ObjectsPermissions = {
[objectMetadataId1]: {
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: true,
restrictedFields: {},
},
};
const role2: ObjectsPermissions = {
[objectMetadataId1]: {
canReadObjectRecords: true,
canUpdateObjectRecords: true,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: true,
restrictedFields: {},
},
};
const role3: ObjectsPermissions = {
[objectMetadataId1]: {
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: true,
canDestroyObjectRecords: true,
restrictedFields: {},
},
};
const result = computePermissionIntersection([role1, role2, role3]);
expect(result[objectMetadataId1]).toEqual({
canReadObjectRecords: true,
canUpdateObjectRecords: false,
canSoftDeleteObjectRecords: false,
canDestroyObjectRecords: true,
restrictedFields: {},
});
});
});
});
@@ -0,0 +1,92 @@
import {
type ObjectsPermissions,
type RestrictedFieldPermissions,
} from 'twenty-shared/types';
export const computePermissionIntersection = (
permissionsArray: ObjectsPermissions[],
): ObjectsPermissions => {
if (permissionsArray.length === 0) {
return {};
}
if (permissionsArray.length === 1) {
return permissionsArray[0];
}
const result: ObjectsPermissions = {};
const allObjectMetadataIds = new Set<string>();
for (const permissions of permissionsArray) {
for (const id of Object.keys(permissions)) {
allObjectMetadataIds.add(id);
}
}
for (const objectMetadataId of allObjectMetadataIds) {
let canReadObjectRecords = true;
let canUpdateObjectRecords = true;
let canSoftDeleteObjectRecords = true;
let canDestroyObjectRecords = true;
const restrictedFields: Record<string, RestrictedFieldPermissions> = {};
for (const permissions of permissionsArray) {
const objPerm = permissions[objectMetadataId];
if (!objPerm) {
canReadObjectRecords = false;
canUpdateObjectRecords = false;
canSoftDeleteObjectRecords = false;
canDestroyObjectRecords = false;
continue;
}
canReadObjectRecords =
canReadObjectRecords && objPerm.canReadObjectRecords === true;
canUpdateObjectRecords =
canUpdateObjectRecords && objPerm.canUpdateObjectRecords === true;
canSoftDeleteObjectRecords =
canSoftDeleteObjectRecords &&
objPerm.canSoftDeleteObjectRecords === true;
canDestroyObjectRecords =
canDestroyObjectRecords && objPerm.canDestroyObjectRecords === true;
if (objPerm.restrictedFields) {
for (const [fieldName, fieldPerm] of Object.entries(
objPerm.restrictedFields,
)) {
if (!restrictedFields[fieldName]) {
restrictedFields[fieldName] = {
canRead: null,
canUpdate: null,
};
}
const current = restrictedFields[fieldName];
restrictedFields[fieldName] = {
canRead:
current.canRead === false || fieldPerm.canRead === false
? false
: null,
canUpdate:
current.canUpdate === false || fieldPerm.canUpdate === false
? false
: null,
};
}
}
}
result[objectMetadataId] = {
canReadObjectRecords,
canUpdateObjectRecords,
canSoftDeleteObjectRecords,
canDestroyObjectRecords,
restrictedFields,
};
}
return result;
};